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
+28
View File
@@ -0,0 +1,28 @@
# DB — 기본 SQLite. PostgreSQL 전환 시 (ASSUMPTIONS.md 참고):
# DATABASE_URL=postgresql://abap:abap@localhost:5432/abap_index
#DATABASE_URL=sqlite:///./data/index.db
# LLM (Stage 3 요약) — OpenAI 호환 엔드포인트
# 키가 없으면 비워두고 SUMMARIZE_BACKEND=file 로 두면 된다 (프롬프트를 data/llm_jobs/ 에
# 쌓아두고 사람·에이전트가 채우는 방식 — README "Stage 3 을 LLM 키 없이 돌리기" 참고)
LLM_BASE_URL=
LLM_API_KEY=
LLM_MODEL=glm-5.2
# 동시 호출 수. 올리면 429 가 늘어나므로 아래 재시도 설정과 함께 조정한다.
LLM_CONCURRENCY=4
LLM_MAX_RETRIES=5
LLM_BACKOFF_BASE=2.0
LLM_BACKOFF_MAX=60.0
LLM_TIMEOUT_S=180
# /ingest 가 뒤이어 돌리는 요약의 백엔드: api | file | off
SUMMARIZE_BACKEND=api
# 임베딩 — 모델 미정(계획서 §10). 비워두면 벡터 검색 비활성(FTS만)
EMBED_BASE_URL=
EMBED_API_KEY=
EMBED_MODEL=
# 질의 API 서버
INDEX_HOST=127.0.0.1
INDEX_PORT=8100
+15
View File
@@ -0,0 +1,15 @@
.env
__pycache__/
*.pyc
.pytest_cache/
data/raw/
data/normalized/
data/parsed/
data/summaries/
data/index.db*
eval/results/
# 위키: programs/·packages/·concepts/ 는 커밋(사람 교정 대상). 로직 조각은 프로그램 문서 안의 섹션.
wiki/_review.md
.venv/
data/llm_jobs/
abap_indexing.egg-info/
+71
View File
@@ -0,0 +1,71 @@
# ASSUMPTIONS — 계획서 §10 미결사항에 대한 진행 가정
계획서(§11.7)에 따라, 미결 사항은 가정을 두고 진행하고 여기에 기록한다.
## 1. DB: PostgreSQL 대신 SQLite 로 시작 (계획서 §2 와 다름)
- **가정/사유**: 개발 장비에 PostgreSQL 이 없고, 임베딩 모델이 미정이라 pgvector 가 아직
필요하지 않다. SQLite + FTS5 로 키워드/2-gram 검색은 충분히 동작한다.
- **전환 경로**: `index/schema_postgres.sql` 에 계획서 §6.1 원안(pgvector, tsvector) 유지.
`DATABASE_URL` 을 postgresql:// 로 바꾸고 `index/db.py` 에 pg 백엔드를 추가하면 된다
(현재는 sqlite:// 만 구현 — pg URL 지정 시 명시적 에러).
- 전량(1만 본) 적재 후 성능 문제가 보이면 그 시점에 전환한다.
## 2. 임베딩 모델 미정 → 벡터 검색 보류
- GLM 계열 임베딩 API 사용 가능 여부 미확인. 확정 전까지 `search_programs`
FTS(단어 + 한글 2-gram) + **용어 사전 동의어 확장** + LIKE 로만 동작한다.
- 동의어 확장은 원질의와 **분리된 2단 검색**이다(`query/expand.py`). `fts_or` 가 토큰과 2-gram 을
전부 OR 로 잇는 재현율 편향 구조라, 동의어를 같은 식에 섞으면 정밀도가 더 나빠진다.
동의어 히트는 `EXPANDED_WEIGHT` 감쇠를 받고 `matched_by='동의어 확장'` 으로 표시된다.
- **이 감쇠 계수는 아직 측정으로 정하지 않았다** — 정답셋(수정사항 7번)이 필요하다.
- `EMBED_*` 환경변수와 `index/embed.py` 어댑터 자리는 확보. 모델 확정 시
요약 텍스트 임베딩 → 하이브리드(RRF) 로 확장한다.
## 3. GLM-5.2 인터페이스 / 키 미확보 상태의 실행 경로
- OpenAI 호환(`/chat/completions`, `response_format: json_object`)으로 가정
(`summarize/llm_client.py`).
- **키가 없어도 Stage 3 을 돌릴 수 있다** (2026-09-16 추가). 백엔드 3종:
- `api` — 환경변수 키로 호출. 429/5xx 는 지수 백오프로 재시도한다(`Retry-After` 존중).
- `file` — 프롬프트를 `data/llm_jobs/<id>.prompt.md` 로 내놓고 `<id>.response.json` 을 기다린다.
사람이 채워도 되고 코딩 에이전트가 프롬프트를 읽고 채워도 된다. `summarize/jobs.py` CLI 가
목록·조회·**스키마 검증 저장**을 돕는다. 프롬프트 id 는 내용 해시라 재실행해도 같은 파일을 가리킨다.
- `fake` — 스키마만 맞는 더미. 파이프라인 배선 검증용이며 **품질 검증이 아니다**.
- 무료 티어(OpenRouter `z-ai/glm-5.2:free`)로 시도한 이력이 `llm_usage_log` 에 남아 있고
실패 원인은 전부 429 였다. 재시도가 없던 탓에 unit 이 `failed` 로 굳었다 — 지금은 재시도한다.
- 프로그램 요약은 unit 프롬프트의 문맥이므로 **요약이 먼저 확정돼야** unit 프롬프트를 내놓는다.
(요약이 나중에 채워지면 unit 프롬프트 내용이 달라져 같은 unit 을 두 번 답해야 한다.)
## 4. 전역 클래스/펑션그룹 소스 미수집
- `zcl_fi_common` 등은 호출 그래프에서 외부 노드(external_name)로만 표시.
## 5. 테이블 정보(DD02T/DD03L) / T-Code(TSTC) 미확보
- `ddic_table`, `ddic_field`, `tcode` 테이블은 스키마만 존재. 데이터 확보 시 적재.
## 6. 소스 파일 단위
- 수집 파일 1개 = 프로그램 1개(MAIN_PROGRAM) 로 가정. 배열로 여러 프로그램이 오는
형식이 발견되면 `ingest/normalize.py` 의 classify 를 확장한다.
## 7. 재수집/증분
- 전체 재수집 후 해시 비교 방식으로 가정. `program.source_hash` 가 같으면 스킵,
다르면 구조 재적재 + `summary_status='stale'` 마킹(요약 재생성 대상).
## 8. unit 단위 증분은 v2
- 현재 로더는 프로그램 단위로 삭제 후 재적재. 해시가 같은 unit 의 요약·로직 조각은
unit_id 가 동일하므로 재적재 후에도 보존된다(그 외 unit 의 조각은 삭제 → 재추출).
## 9. 인덱스 단위 = LLM 이 골라낸 로직 조각 (2026-09-16 구조 변경)
- 계획서의 "unit 마다 요약 하나" 대신, LLM 이 unit 코드를 읽고 **의미 있는 로직 조각**을
0개 이상 골라내 각각 설명을 붙인다. 의미 없는 코드(선언·초기화·ALV 설정 …)는 인덱스에서 버린다.
- LLM 에게 코드를 보여주는 창은 파서 unit(300줄 초과 시 sub_chunks 창)이고, 프로그램 요약을
문맥으로 함께 준다. 근거·대안 비교는 `docs/logic-chunk-design.md`.
- 조각의 줄 번호는 first_line 앵커로 검증·보정하고, 테이블·호출은 파서로 다시 뽑는다.
- 얇은 unit 색인(이름·주석·한 줄 요약)은 남겨 버려진 코드도 unit 이름으로는 도달 가능하게 한다.
- 조각의 크기 기준(3~80줄), 버리는 코드의 범위는 프롬프트 규칙이며 파일럿 결과로 조정한다.
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+164
View File
@@ -0,0 +1,164 @@
# abap-indexing
ABAP 소스코드 인덱싱 서버. 약 1만 본의 ABAP 프로그램 소스와 프로그램 메타정보를
정규화 → 구조 파싱 → (LLM 로직 조각 추출) → 인덱싱하고, 질의 API 를 제공한다.
계획서: `ABAP_INDEXING_PLAN.md` (Downloads), 진행 가정: `ASSUMPTIONS.md`.
**조각의 코드 원문은 로직만이고, 붙여넣는 데 필요한 선언(정의부)은 따로 수집한다** — ABAP 은
내부테이블·스트럭처를 TOP 에 선언하므로 로직만 복사하면 컴파일되지 않는다.
쓸지 말지는 붙여넣는 쪽 LLM 이 정한다. → `docs/definition-block-design.md`
**인덱스의 1차 단위는 로직 조각(logic chunk)** 이다. 파서가 자른 FORM/METHOD 는 LLM 에게 코드를
나눠 보여주는 창이고, LLM 이 그 안에서 업무적으로 의미 있는 조각(특정 SQL, DB 갱신, BAPI 호출,
집계 LOOP …)을 골라내 설명을 붙인다. 의미 없는 코드는 인덱스에서 버린다. → `docs/logic-chunk-design.md`
**소비자**: `opencode-be` 레포의 OpenCode 커스텀 툴(`.opencode/tool/_index_client.ts`)이
이 서버의 HTTP API 를 호출한다 (기본 `http://127.0.0.1:8100`).
## 파이프라인
```
data/raw/*.txt (수집 JSON)
→ python -m ingest.normalize # Stage 1: 줄바꿈 아티팩트 제거, 프로그램/인클루드 파일화
→ python -m parser.run # Stage 2: unit·참조·데이터플로우·호출그래프·정의부 (LLM 없이)
→ python -m index.loader # Stage 4: SQLite(FTS5) 적재
→ python -m summarize.runner # Stage 3: 프로그램 요약 → unit 별 로직 조각 추출
→ python -m wiki_out.run --all # OKF v0.2 위키 (프로그램 문서 + tables/functions 엔티티)
→ python -m query.api # Stage 5: 질의 API :8100
→ python -m eval.run_eval # Stage 6: recall / 조각 적중 / 추적 평가
```
## 설치 / 실행
```bash
python -m venv .venv && .venv/Scripts/python -m pip install -e ".[dev]" # Linux/mac: .venv/bin/python
cp .env.example .env
# data/raw/ 에 수집 파일(ZFI01.txt, ZFIR10070.txt 형식) 배치
python -m ingest.normalize && python -m parser.run && python -m index.loader
python -m query.api # 127.0.0.1:8100
```
테스트: `python -m pytest` (샘플 데이터가 있으면 §4.6 실측 검증 포함)
파서나 색인 로직을 바꿨으면 소스가 그대로라도 재적재가 필요하다:
`python -m index.loader --force`
(정의부를 처음 붙이는 기존 DB 도 `python -m parser.run && python -m index.loader --force` 한 번이 필요하다 —
`declaration` 테이블은 빈 채로 만들어지고 재적재 때 채워진다.)
### 서버가 코드 변경을 반영하지 않을 때
`/dashboard``/wiki-viewer`**템플릿(html)을 요청마다 다시 읽지만**, 파이썬 모듈은
프로세스에 캐시된다(`sys.modules`). 그래서 CSS·JS 를 고치면 새로고침만으로 보이지만
`wiki_out/viewer.py` 같은 **파이썬 코드를 고치면 서버를 다시 띄워야** 한다.
(실제로 조각 내비게이션을 추가했는데 사이드바가 계속 0 으로 보인 적이 있다.)
- 개발 중에는 자동 재시작: `python -m query.api --reload`
- 낡았는지 확인: `GET /health``started_at` 이 코드 수정 시각보다 이전인지 본다
## Stage 3 을 LLM 키 없이 돌리기
`--llm file` 은 프롬프트를 `data/llm_jobs/` 에 파일로 내놓고 응답 파일을 기다린다.
사람이 채워도 되고, 코딩 에이전트가 프롬프트를 읽고 채워도 된다.
```bash
python -m summarize.runner --llm file --program ZFIR10070 --limit 3 # 1) 프롬프트 내놓기
python -m summarize.jobs list --pending # 2) 대기 목록
python -m summarize.jobs show <job_id> # 프롬프트 읽기
python -m summarize.jobs answer <job_id> --file answer.json # 3) 응답 저장(스키마 검증)
python -m summarize.runner --llm file --program ZFIR10070 --limit 3 # 4) 같은 명령 → 적재
```
프로그램 요약은 unit 프롬프트의 **문맥**이므로 먼저 답해야 한다 — 요약이 대기 중이면 그 프로그램의
unit 프롬프트는 나오지 않는다(요약이 채워진 뒤 프롬프트 내용이 달라져 두 번 답하게 되는 것을 막는다).
백엔드 3종: `--llm api`(환경변수 키) / `--llm file`(키 불필요) / `--llm fake`(배선 검증용 더미).
`/ingest` 가 뒤이어 돌리는 요약의 백엔드는 `SUMMARIZE_BACKEND=api|file|off` 로 고른다.
큐 현황은 `GET /summaries/jobs` 또는 `python -m summarize.jobs stats`.
## 결과를 다른 PC 로 가져가기
### 방법 A — 단일 HTML 스냅샷 (파이썬·서버 불필요)
```bash
python -m wiki_out.viewer # → data/wiki-viewer.html (약 1.2 MB)
python -m query.dashboard # → data/dashboard.html (약 1.4 MB)
```
데이터가 파일 안에 박히므로 **그 파일 하나만 복사해 브라우저로 열면** 된다.
로직 조각의 소스 원문과 자연어 설명, 테이블·펑션 문서, 관측소 수치가 모두 들어 있다.
외부 스크립트·이미지를 받지 않으며, 웹폰트는 못 받아도 시스템 한글 글꼴로 폴백한다(폐쇄망 가능).
안 되는 것: 조각 **검색**(`/search/logic`)과 `trace_variable`·`get_call_graph`·`get_table_usage`
같은 구조 도구. 이건 DB 질의라 서버가 필요하다 (뷰어 사이드바의 문서 검색은 동작).
### 방법 B — 인덱스째로 옮기기 (전체 기능)
가져갈 것은 **소스 + `data/index.db` + `wiki/`** 뿐이다 (합쳐 약 14 MB).
```
config/ ingest/ parser/ index/ summarize/ query/ wiki_out/ eval/ docs/
pyproject.toml
data/index.db ← 조각·요약·심볼·호출그래프 + 인클루드 소스 원문까지 전부 들어있다
wiki/ ← 사람 교정본이 있으면 함께 (없으면 재생성 가능)
```
`data/normalized/`·`data/parsed/`·`data/llm_jobs/`**가져갈 필요 없다** — 재생성 가능하고,
조각의 소스 원문은 `include.code` 로 DB 안에 있다. 새 PC 에서:
```bash
python -m venv .venv && .venv/Scripts/python -m pip install -e ".[dev]"
python -m query.api # 관측소·뷰어·검색 API 전부 동작
python -m wiki_out.run --all # (선택) 위키 재생성 — DB 만으로 된다
```
실측 검증: 위 최소 세트만 복사한 디렉토리에서 위키 재생성(283건 검증 통과)·스냅샷 생성·
`search_logic`·`get_table_usage`·`trace_variable`·서버 기동이 모두 정상 동작했다.
## API
| Method | Path | 설명 |
|---|---|---|
| GET | `/health` | 상태 + 적재 통계 |
| GET | `/search/logic?q=&top_k=&program=&kind=` | **로직 조각 검색** — 자연어 로직 질의의 1차 진입점. 프로그램 단위로 묶어 반환 |
| GET | `/summaries/jobs` | LLM 키 없이 돌릴 때의 프롬프트 대기 큐 |
| GET | `/chunks/{chunk_id}` | 조각 메타 + 코드 원문 + **정의부**(붙여넣기용 선언, `?decls=false` 로 끔). chunk_id 의 `#``%23` 으로 인코딩 |
| GET | `/programs/{name}/declarations?scope=` | 프로그램의 선언 카탈로그 (정의부) |
| GET | `/programs/{name}/chunks?unit_id=` | 프로그램(또는 unit)의 조각 목록 |
| GET | `/search/programs?q=&top_k=` | 프로그램 검색 — 이름·타이틀 기반 얇은 색인 (FTS + 한글 2-gram) |
| GET | `/search/units?q=&program=` | unit 검색 — 이름·주석·한 줄 요약 기반 얇은 색인 |
| GET | `/programs/{name}/summary` | 프로그램 요약(LLM) + 구조 요약(파서, 조각 목록 포함) |
| GET | `/programs/{name}/source` | 정규화된 전체 소스 (opencode-be 의 fetch_abap 용) |
| GET | `/programs/{name}/units/{unit}/code` | unit 코드 원문(400줄 상한) + 요약 + 정의부 |
| GET | `/programs/{name}/trace/{symbol}` | 변수 선언 + 쓰기 지점 (via_perform 재귀 전개, 깊이 5) |
| GET | `/programs/{name}/call-graph?unit=` | 호출 그래프 |
| GET | `/programs/{name}/who-calls/{unit}` | 역참조 |
| GET | `/tables/{name}/usage` | 테이블 읽기/쓰기 프로그램·unit |
오류: 404(존재하지 않는 프로그램/심볼 — 유사 후보를 detail 에 포함), 503(DB 오류).
## 원칙 (계획서)
- **사실은 파서가, 해석은 LLM이** — tables/calls/writes 는 파서 결과가 항상 우선. 조각의 줄 번호도
first_line 앵커로 검증하고, 조각의 테이블·호출은 파서로 다시 뽑는다.
- **코드 원문은 로직, 선언은 정의부로 분리.** 조각에는 로직만 담아 검색 품질을 지키고, 붙여넣을 때
필요한 선언은 `declaration` 테이블에서 의존까지 묶어 따로 조립한다 (index/decls.py).
인덱스는 재료와 분류만 넘기고, 쓸지 말지는 붙여넣는 쪽이 정한다.
- **인덱스 단위는 LLM 이 골라낸 로직 조각.** 파서의 문법 단위(FORM/METHOD/FUNCTION/MODULE/이벤트 블록)는
LLM 에게 보여주는 창이자 조각의 컨테이너다. 프로그램 요약을 먼저 만들어 문맥으로 붙인다.
- 모든 산출물은 코드 해시에 묶여 증분 처리 (unit code_hash 가 같으면 조각 보존).
- 요약·태그·키워드는 한국어 우선 + SAP 영어 용어·객체명 병기.
## 현재 상태 / 남은 일
- Stage 1·2·4·5·6 동작. 샘플 14본(문장 24,578) 실측: **미인식 문장 30건(0.12%)**, 프로그램별 최대 0.46%
(프로그램 하나만 보면 안 된다 — `tests/test_parser_fieldsymbol.py` 가 전체를 잰다)
- Stage 3(로직 조각 추출): 구조 완료. **LLM 키 없이 `--llm file` 로 실행 가능** (위 참고)
- 임베딩/벡터 검색: 모델 미정으로 보류 — FTS(+2-gram, 동의어 확장)만 사용 중
- DB: SQLite 기본. PostgreSQL 전환 스키마는 `index/schema_postgres.sql`
- **보강 과제 10건 중 9건 반영 완료** — 판정·적용 내역은 `docs/수정사항-적용.md`
(1 색인 반영 / 3 텍스트 심볼 FTS / 4 용어 사전 질의 확장 / 6 중복 조각 복제 /
8 엔티티 위키·계층 index / 10 병렬 실행 + 429 백오프. 2·5·9 는 이미 완료·무효)
- **남은 일: 7번 정답셋 확대.** 개발자 질의 50~100개를 받아 recall@10/MRR 을 재고 베이스라인과
비교해야 한다. 이게 없으면 위 검색 변경들의 정밀도 효과를 검증할 수 없다
(튜닝 대상 값: `query/expand.py``EXPANDED_WEIGHT`, `MAX_SYNONYMS`, 2단 검색 보충 조건)
- 프로세스 위키 페이지("입고 처리는 세 경로")는 reduce 단계가 필요해 조각 데이터가 쌓인 뒤로 보류
View File
+31
View File
@@ -0,0 +1,31 @@
# 질의 확장용 도메인 용어 사전 초기 시드 (계획서 §5.6)
# 형식 — 용어: [동의어/관련 테이블/T-Code ...]
#
# 이 사전은 두 곳에서 같이 쓰인다 (config/glossary.py):
# 1) query/expand.py — 검색 질의 확장 ("입고"로 물어도 GR·MSEG·101 로 색인된 문서를 찾는다)
# 2) wiki_out/run.py — concepts/ 위키 문서 생성
# 양방향으로 동작한다: 대표어로도, 동의어 중 아무거나로도 질의가 걸린다.
총계정원장: [G/L, GL, 원장, ACDOCA, ACDOCT, SKAT, FAGLL03, FS10N]
전표: [회계전표, BKPF, BSEG, ACDOCA, FB03]
반제: [클리어링, 미결정리, BSIS, BSAS, F-03, F.13]
제조원가: [원가계산, CO-PC, 오더결산, COEP, COSS, CKMLHD, KKS1, KO88]
코스트센터: [CCTR, CSKS, KSB1]
회계단위: [회사코드, BUKRS, T001]
계정과목: [계정, RACCT, SAKNR, SKA1, SKAT]
사업영역: [BUSA, GSBER]
기능영역: [FAREA, RFAREA]
손익: [P/L, 손익계산서, GVTYP]
잔액: [기말잔액, 이월, HSL, BALANCE]
# MM — 샘플에 ZMMR* 프로그램이 있어 함께 시드한다 (계속 확장 대상)
입고: [GR, Goods Receipt, 101, MSEG, MKPF, MB51, MIGO]
출고: [GI, Goods Issue, 201, 261, MSEG, MB1A]
자재: [품목, 자재마스터, MATNR, MARA, MAKT, MM03]
자재문서: [물자문서, MBLNR, MKPF, MSEG, MB51]
구매오더: [PO, Purchase Order, EBELN, EKKO, EKPO, ME23N]
구매요청: [PR, Purchase Requisition, BANFN, EBAN, ME53N]
재고: [재고수량, LABST, MARD, MBEW, MMBE]
이동유형: [BWART, 이동타입, movement type]
플랜트: [WERKS, 사업장, T001W]
저장위치: [LGORT, 창고, T001L]
공급업체: [벤더, LIFNR, LFA1, XK03]
송장: [인보이스, Invoice, RBKP, RSEG, MIRO]
+67
View File
@@ -0,0 +1,67 @@
"""도메인 용어 사전 로더 (계획서 §5.6, 수정사항 4번).
`config/domain_glossary.yaml` 은 의존성을 늘리지 않으려고 YAML 파서 없이 읽는다.
지원 형식은 한 줄 리스트뿐이다:
총계정원장: [G/L, GL, 원장, ACDOCA, SKAT, FAGLL03]
질의 확장(query/expand.py)과 concepts 위키 생성(wiki_out/run.py)이 같은 사전을 쓰도록
로딩을 여기로 모았다 (이전에는 wiki_out/run.py 안에 인라인 정규식으로만 있었다).
"""
from __future__ import annotations
import re
from functools import lru_cache
from pathlib import Path
from .settings import ROOT
# "용어: [동의어, 동의어]" — 주석(#)·빈 줄·중첩 키(tags:)는 건너뛴다
GLOSSARY_LINE = re.compile(r"^([^#:\s][^:]*):\s*\[(.*)\]\s*$")
DEFAULT_PATH = ROOT / "config" / "domain_glossary.yaml"
def parse_glossary(text: str) -> dict[str, list[str]]:
"""사전 텍스트 → {대표어: [동의어...]}"""
out: dict[str, list[str]] = {}
for line in text.splitlines():
m = GLOSSARY_LINE.match(line.strip())
if not m:
continue
term = m.group(1).strip()
syns = [t.strip() for t in m.group(2).split(",") if t.strip()]
if term:
out[term] = syns
return out
@lru_cache(maxsize=4)
def load_glossary(path: Path | None = None) -> dict[str, list[str]]:
p = path or DEFAULT_PATH
if not p.exists():
return {}
return parse_glossary(p.read_text(encoding="utf-8"))
@lru_cache(maxsize=4)
def synonym_map(path: Path | None = None) -> dict[str, list[str]]:
"""양방향 동의어 맵 — 대표어로도, 동의어로도 질의가 들어올 수 있다.
"입고" → [GR, MSEG, 101 …] 뿐 아니라 "GR" → [입고, MSEG …] 도 성립해야 한다.
키는 대문자 정규화(한글은 그대로)해 비교한다.
"""
glossary = load_glossary(path)
groups: list[set[str]] = [{term, *syns} for term, syns in glossary.items()]
out: dict[str, list[str]] = {}
for group in groups:
for member in group:
key = member.upper()
others = [m for m in group if m.upper() != key]
if not others:
continue
bucket = out.setdefault(key, [])
for o in others:
if o not in bucket:
bucket.append(o)
return out
+57
View File
@@ -0,0 +1,57 @@
"""전역 설정. 모든 외부 접점(LLM/임베딩/DB/서버)은 환경변수로만 결정한다."""
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
ROOT = Path(__file__).resolve().parent.parent
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=ROOT / ".env", env_file_encoding="utf-8", extra="ignore")
# DB — 기본은 SQLite(개발/폐쇄망 무의존). PostgreSQL 전환은 ASSUMPTIONS.md 참고.
database_url: str = f"sqlite:///{ROOT / 'data' / 'index.db'}"
# LLM (Stage 3 요약) — OpenAI 호환 엔드포인트 가정
llm_base_url: str = ""
llm_api_key: str = ""
llm_model: str = "glm-5.2"
# 요약 배치의 동시 LLM 호출 수. DB 쓰기는 메인 스레드에 남기고 호출만 병렬로 돈다.
llm_concurrency: int = 4
llm_timeout_s: int = 180
# 429/5xx 재시도 — 동시성을 올리면 429 가 늘어나므로 백오프와 같이 써야 한다
llm_max_retries: int = 5
llm_backoff_base: float = 2.0
llm_backoff_max: float = 60.0
# /ingest 가 뒤이어 돌리는 요약의 백엔드:
# api — 환경변수 키로 호출 (키가 유효할 때)
# file — 프롬프트를 data/llm_jobs/ 에 쌓아두고 사람·에이전트가 채운다 (키 불필요)
# off — 자동 요약을 하지 않는다 (배치로만 돌린다)
summarize_backend: str = "api"
# 임베딩 — 모델 미정(§10). 비워두면 벡터 검색 비활성(FTS만 사용).
embed_base_url: str = ""
embed_api_key: str = ""
embed_model: str = ""
# 질의 서비스
index_host: str = "127.0.0.1"
index_port: int = 8100
# 데이터 디렉토리
data_raw: Path = ROOT / "data" / "raw"
data_normalized: Path = ROOT / "data" / "normalized"
data_parsed: Path = ROOT / "data" / "parsed"
data_summaries: Path = ROOT / "data" / "summaries"
# file 백엔드의 프롬프트/응답 큐 — LLM 키 없이 요약을 돌리는 입구 (summarize/jobs.py)
data_llm_jobs: Path = ROOT / "data" / "llm_jobs"
# 위키 (계획서 v4 §5.7 — OKF v0.2 출력, 의미 검색의 1차 대상)
wiki_dir: Path = ROOT / "wiki"
# 위키 검색 백엔드: qmd | pg (M3.5 파일럿으로 확정 — 계획서 §6.5)
# "pg" 는 내장 DB 검색 경로를 뜻함. 현 구현은 SQLite FTS5 (ASSUMPTIONS.md §1).
wiki_search_backend: str = "pg"
qmd_url: str = "http://127.0.0.1:8181"
settings = Settings()
+40
View File
@@ -0,0 +1,40 @@
# 허용 업무 태그 초기 시드 (계획서 §5.6) — 첫 300본 요약 후 proposed_tags 집계로 확장
tags:
# FI
- 전표
- 총계정원장
- 계정잔액
- 반제
- 자동반제
- 미결관리
- 결산
- 월별비교
- 채권관리
- 채무관리
- 지급처리
- 세금계산서
- 부가세
- 원천세
- 고정자산
- 예산관리
- 자금관리
- 인사연동
- 급여전기
- 경비처리
# CO
- 제조원가
- 표준원가
- 오더결산
- 차이분석
- 배부
- 코스트센터
- 수익성분석
- 내부오더
# 공통
- 인터페이스
- 배치작업
- 마스터관리
- 권한관리
- 리포트
- 대사작업
- 마감작업
Binary file not shown.
Binary file not shown.
Binary file not shown.
+113
View File
@@ -0,0 +1,113 @@
# 정의부(declaration) 수집 설계 — 2026-09-21
## 왜 필요한가
이 인덱스의 최종 용도는 **비슷한 로직을 새로 짤 때 기존 코드를 찾아 복사해 오는 것**이다.
그런데 인덱스에 담긴 조각(logic_chunk)의 코드 원문은 **로직만**이다. ABAP 은 내부테이블·스트럭처·
상수·필드심볼을 TOP 인클루드나 FORM 머리에 따로 선언하므로, 로직만 복사해 붙여넣으면
`GT_LOAD 는 선언되지 않았습니다` 류의 문법 오류가 난다.
그래서 **정의부를 인덱싱 시점에 따로 수집**한다. 조각 코드에는 계속 로직만 둔다(검색 품질을
지키기 위해서다 — 선언 수백 줄이 조각에 섞이면 조각의 뜻이 흐려진다).
수집한 정의부를 **쓸지 말지는 판단하지 않는다.** 붙여넣는 쪽의 LLM 이 정한다:
이미 같은 선언이 있으면 버리고, 이름이 겹치면 바꾸고, 타입만 빌려 쓰기도 한다.
인덱스는 "이 조각을 가져가려면 이것들이 함께 필요하다"는 **재료와 분류**만 정확히 넘긴다.
## 무엇을 수집하는가
`parser/declarations.py` 가 선언 문장을 **붙여넣을 수 있는 한 문장 단위**로 잘라 `declaration`
테이블에 넣는다 (`index/db.py`).
| 항목 | 내용 |
|---|---|
| kind | data / types / constants / tables / field-symbol / parameter / select-option / ranges / type-pool / controls / class / macro |
| scope | `global`(TOP·공용 인클루드) / `unit`(FORM·METHOD 머리의 로컬 선언) |
| code | **선언 원문** — 체인 항목이면 헤드를 다시 붙이고 `,``.` 로 닫아 유효한 문장으로 만든다 |
| depends | 이 선언이 참조하는 다른 이름 (`LIKE GT_DATA` → GT_DATA, `TYPE TABLE OF TY_X` → TY_X) |
세 가지가 핵심이다.
1. **`BEGIN OF … END OF` 는 한 덩어리다.** 업무 프로그램의 내부테이블은 거의 전부 이 형태다
(`DATA : BEGIN OF GT_UPLOAD OCCURS 0, … END OF GT_UPLOAD.`). `parser/dataflow.py` 의 심볼
추출은 이걸 건너뛰지만(추적에는 이름만 필요하다), 정의부에는 반드시 통째로 있어야 한다.
2. **체인 항목도 혼자 설 수 있어야 한다.** `DATA: a TYPE i,\n b TYPE i.``b` 만 잘라내면
`b TYPE i.` 라 붙여넣을 수 없다 → `DATA:\n b TYPE i.` 로 헤드를 복원한다.
이를 위해 `parser/statements.py` 가 체인 전개 시 **항목별 줄 범위**를 기록한다.
3. **로컬 클래스·매크로도 정의부다.** `CLASS lcl_x DEFINITION … ENDCLASS`, `DEFINE … END-OF-DEFINITION`
은 unit 통째를 한 건으로 담는다.
## 조각 → 정의부 조립 (`index/decls.py`)
1. 조각 코드의 식별자를 모은다 (파서 토크나이저 · 문장 단위)
2. `declaration` 에서 찾는다 — **unit 로컬 먼저, 없으면 전역**
3. 찾은 선언의 `depends`**재귀로** 끌어온다 (`LT_DATA LIKE GT_DATA` → GT_DATA)
4. 의존이 먼저 오도록(post-order) 정렬해 `declaration_code` 한 덩어리로 만든다
결과는 네 갈래로 분류해 넘긴다.
| 필드 | 뜻 | 붙여넣는 쪽이 할 일 |
|---|---|---|
| `declarations` / `declaration_code` | 함께 가야 하는 선언 원문 | 있으면 버리거나, 이름을 바꿔 쓰거나, 그대로 넣는다 |
| `declaration_external_refs` | DDIC 사전 객체 (BKPF, LVC_T_FCAT …) | 가져갈 게 없다. 대상 시스템에 있는지만 본다 |
| `declaration_params` | 이 조각이 쓰는 FORM 파라미터 | 호출 측에서 넘어오는 값이다 |
| `declaration_unresolved` | 변수처럼 생겼는데 선언을 못 찾은 이름 | 수집 안 된 인클루드·함수 인터페이스일 수 있다 |
### 판정에서 가른 두 가지
- **`LIKE BSEG-WRBTR` 은 작업영역이 아니다.** 사전에서 타입만 빌려 쓰는 것이라
TOP 의 `TABLES: BSEG.` 를 딸려 보내면 안 된다. 반면 `bseg-wrbtr = 100.` 처럼 **작업영역을
실제로 쓰면** 그 선언이 필요하다. 그래서 참조를 컴포넌트까지 보존해 둘을 구분한다.
- **호출문의 형식 파라미터는 변수가 아니다.** `CALL FUNCTION 'X' EXPORTING i_bukrs = gv_bukrs`
`i_bukrs``EXCEPTIONS no_rate_found` 는 호출 대상의 인터페이스다. 이름만 보면 변수와
구분되지 않아(둘 다 `I_`/`NO_`) 문장 구조로 걸러낸다.
### 저장하지 않고 읽을 때 계산한다
조각↔선언 대응은 `logic_chunk` 에 캐시하지 않는다. `declaration` 과 조각 코드만 있으면
결정론적으로 다시 나오므로, 캐시하면 조각 재추출·선언 변경과 엇갈려 따로 늙기만 한다.
조각 하나당 수십 줄을 토큰화하는 비용이라 조회 경로에서 충분히 싸다.
## 실측 (샘플 55본 / 조각 3,212건, 2026-09-21)
- 선언 18,049건 수집 (data 11.7k · constants 1.7k · field-symbol 1.0k · tables 1.0k · type-pool 488 · macro 295 · class 91 …)
- 조각의 **79%** 가 정의부를 갖는다 (평균 3.5건, 최대 21건).
나머지 21%는 호출·제어·메시지처럼 프로그램 선언을 쓰지 않는 조각이다.
- 선언을 못 찾은 이름이 남은 조각 **3%** — DDIC 테이블(J_1BBRANCH), 함수 인터페이스 파라미터,
화면 필드가 대부분이다.
### 곁가지로 고친 파서 버그 3건
정의부를 조립하면서 "선언이 없다"고 새어 나온 이름들을 좇아가 드러난 것들이다.
1. `CLASS lcl_x DEFINITION DEFERRED.` 를 블록으로 열어 **그 뒤 인클루드 전체를 CLASS_DEF 하나로
삼켰다** (ENDCLASS 가 없다). ZCO_ALV 의 선언 수십 건이 통째로 사라져 있었다.
2. FORM 시그니처의 파라미터를 **첫 개만** 잡았다 (`USING p_date LIKE x p_days LIKE y` 에서
TYPE/LIKE 를 만나면 멈췄다).
3. `PARAMETER`(단수형)를 선언 키워드로 몰랐다 — 미인식 문장 123건 → 3건.
## API
| Method | Path | 정의부 관련 |
|---|---|---|
| GET | `/chunks/{chunk_id}` | `declarations`, `declaration_code`, `declaration_external_refs`, `declaration_params`, `declaration_unresolved` (끄려면 `?decls=false`) |
| GET | `/programs/{name}/units/{unit}/code` | 같은 필드 (unit 통째로 가져갈 때) |
| GET | `/programs/{name}/declarations?scope=global\|unit` | 프로그램의 선언 카탈로그 |
## 위키에서의 표시
원문은 프로그램 문서의 `## 정의부` 섹션에 **한 번만** 싣는다 (인클루드별, 전역 선언 전체 +
조각이 실제로 쓰는 unit 로컬 선언). 조각 쪽에는 이름만 두고 그 항목으로 가는 **문서 내 링크**를 건다.
```markdown
- 정의부(복사 시 함께 필요): [`GT_LOAD`](#decl-zfic0020top-6-gt_load), [`C_X`](#decl-zficom-213-c_x)
#### GT_LOAD — data · L6-L56 {#decl-zfic0020top-6-gt_load}
```
`{#id}` 는 제목에 앵커를 다는 표기이고, 뷰어(`wiki_out/viewer.html`)가 이걸 `id` 로 바꿔
눌러서 이동하게 만든다(이동 후 1.4초 강조). **뷰어 전용 기능이 아니다** — 마크다운으로 읽으면
링크 텍스트가 곧 선언 이름이라 LLM 에게는 지금까지와 똑같이 읽히고, 앵커는 무시된다.
링크와 앵커가 어긋나면 조용히 죽으므로 짝을 테스트로 묶어 뒀다
(`tests/test_wiki_out.py::test_chunk_links_to_declaration`). 실측 11,051개 링크 / 깨진 링크 0개.
+91
View File
@@ -0,0 +1,91 @@
# 로직 조각(logic chunk) 추출 설계 — 2026-09-16 구조 변경
## 결정
인덱스의 단위를 **파서가 자른 unit(FORM/METHOD/FUNCTION/MODULE/이벤트)** 에서
**LLM 이 골라낸 로직 조각(logic chunk)** 으로 바꾼다.
- 파서 unit 은 더 이상 인덱스 단위가 아니다. LLM 에게 코드를 **나눠 보여주는 창(window)** 이자,
조각의 위치를 설명하는 **컨테이너** 역할만 남는다.
- LLM 은 unit 코드를 읽고 업무적으로 의미 있는 로직 조각(특정 SQL, DB 갱신, BAPI 호출,
집계 LOOP, 검증 …)을 0개 이상 골라내 각각에 자연어 설명을 붙인다.
- 의미 없는 코드(선언, 초기화, ALV 필드카탈로그, 화면 속성 …)는 조각으로 만들지 않는다 → 인덱스에서 버려진다.
- 조각의 설명·키워드가 검색(chunk_fts)의 1차 대상이다.
## 왜 프로그램 전체가 아니라 unit 하나씩 보여주는가
| 관점 | 프로그램 전체 1회 | unit 창 + 프로그램 요약 문맥 (채택) |
|---|---|---|
| 긴 코드에서의 추출 품질 | 수천 줄에서 뒤쪽을 대충 봄 | 200~300줄 단위라 안정적 |
| 줄 번호 확정 | 어려움 | 창 안이라 매칭 쉬움 |
| 증분 비용 | 한 줄 수정에도 전체 재실행 | 바뀐 unit 만 |
| unit 을 가로지르는 로직 | 잡을 수 있음 | **프로그램 요약(main_flow) 이 담당** |
그래서 2단계로 간다.
1. **프로그램 요약** — 구조 사실(이벤트 흐름, unit 목록, 테이블, 외부 호출, 텍스트 심볼, 이벤트 블록 코드)로
`ProgramSummary` 를 만든다. 코드가 바뀌면(`summary_status='stale'`) 다시 만든다.
2. **unit 별 조각 추출** — 프로그램 요약 + unit 코드(줄번호 포함) + 파서 힌트(DB 접근·호출 줄)를 주고
`UnitExtraction { unit_purpose_ko, chunks[] }` 를 받는다.
300줄을 넘는 unit 은 파서의 `sub_chunks`(최상위 IF/LOOP 경계) 창으로 나눠 여러 번 호출한다.
## 사실은 파서가, 해석은 LLM 이 — 조각 단위에서의 적용
- **줄 번호**: LLM 이 준 `line_start/line_end``first_line`(시작 줄 코드 원문)과 대조해 확정한다.
어긋나면 unit 안에서 그 줄을 찾아 보정하고, 못 찾으면 조각을 버린다(`dropped`).
- **테이블/호출**: 조각 코드 범위를 파서(`split_statements` + `extract_refs`)로 다시 돌려
`tables_read/tables_write/calls` 를 채운다. LLM 이 쓴 값은 쓰지 않는다.
- **해시**: 조각 코드의 sha256 을 `code_hash` 로 저장한다. unit 의 code_hash 가 같으면 재적재 시 조각을 보존한다.
## 스키마 (index/db.py)
```
logic_chunk(chunk_id PK = <unit_id>#C<seq>, 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 (JSON),
confidence, prompt_version, extracted_at)
chunk_fts(chunk_id, program, purpose, keywords, objects, bigrams) -- FTS5
unit.summary_json = {purpose_ko(한 줄), chunk_count, covered_lines, coverage} -- 얇은 unit 색인
unit.summary_status = none | done | failed (done 이면서 chunk_count=0 = 의미 조각 없음)
program.summary_json = ProgramSummary, program.text_symbols_json
```
`kind` 어휘: sql_select, db_write, fm_call(BAPI/FM/RFC), aggregation(내부테이블 집계·가공),
validation(검증·권한), calculation, output(ALV·화면·파일·메일), interface, control_flow, other
## 검색 (query/tools.py)
- `search_logic(q)` → chunk_fts 검색 → **프로그램 단위로 묶어** 반환(프로그램 요약 + 조각 목록).
- `search_units`/`search_programs` 는 얇은 색인으로 유지 — 버려진 코드도 unit 이름·주석·한 줄 요약으로는 도달 가능.
- `get_chunk(chunk_id)` → 메타 + 코드 원문.
## 정의부 — 조각을 붙여넣을 때 필요한 선언
조각 코드에는 **로직만** 둔다. 선언을 섞으면 조각의 뜻이 흐려지고 검색이 나빠진다.
대신 `declaration` 테이블에 선언 원문을 따로 모아 두고, 조각을 꺼낼 때 그 조각이 쓰는 선언을
의존까지 묶어 조립해 함께 준다 (`get_chunk``declaration_code`).
`docs/definition-block-design.md`
## 위키 (wiki_out)
- units/ 문서 생성 중단. 프로그램 문서 안에 `## 로직 조각` 섹션(조각마다 H3, resource 줄 범위)으로 넣는다.
→ 1만 프로그램 × 수십 unit 파일 문제 해소.
- OKF frontmatter 에 `x-chunk-count` 추가.
## 평가 (eval)
- 질문 유형 `logic` 추가: `expected` = `[{program, line_from, line_to}]`.
조각 검색 상위 10개 중 프로그램이 같고 줄 범위가 겹치면 적중.
## 남은 일 (구조 변경 뒤 10개 보강 과제와 연결)
2026-09-16 에 9건 반영 완료 — 판정·적용 내역은 `docs/수정사항-적용.md`.
- ✅ 조각 키워드·요약을 program_fts 에 반영(과제 1) — `index/loader.py:refresh_program_fts`
- ✅ 텍스트 심볼 FTS 반영(과제 3) — `index/db.py:text_symbol_phrases`
- ✅ 용어 사전 질의 확장(과제 4) — `query/expand.py` (2단 검색)
- ✅ 중복 조각 해시 묶기(과제 6) — `summarize/runner.py:clone_unit_chunks` (줄 평행이동 포함)
- ✅ 병렬 실행(과제 10) — 프롬프트 조립/호출/저장 3단 분리 + 429 백오프
- ✅ 엔티티 위키 + 계층 index.md(과제 8) — `wiki_out/entities.py`
-**정답셋 확대(과제 7)** — 남은 유일한 과제. 이게 없으면 위 검색 변경의 정밀도를 검증할 수 없다.
- ⏸ 프로세스 페이지(과제 8 후반) — 프로그램을 가로지르는 reduce 단계. 조각이 쌓인 뒤 판단.
+39
View File
@@ -0,0 +1,39 @@
# OKF 스펙 버전 고정 기록 (계획서 v4 §10.11)
- **채택 버전**: OKF v0.2
- **확인일**: 2026-08-26
- **원문**: https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md
- (v0.1: 2026-06-12 공개 / v0.2: 2026-07-25 — 신뢰 필드 추가)
## 확인된 스펙 요지
- 필수 필드는 `type` 하나. 권장: `title`, `description`, `resource`, `tags`.
- 신뢰/수명 필드(선택): `generated: {by, at}`, `verified: [{by, at}]`(단일 매핑도 1원소 리스트로 해석),
`sources`(provenance), `status: draft|stable|deprecated`(기본 stable), `stale_after`.
- actor 표기: 에이전트/도구 `<producer>/<version>`, 사람 `human:<id>`, 프로세스 `process:<id>`.
- **확장 필드 자유** — 소비자는 미지 키를 보존해야 하고, 없는 선택 필드 때문에 거부하면 안 됨.
- 예약 파일: 번들 루트 `index.md`(매니페스트, `okf_version: "0.2"` frontmatter), `log.md`.
→ 구 계획(v3)의 `okf.yaml` 매니페스트는 **index.md 로 대체**.
- 링크: 표준 마크다운 링크. `/` 로 시작하면 번들 루트 상대(권장 — 문서 이동에 안정적).
## 계획서 x- 필드 → v0.2 표준 필드 매핑 (확정)
| 구 계획(v3) | 채택(v4) | 비고 |
|---|---|---|
| `x-human-edited: true` | `verified: { by: "human:<id>", at: <ISO-8601> }` | 사람이 검토·수정 후 저장할 때 추가. merge.py 는 구 규약도 하위호환 인식 |
| (없음) | `generated: { by: "abap-indexing/<모델>", at: ... }` | 생성 주체. 요약 전 구조 문서는 `abap-indexing/parser` |
| 재검토 필요 표시 | `status: draft` + `wiki/_review.md` | 코드 해시 변경 시 merge.py 가 설정 |
| `x-confidence`, `x-prompt-version`, `x-program` 등 사실 필드 | **x- 확장 유지** | 표준에 대응 없음 — 스펙이 확장 키를 허용 |
| `okf.yaml` | `index.md` (예약 파일) | wiki_out/okf_writer.py `write_manifest` |
## 구현 위치
- 출력: `wiki_out/okf_writer.py` / 병합: `wiki_out/merge.py` / 검증: `python -m wiki_out.validate`
- frontmatter 의 관리 대상(배치가 갱신하는) 필드는 `merge.MANAGED_KEYS` — 모두 한 줄 YAML 로 emit.
## 2026-09-16 변경 — 문서 단위
- unit 별 개별 문서(`units/`, `type: abap-unit`)는 생성하지 않는다.
- LLM 이 골라낸 로직 조각은 프로그램 문서(`type: abap-program`) 안의 `## 로직 조각` 섹션이다.
조각마다 resource 는 `abap://<PROG>/<INCLUDE>#L<from>-L<to>` 형식으로 본문에 적는다.
- 확장 필드 `x-chunk-count` 추가. 설계 근거는 `docs/logic-chunk-design.md`.
+67
View File
@@ -0,0 +1,67 @@
# 실행 기록 — 어느 모델이 무엇을 만들었나
다른 모델로 같은 작업을 다시 돌려 비교하기 위한 기록이다. 비교 기준선(baseline)은 아래 상태다.
## 모델
| 범위 | 모델 |
|---|---|
| **인덱싱 파이프라인 본체** (Stage 1~6: 정규화 · 파서 · 적재 · 요약/조각 추출 · 질의 API · 위키/뷰어) | **Fable 5.1** |
| 정의부(declaration) 수집·조립 기능 (2026-09-21, `docs/definition-block-design.md`) | Opus 5 (1M context, `claude-opus-5[1m]`) |
이 저장소에 있는 것의 **거의 전부는 Fable 5.1 이 만들었다.** 정의부 작업은 그 위에 얹은 한 기능이다.
## 기준선 지표 (2026-09-21 기준)
샘플 55본으로 전 단계를 돌린 결과다. 재실행 결과를 여기에 대보면 된다.
| 항목 | 값 |
|---|---|
| 프로그램 / unit / 문장 | 55 · 6,296 · 95,401 |
| 파서 미인식 문장 | 3건 (0.003%) |
| 로직 조각 (LLM 추출) | 3,212건 |
| 선언(정의부) | 18,049건 — 조각의 79%가 정의부를 가짐 (평균 3.5건) |
| 위키 | 프로그램 55 · 테이블 160 · 펑션 119 · 개념 23 · T-code 17 |
| 테스트 | 99 passed / 6 skipped (6건은 ZFIR10070 정규화 산출물이 없어 skip) |
| index.db | 약 43 MB |
## 재실행할 때 주의
- **이 저장소 상태 위에서 같은 프롬프트를 돌리면 비교가 안 된다.** 결과물(코드·위키·DB)이 이미
있어서 다음 모델은 "무엇을 만들어야 하는지"를 읽고 시작하게 된다. 같은 출발선에서 재려면
변경 전 상태를 따로 떠 두고 거기서 돌려야 한다 (이 저장소는 git 관리가 아니다).
- Stage 3(조각 추출)은 LLM 호출이라 **모델이 바뀌면 조각의 개수·경계·설명이 달라진다.**
Stage 1·2·4(정규화·파서·적재)는 LLM 없이 결정론적이므로 코드가 같으면 결과도 같아야 한다.
즉 모델 비교가 실제로 갈리는 지점은 **코드를 어떻게 짜는가**와 **조각 추출 품질**이다.
- 조각 추출을 다시 돌릴 거면 `PROMPT_VERSION`(summarize/runner.py)과 `.env` 의 모델 설정을
함께 기록해 둘 것 — 그게 없으면 나중에 어느 조각이 어느 모델 산출인지 구분되지 않는다.
## GLM 5.2 비용 실측 (2026-09-21)
비교 실행 대상은 OpenRouter `z-ai/glm-5.2`. 전체 실행은 **잔액 부족(402)** 으로 못 돌렸고,
실제 프롬프트 5건을 GLM 5.2 로 직접 호출해 단가를 쟀다 (총 $0.024 소요).
| 측정값 | 결과 |
|---|---|
| 문자/토큰 (한국어+ABAP 혼합) | 1.97 ~ 2.1 |
| 호출 1회 (프롬프트 3.1k~5.1k자) | 입력 1,643~2,577 토큰 / 출력 130~1,639 토큰 |
| 그중 **추론(reasoning) 토큰** | 출력의 58~68% — GLM 5.2 는 추론 모델이라 비용의 큰 몫 |
| 프롬프트 1,000자당 비용 | $0.00071 (관측 범위 $0.0005~$0.0012) |
| 단가 | 입력 $0.6496/M · 출력 $2.0416/M |
이 단가를 현재 코퍼스의 **실제 프롬프트 5,642건(23.3M자)** 에 적용한 예측:
| 범위 | 비용 |
|---|---|
| 프로그램 1본 (중앙값 120호출·446k자) | **$0.32** (범위 $0.22~$0.52) |
| 현재 코퍼스 55본 전체 | **$16.5** (범위 $11.6~$27.0) |
| $10 으로 가능한 양 | **프로그램 약 32본** / LLM 호출 약 3,400회 |
- 호출 수는 파서가 정하므로(unit 수) 모델이 바뀌어도 같다. 달라지는 건 토큰 단가와 추론 길이다.
- 추론 길이 편차가 커서 범위가 넓다(같은 크기 프롬프트에서 출력 130~1,639 토큰).
- 더 싼 슬러그 환산: `z-ai/glm-4.7` 약 $12.5, `z-ai/glm-5.3-flash` 약 $2.4 (같은 코퍼스 전체).
- `summarize/llm_client.py``max_tokens` 를 안 보낸다. OpenRouter 는 최대 출력 기준으로
선결제 견적을 잡으므로 잔액이 적으면 402 가 난다. 추론 폭주 방어도 겸해 상한을 두는 게 좋다.
실행용 폴더: `C:\EdgeCenter\abap-indexing-glm52` (코드 + data/raw 만 복사, Stage 1·2·4 완료,
ZFIR10070 1본 적재 = unit 122·문장 1,756). 키만 채우면 Stage 3 부터 바로 돌릴 수 있다.
Binary file not shown.
+182
View File
@@ -0,0 +1,182 @@
# 수정사항 10건 검토·적용 기록 (2026-09-16)
원본: `수정사항.txt`. 이 파일은 **2026-09-16 구조 변경(로직 조각 도입) 이전** 기준으로 작성돼
일부 항목은 이미 해소된 상태였다. 항목별 판정과 적용 내용을 남긴다.
| # | 항목 | 판정 | 적용 |
|---|---|---|---|
| 1 | LLM 요약이 검색 색인에 미반영 | 부분 유효 | ✅ 적용 |
| 2 | 프로그램 단위 요약 미생성 | **이미 완료** | 코드 변경 없음 |
| 3 | 텍스트 심볼 치환 | 프롬프트 완료 / FTS 미반영 | ✅ FTS 반영 |
| 4 | 용어 사전이 질의 확장에 미사용 | 유효 | ✅ 적용 |
| 5 | keywords_en · sap_objects 필드 없음 | **이미 완료** | 코드 변경 없음 |
| 6 | 중복 프로그램 처리 없음 | 유효 | ✅ 적용 |
| 7 | 정답셋 4문항 | 유효 | ⏸ **범위 제외** (사용자 지시) |
| 8 | 엔티티·프로세스 위키 페이지 없음 | 유효 | ✅ 엔티티 적용 / 프로세스 보류 |
| 9 | unit 문서 70만 파일 문제 | **구조 변경으로 해소** | 무효 |
| 10 | 요약 병렬 실행 없음 | 유효 | ✅ 적용 |
## 이미 완료였던 항목 (2·5·9)
- **2번** — `summarize/runner.py:ensure_program_summary()``ProgramSummary` 를 만들어
`program.summary_json` 에 저장하고, `UNIT_PROMPT``program_purpose` · `main_flow` ·
`key_internal_tables` 를 문맥으로 주입한다. DB 에 요약이 0건이었던 것은 미구현이 아니라
OpenRouter 무료 티어의 **429 Too Many Requests** 로 실행이 실패해서였다 (`unit.summary_error` 6건 전부 429).
- **5번** — `schemas.LogicChunk``purpose_en` · `keywords_ko` · `keywords_en` · `sap_objects`
모두 있고 프롬프트가 명시적으로 요구한다.
- **9번** — `units/` 문서 생성은 중단됐다. 로직 조각은 프로그램 문서 안의 H3/H4 섹션이다.
원본 파일의 "units/ 를 git 제외로 둔 것은 좋은 완화책" 서술은 현재 상태와 맞지 않는다
(`.gitignore``units` 항목이 없고 디렉토리도 없다).
## 적용 내용
### 1번 — 요약을 검색 색인에 반영
구조 변경으로 `chunk_fts` 가 신설돼 조각의 `purpose`/`keywords`/`objects` 는 이미 색인되고 있었고,
`extract_unit` 도 요약 후 `unit_fts` 를 갱신하고 있었다. 남아 있던 두 구멍만 막았다.
- **적재 시점 `unit_fts.purpose` 의 장식 문자** — `index/loader.py``header_comment` 를 날것으로
넣어 1,177행 중 **867행이 `'----------------'`** 이었다. `clean_comment()` 를 적용해 0행으로.
- `clean_comment``query/tools.py``index/db.py` 로 이동 (loader 가 써야 하는데
`index → query` 는 의존 역방향). `query.tools.clean_comment` 이름은 하위호환으로 유지.
- **`program_fts` 가 타이틀·패키지명뿐** — `business_purpose_ko` / `main_flow` / `keywords_ko` /
`keywords_en` / `business_tags` / `sap_module` / `related_tcodes` / 조각 키워드 / 조각 SAP 객체 /
테이블명 / 텍스트 심볼을 색인에 넣었다.
- **색인을 두 테이블로 나눴다**: `program_fts`(이름·타이틀·패키지) + `program_desc_fts`(서술).
한 행에 몰아넣었더니 회귀가 났다 — bm25 는 **행 전체 길이로 정규화**하므로 요약이 붙어
길어진 행이 타이틀만 있는 짧은 행보다 낮게 나온다. 실측으로 타이틀이 정확히 `"총계정원장 조회"`
ZFIR10070 이 `"총계정원장 조회하는 프로그램"` 질의에서 **1위 → 33위**로 밀렸다
(행 길이 1,872자 vs 40자). 컬럼 가중치로는 해결되지 않는다(정규화는 행 단위).
나누면 각 테이블 안 행 길이가 비슷해지고, 서술 히트는 `_DESC_WEIGHT=0.6` 으로 **더해질 뿐**
이름·타이틀 일치를 밀어내지 않는다. 회귀 테스트:
`tests/test_dedupe_parallel.py::test_long_summary_does_not_outrank_title_match`
- 세 FTS 모두 `rank`(모든 컬럼 가중치 1) 대신 **가중 bm25**(`index/db.py:bm25_rank`)를 쓴다.
- FTS5 는 컬럼 추가가 불가하므로 `index/db.py:_migrate_fts()` 가 컬럼 구성 변화를 감지해
DROP→재생성한다 (전부 파생 데이터라 안전).
- `refresh_program_fts(con, program)` 단건 경로를 추가했다. 전량 재구축을 `/ingest` 마다 돌리면
1만 본에서 O(N²) 가 된다.
- 검색 결과의 `reason` 이 근거를 구분한다: `이름/타이틀 일치` > `키워드 일치` > `요약 일치` > `동의어 확장`.
### 3번 — 텍스트 심볼을 FTS 에 반영
- `index/db.py:text_symbol_phrases()` — 코드 범위에 등장하는 `TEXT-nnn` 을 한국어 원문으로 치환.
- 조각 저장 시 `chunk_fts.keywords` / `bigrams` 에, 프로그램 색인에는 `program_fts.purpose` 에 넣는다.
- 결과: `"계정코드를 입력하세요"`, `"회계단위 간편선택"` 같은 **화면 문구로도** 검색된다 (LLM 무관).
### 4번 — 용어 사전 질의 확장
- `config/glossary.py` — 사전 로딩을 한 곳으로 모았다. 기존에는 `wiki_out/run.py` 안에
인라인 정규식으로만 있어 검색 쪽에서 쓸 수 없었다. **양방향 동의어 맵**을 만든다
(`입고``GR/MSEG/101` 뿐 아니라 `GR``입고/MSEG` 도 성립).
- `query/expand.py`**2단 검색**. 원질의로 먼저 찾고, 결과가 `top_k` 미만일 때만 동의어 식으로
보충하며 점수에 `EXPANDED_WEIGHT=0.35` 감쇠를 걸고 `matched_by='동의어 확장'` 으로 표시한다.
- 동의어를 원질의와 같은 OR 버킷에 섞지 않은 이유: `fts_or` 는 토큰과 한글 2-gram 을 전부 OR 로
이어 이미 재현율 편향이다. 여기에 동의어까지 같은 가중치로 넣으면 `"총계정원장"` 질의가
`"원장"` 2-gram 하나로 걸린 문서와 동일 점수가 된다.
- `config/domain_glossary.yaml` 에 MM 섹션(입고·출고·자재·구매오더·이동유형 …)을 시드했다.
기존 사전이 FI 전용이라 원본 파일이 예로 든 "입고 → GR/101" 이 실제로는 동작하지 않았다.
### 6번 — 중복 unit 조각 복제
- `summarize/runner.py:_dedupe_plan()` + `clone_unit_chunks()`.
- `code_hash` 가 같은 unit 은 **대표 1건만 LLM 에 보내고** 나머지는 조각을 복제한다.
이번 배치 밖에서 이미 추출된 동일 코드 unit 이 있으면 **LLM 호출 0회**로 복제한다.
- **줄 번호 평행이동이 핵심이다** — 코드는 같아도 include 안 위치가 달라
`target.line_start - rep.line_start` 만큼 밀어야 한다. 테스트가 복제된 줄 범위의 코드 원문이
대표와 동일한지 확인한다.
- 실측(샘플 14본): 중복 그룹 108개 / unit 287개 → **LLM 호출 179회(62%) 절감 가능**.
- `--no-dedupe` 로 끌 수 있다.
### 8번 — 엔티티 위키 페이지
- `wiki_out/entities.py``tables/` `functions/` `tcodes/``table_ref` · `call_edge` 에서
**LLM 없이 결정론적으로** 생성. 실측 산출: 테이블 146건, 펑션 94건.
- `tables/<T>.md` — 읽는 프로그램 / 쓰는 프로그램 / unit 단위 사용 지점
- `functions/<FM>.md` — 호출 프로그램 / 호출 지점 / RFC 여부
- `tcodes/``tcode` 테이블 미확보로 현재 0건 (데이터가 들어오면 그대로 생성됨)
- 프로그램당 파일이 아니라 **엔티티당 파일**이라 9번의 파일 수 폭발이 재발하지 않는다.
- `index.md` 를 계층 진입점으로 재작성 — **SAP 모듈 → 패키지 → 프로그램**.
모듈은 요약의 `sap_module`, 요약 전에는 `Z<모듈><번호>` 관행에서 추정한다.
- **프로세스 페이지는 보류.** 프로그램을 가로지르는 reduce 단계가 필요하고, 조각 데이터가
실제로 쌓인 뒤에 판단하는 게 맞다 (원본 파일도 "별도 작업"으로 인정).
### 10번 — 병렬 실행
- `llm_concurrency` 가 **선언만 있고 읽는 코드가 없는 죽은 설정**이었다. 실제로 연결했다.
- sqlite 커넥션은 스레드 간 공유가 안 되므로 3단으로 갈랐다:
`build_unit_prompts()` (DB 읽기, 메인) → `_run_calls_parallel()` (LLM 호출, 워커) →
`finish_unit()` (검증·저장, 메인).
- **동시성만 올리면 429 가 늘어난다.** `OpenAICompatClient.complete_json` 에 429/5xx 지수 백오프
재시도(`Retry-After` 존중, jitter 포함)를 같이 넣었다. 기존에는 재시도가 없어 429 한 번에
unit 이 `failed` 로 굳었다.
## 함께 고친 것 (직전 분석에서 보고한 버그)
### 파서 토큰화 — 필드심볼·테이블본문 대입
`<ls_fcat>-fieldname = 'X'``['<ls_fcat>', '-', 'fieldname', '=', ...]` 로 쪼개져
일반 대입 판정(`up[1] == "="`)이 깨졌다. 결과로 **쓰기 지점이 누락**되고 미인식 문장으로 잡혔다.
- `parser/tokenizer.py` — 필드심볼이 `-컴포넌트` / `->메서드` 까지 한 토큰. `&1` 매크로 파라미터도 토큰화.
- `parser/statements.py:assignment_eq_index()` — 대입 판정을 한 곳으로. `X = `, `X[] = `,
`X[ key = v ] = ` 형태를 모두 인식한다. `dataflow``run`(미인식 리포트)이 같은 판정을 쓴다.
- 효과 (샘플 14본 / 문장 24,578건. 베이스라인 커밋 `35a15aa` 를 worktree 로 꺼내 직접 측정):
| 지표 | 이전 | 이후 | 차이 |
|---|---|---|---|
| 미인식 문장 | 1,055건 (4.29%) | **30건 (0.12%)** | 1,025 |
| 최악 프로그램 | ZMMR71110_API **12.03%** | ZBACKUP_FI **0.46%** | |
| 쓰기 지점 총계 | 7,327 | **8,251** | **+924** |
| └ 필드심볼 대상 | 207 | **1,067** | **+860** |
| └ `itab[]` 형태 | 230 | **307** | **+77** |
`itab[] = ...``<fs>-comp = ...` 는 내부테이블을 채우는 흔한 관용구라
`trace_variable`("gt_head 가 어디서 채워지나")의 정확도에 직접 영향이 있었다.
이전에도 필드심볼 쓰기가 207건 잡혔던 것은 `READ TABLE ... ASSIGNING <fs>` /
`LOOP AT ... ASSIGNING <fs>` 경로는 별도 분기라 영향을 받지 않았기 때문이다.
### 외부 호출 목록 오염
`CL_GUI_ALV_GRID=>MC_FC_AUF` 같은 **정적 상수 읽기**가 `method_ref` 로 기록돼 호출로 취급됐다.
`query/tools.py:_structure_summary` 는 걸러냈지만 `wiki_out/okf_writer.py` 는 걸르지 않아
`x-calls` 40건이 거의 전부 ALV 상수와 `CALL SCREEN` 의 화면번호(`"100"`)였다.
- `parser/refs.py:EXTERNAL_CALL_KINDS` / `FUNCTION_CALL_KINDS` 로 기준을 한 곳에 두고
요약·위키·엔티티 페이지가 공유한다.
- ZFIR10070 의 `x-calls`: ALV 상수 40건 → **실제 FM·외부 PERFORM·SUBMIT 14건**.
### 설치 명령이 동작하지 않았음
README 가 안내하는 `pip install -e ".[dev]"` 가 실패했다 — flat-layout 자동 탐색이
`data/` `wiki/` 까지 패키지로 오인한다. `pyproject.toml``[tool.setuptools] packages` 를 명시했다.
### 로더 강제 재적재
파서·색인 로직이 바뀌면 소스가 그대로라도 산출물이 달라지는데, `source_hash` 가 같으면 스킵돼
반영할 방법이 없었다. `python -m index.loader --force` 를 추가했다.
## 7번 (정답셋)을 제외한 결과
1·4번의 효과를 숫자로 확인할 수단이 없다. 확인한 것은 **기능이 동작한다는 사실**뿐이다:
- `ACDOCT` / `FAGLL03` 로 질의 → 총계정원장 프로그램이 `동의어 확장` 으로 나온다
- `"회계 담당자가 월 단위로 확인"`(요약 본문) → 프로그램이 나온다
- `"계정코드를 입력하세요"`(텍스트 심볼) → 프로그램이 나온다
정밀도가 좋아졌는지, 아래 계수들이 적절한지, 2단 검색이 베이스라인보다 나은지는
**측정하지 않았다**. 현 정답셋 5문항으로는 개선도 회귀도 보이지 않는다.
정답셋 50~100문항이 생긴 뒤에 튜닝할 값:
| 값 | 위치 | 현재 | 뜻 |
|---|---|---|---|
| `EXPANDED_WEIGHT` | `query/expand.py` | 0.35 | 동의어 히트 점수 감쇠 |
| `MAX_SYNONYMS` | `query/expand.py` | 24 | 질의당 동의어 상한 |
| `_DESC_WEIGHT` | `query/tools.py` | 0.6 | 요약·키워드 색인 히트 가중 |
| `_BM25_WEIGHTS` | `index/db.py` | 표 참조 | FTS 컬럼별 bm25 가중치 |
| 2단 검색 보충 조건 | `query/tools.py:_fts_rows` | `len(결과) < limit` | 동의어 단계 발동 시점 |
**이번 회귀가 이 과제의 필요성을 그대로 보여준다.** `find` recall@5 가 1.0 → 0.833 으로 떨어진 것을
5문항 정답셋이 잡아냈지만, 원인 파악은 수동 디버깅으로 했다. 문항이 50개면 어떤 유형의 질의가
깨졌는지가 바로 드러난다. 위 5개 값을 감으로 정한 상태가 현재의 가장 큰 미확정 요소다.
View File
+5
View File
@@ -0,0 +1,5 @@
{"id": "find-001", "type": "find", "question": "총계정원장 조회하는 프로그램", "expected_programs": ["ZFIR10070", "ZFIR10560", "ZFIR10570"]}
{"id": "find-002", "type": "find", "question": "총계정원장 월별", "expected_programs": ["ZFIR10560"]}
{"id": "logic-001", "type": "logic", "question": "회사코드별 통화 조회하는 로직", "expected": [{"program": "ZFIR10070", "line_from": 1, "line_to": 999999}]}
{"id": "trace-001", "type": "trace", "question": "ZFIR10070에서 gt_acdoct 채워지는 과정", "program": "ZFIR10070", "symbol": "gt_acdoct", "expected_units": ["SELECT_SUMMARY_DATA", "SELECT_ACDOCT"]}
{"id": "explain-001", "type": "explain", "question": "ZFIR10070 의 START-OF-SELECTION 흐름", "program": "ZFIR10070", "expected_keywords": ["SELECT_DATA"]}
+108
View File
@@ -0,0 +1,108 @@
"""Stage 6 — 평가. 질문셋으로 검색 recall / 조각 적중 / trace unit 포함률을 측정한다.
python -m eval.run_eval (인덱스 DB 적재 상태에서, API 서버 없이 tools 직접 호출)
결과는 eval/results/<date>.json 에 저장 — 프롬프트/사전 변경 전후 비교용.
질문 유형 (eval/questions.jsonl):
- find : {"question", "expected_programs"} → search_programs recall@5/@10
- logic : {"question", "expected": [{"program","line_from","line_to"}]}
→ search_logic 상위 조각 중 프로그램이 같고 줄 범위가 겹치면 적중. hit@10, MRR
- trace : {"program", "symbol", "expected_units"} → trace_variable unit 포함률
- explain : {"program", "expected_keywords"} → 요약 텍스트 키워드 적중률
"""
from __future__ import annotations
import json
from datetime import date
from pathlib import Path
from query import tools
HERE = Path(__file__).resolve().parent
def _flatten_units(writes: list[dict], acc: set[str]) -> None:
for w in writes:
acc.add(w.get("unit", ""))
if w.get("callee"):
acc.add(w["callee"].upper())
_flatten_units(w.get("callee_writes", []), acc)
def _overlaps(chunk: dict, exp: dict) -> bool:
if chunk["program"] != exp["program"].upper():
return False
lo, hi = int(exp.get("line_from", 0)), int(exp.get("line_to", 10**9))
return chunk["line_start"] <= hi and chunk["line_end"] >= lo
def eval_logic(q: dict) -> dict:
res = tools.search_logic(q["question"], top_k=10)
ranked = [c for g in res["programs"] for c in g["chunks"]]
ranked.sort(key=lambda c: -c["score"])
expected = q["expected"]
hits, first_rank = 0, None
for exp in expected:
for i, c in enumerate(ranked[:10], 1):
if _overlaps(c, exp):
hits += 1
first_rank = i if first_rank is None else min(first_rank, i)
break
return {
"hit@10": hits / len(expected) if expected else 0.0,
"mrr": (1.0 / first_rank) if first_rank else 0.0,
"found": [f"{c['program']} {c['include']} L{c['line_start']}-{c['line_end']}" for c in ranked[:10]],
}
def main() -> None:
questions = [json.loads(l) for l in (HERE / "questions.jsonl").read_text(encoding="utf-8").splitlines() if l.strip()]
results = []
for q in questions:
r: dict = {"id": q["id"], "type": q["type"], "question": q.get("question", "")}
try:
if q["type"] == "find":
found = [x["program"] for x in tools.search_programs(q["question"], top_k=10)]
expected = set(q["expected_programs"])
r["recall@5"] = len(expected & set(found[:5])) / len(expected)
r["recall@10"] = len(expected & set(found[:10])) / len(expected)
r["found"] = found[:10]
elif q["type"] == "logic":
r.update(eval_logic(q))
elif q["type"] == "trace":
t = tools.trace_variable(q["program"], q["symbol"])
units: set[str] = set()
_flatten_units(t["writes"], units)
expected = set(u.upper() for u in q["expected_units"])
r["unit_hit_rate"] = len(expected & units) / len(expected)
r["units"] = sorted(units)
elif q["type"] == "explain":
s = tools.get_program_summary(q["program"])
text = json.dumps(s, ensure_ascii=False).upper()
hits = [k for k in q["expected_keywords"] if k.upper() in text]
r["keyword_hit_rate"] = len(hits) / len(q["expected_keywords"])
except Exception as e: # noqa: BLE001
r["error"] = f"{type(e).__name__}: {e}"
results.append(r)
# 유형별 평균
agg: dict[str, dict[str, float]] = {}
for r in results:
for k, v in r.items():
if isinstance(v, (int, float)) and k not in ("id",):
a = agg.setdefault(r["type"], {}).setdefault(k, [0.0, 0])
a[0] += v
a[1] += 1
summary = {t: {k: round(v[0] / v[1], 3) for k, v in m.items()} for t, m in agg.items()}
out_dir = HERE / "results"
out_dir.mkdir(exist_ok=True)
out_path = out_dir / f"{date.today().isoformat()}.json"
out_path.write_text(json.dumps({"summary": summary, "results": results}, ensure_ascii=False, indent=2),
encoding="utf-8")
print(json.dumps({"summary": summary, "results": results}, ensure_ascii=False, indent=2))
print(f"저장: {out_path}")
if __name__ == "__main__":
main()
View File
+271
View File
@@ -0,0 +1,271 @@
"""Stage 4 — 인덱스 DB.
기본 백엔드는 SQLite(FTS5) — 개발/폐쇄망에서 무의존 동작.
PostgreSQL(+pgvector) 전환은 ASSUMPTIONS.md 와 schema_postgres.sql 참고.
계획서 §6.1 스키마의 SQLite 대응판이다 (embedding 컬럼은 임베딩 모델 확정 전까지 보류).
검색의 1차 단위는 logic_chunk(LLM 이 골라낸 로직 조각) 이고 unit 은 컨테이너·얇은 색인이다
(docs/logic-chunk-design.md).
"""
from __future__ import annotations
import json
import re
import sqlite3
from pathlib import Path
from config.settings import settings
SCHEMA = """
CREATE TABLE IF NOT EXISTS package(
devclass TEXT PRIMARY KEY, text_ko TEXT, summary_json TEXT
);
CREATE TABLE IF NOT EXISTS program(
name TEXT PRIMARY KEY, devclass TEXT, title_ko TEXT,
created_on TEXT, changed_on TEXT, source_hash TEXT,
summary_json TEXT, summary_status TEXT DEFAULT 'none', has_source INTEGER DEFAULT 0,
text_symbols_json TEXT
);
CREATE TABLE IF NOT EXISTS include(
program TEXT, include TEXT, code_hash TEXT, line_count INTEGER, code TEXT,
PRIMARY KEY(program, include)
);
CREATE TABLE IF NOT EXISTS unit(
unit_id TEXT PRIMARY KEY, program TEXT, include TEXT, unit_type TEXT, name TEXT,
line_start INTEGER, line_end INTEGER, code_hash TEXT, signature TEXT,
header_comment TEXT, loc INTEGER, refs_json TEXT, sub_chunks_json TEXT,
summary_json TEXT, summary_status TEXT DEFAULT 'none', prompt_version INTEGER DEFAULT 0,
summary_error TEXT, chunk_count INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_unit_program ON unit(program);
CREATE TABLE IF NOT EXISTS symbol(
program TEXT, name TEXT, scope TEXT, unit_id TEXT, decl_include TEXT,
decl_line INTEGER, type_text TEXT, ddic_ref TEXT, kind TEXT
);
CREATE INDEX IF NOT EXISTS idx_symbol ON symbol(program, name);
-- 정의부 — 조각을 복사해 붙여넣을 때 함께 가야 하는 **선언 원문**.
-- symbol 과 행이 겹쳐 보이지만 목적이 다르다: symbol 은 추적(어디서 채워지나), declaration 은
-- 붙여넣기(무엇을 함께 가져가야 컴파일되나). 그래서 code/줄범위/의존을 따로 담는다.
-- → parser/declarations.py, index/decls.py
CREATE TABLE IF NOT EXISTS declaration(
program TEXT, name TEXT, kind TEXT, scope TEXT, unit_id TEXT, include TEXT,
line_start INTEGER, line_end INTEGER, code TEXT, type_text TEXT, depends_json TEXT
);
CREATE INDEX IF NOT EXISTS idx_decl ON declaration(program, name);
CREATE INDEX IF NOT EXISTS idx_decl_unit ON declaration(unit_id);
CREATE TABLE IF NOT EXISTS symbol_write(
id INTEGER PRIMARY KEY AUTOINCREMENT,
program TEXT, symbol TEXT, unit_id TEXT, include TEXT, line INTEGER, kind TEXT,
source_symbols TEXT, source_tables TEXT, stmt_text TEXT, callee TEXT
);
CREATE INDEX IF NOT EXISTS idx_write ON symbol_write(program, symbol);
CREATE TABLE IF NOT EXISTS symbol_read(
program TEXT, symbol TEXT, unit_id TEXT, include TEXT, line INTEGER
);
CREATE INDEX IF NOT EXISTS idx_read ON symbol_read(program, symbol);
CREATE TABLE IF NOT EXISTS table_ref(
program TEXT, unit_id TEXT, table_name TEXT, mode TEXT, line INTEGER
);
CREATE INDEX IF NOT EXISTS idx_table_ref ON table_ref(table_name);
CREATE TABLE IF NOT EXISTS call_edge(
program TEXT, from_unit TEXT, to_unit TEXT, external_name TEXT, call_type TEXT, line INTEGER
);
CREATE INDEX IF NOT EXISTS idx_edge_to ON call_edge(to_unit);
CREATE INDEX IF NOT EXISTS idx_edge_ext ON call_edge(external_name);
CREATE TABLE IF NOT EXISTS topo(
program TEXT, unit_id TEXT, ord INTEGER, PRIMARY KEY(program, unit_id)
);
CREATE TABLE IF NOT EXISTS llm_usage_log(
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT, program TEXT, model TEXT, trigger_by TEXT,
calls INTEGER, prompt_tokens INTEGER, completion_tokens INTEGER, cost_usd REAL,
done INTEGER, failed INTEGER, skipped INTEGER, elapsed_s REAL,
status TEXT DEFAULT 'done', total INTEGER DEFAULT 0, chunks INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS logic_chunk(
chunk_id TEXT PRIMARY KEY,
program TEXT, include TEXT, unit_id TEXT, seq INTEGER,
line_start INTEGER, line_end INTEGER, code_hash TEXT,
kind TEXT, purpose_ko TEXT, purpose_en TEXT,
keywords_ko TEXT, keywords_en TEXT, sap_objects TEXT,
tables_read TEXT, tables_write TEXT, calls TEXT,
confidence REAL, prompt_version INTEGER, extracted_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_chunk_program ON logic_chunk(program);
CREATE INDEX IF NOT EXISTS idx_chunk_unit ON logic_chunk(unit_id);
CREATE TABLE IF NOT EXISTS ddic_table(name TEXT PRIMARY KEY, text_ko TEXT);
CREATE TABLE IF NOT EXISTS ddic_field(tabname TEXT, field TEXT, text_ko TEXT, data_element TEXT);
CREATE TABLE IF NOT EXISTS tcode(tcode TEXT PRIMARY KEY, program TEXT, text_ko TEXT);
-- 프로그램 색인은 **두 테이블로 나눈다**. bm25 는 행 전체 길이로 정규화하므로, 짧은 타이틀과
-- 긴 요약을 같은 행에 넣으면 요약이 있는 프로그램이 오히려 밀린다 (실측: 타이틀이 "총계정원장 조회"
-- ZFIR10070 이 요약을 붙인 뒤 1위→33위. 행 길이 1,872자 vs 타이틀만 있는 행 40자).
-- 나누면 각 테이블 안에서 행 길이가 비슷해져 요약은 **점수를 더하기만** 하고 이름/타이틀 일치를
-- 밀어내지 않는다.
CREATE VIRTUAL TABLE IF NOT EXISTS program_fts USING fts5(
name, title, keywords, bigrams, tokenize='unicode61'
);
-- 서술 색인 — LLM 요약·조각 키워드·테이블·텍스트 심볼 (수정사항 1·3번)
CREATE VIRTUAL TABLE IF NOT EXISTS program_desc_fts USING fts5(
name UNINDEXED, purpose, keywords, objects, bigrams, tokenize='unicode61'
);
CREATE VIRTUAL TABLE IF NOT EXISTS unit_fts USING fts5(
unit_id UNINDEXED, program UNINDEXED, name, purpose, keywords, bigrams, tokenize='unicode61'
);
CREATE VIRTUAL TABLE IF NOT EXISTS chunk_fts USING fts5(
chunk_id UNINDEXED, program UNINDEXED, purpose, keywords, objects, bigrams, tokenize='unicode61'
);
"""
def db_path() -> Path:
url = settings.database_url
if not url.startswith("sqlite:///"):
raise RuntimeError(
f"현재 빌드는 SQLite 백엔드만 구현되어 있다 (DATABASE_URL={url}). ASSUMPTIONS.md 참고."
)
return Path(url.replace("sqlite:///", ""))
# 기존 DB에 나중에 추가된 컬럼 — CREATE TABLE IF NOT EXISTS 는 컬럼을 추가하지 못한다
_COLUMN_MIGRATIONS = [
("unit", "summary_error", "TEXT"),
("unit", "chunk_count", "INTEGER DEFAULT 0"), # 로직 조각 수 (docs/logic-chunk-design.md)
("program", "text_symbols_json", "TEXT"), # TEXT-nnn → 한국어 텍스트 (요약 문맥용)
("llm_usage_log", "status", "TEXT DEFAULT 'done'"),
("llm_usage_log", "total", "INTEGER DEFAULT 0"),
("llm_usage_log", "chunks", "INTEGER DEFAULT 0"),
]
def _migrate(con: sqlite3.Connection) -> None:
for table, column, decl in _COLUMN_MIGRATIONS:
cols = {r[1] for r in con.execute(f"PRAGMA table_info({table})")}
if column not in cols:
con.execute(f"ALTER TABLE {table} ADD COLUMN {column} {decl}")
_migrate_fts(con)
# FTS5 가상테이블은 ALTER 로 컬럼을 추가할 수 없다. 모두 파생 데이터(program/unit/logic_chunk 에서
# 재생성 가능)이므로 컬럼 구성이 달라지면 DROP 후 다시 만든다. 재적재는 호출 측 책임:
# program_fts → index.loader.refresh_program_fts(con)
# chunk_fts / unit_fts → 해당 프로그램 재적재 또는 요약 재실행
_FTS_EXPECTED = {
"program_fts": ["name", "title", "keywords", "bigrams"],
"program_desc_fts": ["name", "purpose", "keywords", "objects", "bigrams"],
"unit_fts": ["unit_id", "program", "name", "purpose", "keywords", "bigrams"],
"chunk_fts": ["chunk_id", "program", "purpose", "keywords", "objects", "bigrams"],
}
def _migrate_fts(con: sqlite3.Connection) -> list[str]:
"""컬럼 구성이 바뀐 FTS 테이블을 재생성하고, 재생성한 테이블 이름을 돌려준다."""
rebuilt: list[str] = []
for table, expected in _FTS_EXPECTED.items():
cols = [r[1] for r in con.execute(f"PRAGMA table_info({table})")]
if cols and cols != expected:
con.execute(f"DROP TABLE {table}")
con.executescript(SCHEMA) # IF NOT EXISTS — 빠진 테이블만 다시 만든다
rebuilt.append(table)
return rebuilt
def connect() -> sqlite3.Connection:
path = db_path()
path.parent.mkdir(parents=True, exist_ok=True)
con = sqlite3.connect(str(path))
con.row_factory = sqlite3.Row
con.execute("PRAGMA journal_mode=WAL")
con.executescript(SCHEMA)
_migrate(con)
return con
_HANGUL_SEQ = re.compile(r"[가-힣]{2,}")
_TEXT_SYM_REF = re.compile(r"TEXT-(\w{3})", re.I)
def clean_comment(text: str | None) -> str:
"""헤더 주석에서 장식 문자(----, ****, &, *&)를 제거해 사람이 읽을 문장만 남긴다.
적재 시점의 unit_fts.purpose 와 요약 프롬프트가 모두 이걸 거쳐야 한다 — 거치지 않으면
색인에 '----------------' 만 들어간다 (수정사항 1번).
"""
if not text:
return ""
parts = re.split(r"[*&\-]{3,}|\s\*\s|&", text)
words = " ".join(p.strip() for p in parts if p.strip())
words = re.sub(r"^(Form|FORM|Include|INCLUDE)\s+", "", words).strip()
return words[:150]
def text_symbol_phrases(code: str, text_symbols: dict[str, str]) -> list[str]:
"""코드에 등장하는 TEXT-nnn 을 실제 한국어 텍스트로 치환한 목록 (수정사항 3번).
LLM 없이도 조각·프로그램에 자연어 앵커가 생기는 가장 싼 경로라 FTS 에도 넣는다.
"""
if not code or not text_symbols:
return []
out: list[str] = []
for m in _TEXT_SYM_REF.finditer(code):
val = text_symbols.get(m.group(1).upper())
if val and val not in out:
out.append(val)
return out
def bigrams(text: str) -> str:
"""한국어 형태소 분석기 없이 부분일치를 위해 한글 연속열의 2-gram을 공백 구분으로 나열."""
grams: list[str] = []
for seq in _HANGUL_SEQ.findall(text or ""):
grams.extend(seq[i : i + 2] for i in range(len(seq) - 1))
return " ".join(dict.fromkeys(grams))
# FTS5 컬럼 가중치. 기본 `rank`(= 모든 컬럼 가중치 1)를 쓰면 안 된다:
# bm25 는 문서 길이로 정규화하므로, 요약·키워드가 붙어 **길어진** 행이 타이틀만 있는 행보다
# 낮게 나온다. 요약이 있는 프로그램이 오히려 밀리는 역전이 생긴다(실측: 타이틀이 "총계정원장 조회"인
# ZFIR10070 이 요약을 붙인 뒤 top5 밖으로 밀려났다). 이름·타이틀을 강하게 가중해 바로잡는다.
_BM25_WEIGHTS = {
# 컬럼 순서와 같아야 한다 (UNINDEXED 컬럼도 자리를 차지한다)
"program_fts": (12.0, 8.0, 1.5, 1.0), # name title keywords bigrams
"program_desc_fts": (0.0, 3.0, 2.0, 1.5, 1.0), # name purpose keywords objects bigrams
"unit_fts": (0.0, 0.0, 6.0, 2.0, 1.5, 1.0), # unit_id program name purpose keywords bigrams
"chunk_fts": (0.0, 0.0, 4.0, 2.0, 1.5, 1.0), # chunk_id program purpose keywords objects bigrams
}
def bm25_rank(table: str) -> str:
"""`ORDER BY` / `SELECT` 에 넣을 가중 bm25 식. 점수는 음수이고 작을수록 관련도가 높다."""
weights = ", ".join(f"{w}" for w in _BM25_WEIGHTS[table])
return f"bm25({table}, {weights})"
def query_tokens(q: str) -> list[str]:
"""질의 문자열 → 검색 토큰 목록 (구두점·기호 제거)."""
return [t for t in re.split(r"[^\w가-힣]+", q or "") if t]
def fts_or(terms: list[str]) -> str:
"""임의 용어 목록 → FTS5 MATCH 식. 용어마다 큰따옴표로 감싸고 한글 2-gram 을 함께 OR 결합.
질의 확장(query/expand.py)이 원질의와 동의어를 서로 다른 MATCH 식으로 나눠 쓰기 위해
fts_escape 에서 분리했다.
"""
parts: list[str] = []
for t in terms:
t = t.strip()
if not t:
continue
parts.append('"' + t.replace('"', "") + '"')
for g in bigrams(t).split():
parts.append(f'"{g}"')
return " OR ".join(dict.fromkeys(parts)) if parts else '""'
def fts_escape(q: str) -> str:
"""FTS5 MATCH 질의에 안전한 형태로: 토큰별 큰따옴표 감싸고 OR 결합."""
return fts_or(query_tokens(q))
def loads(v: str | None):
return json.loads(v) if v else None
+237
View File
@@ -0,0 +1,237 @@
"""정의부 조립 — 조각(또는 unit) 코드를 붙여넣을 때 함께 가야 하는 선언을 모은다.
인덱스에 든 코드 원문은 **로직만**이다. ABAP 은 내부테이블·스트럭처·상수·필드심볼을 TOP 인클루드나
FORM 머리에 따로 선언하므로, 로직만 복사하면 그대로는 컴파일되지 않는다. 여기서 하는 일:
1. 조각 코드에 등장하는 이름을 모은다 (파서 토크나이저)
2. 그 이름의 선언을 `declaration` 테이블에서 찾는다 (unit 로컬 → 전역 순)
3. 선언이 참조하는 다른 선언을 **재귀로** 끌어온다
(`LT_DATA LIKE GT_DATA` → GT_DATA, `GT_ITEMS TYPE TY_ITEMS` → TY_ITEMS → TY_ITEM)
4. 의존이 먼저 오도록 정렬해 **붙여넣을 수 있는 한 덩어리**로 만든다
**쓸지 말지는 판단하지 않는다.** 붙여넣는 쪽 LLM 이 정한다(이미 있으면 버리고, 이름이 겹치면 바꾸고).
그래서 분류만 붙여 넘긴다: 가져가야 할 선언 / DDIC 외부 참조 / FORM 파라미터 / 못 찾은 이름.
조각→선언 대응을 따로 저장하지 않고 **읽을 때 계산**한다. 결정론적이라 언제 계산해도 같고,
조각이 재추출되거나 선언이 바뀌어도 엇갈리지 않는다 (logic_chunk 에 캐시하면 둘이 따로 늙는다).
"""
from __future__ import annotations
import re
import sqlite3
from parser.declarations import BUILTIN_TYPES, base_name, clean_ref, type_name_after
from parser.statements import split_statements
from .db import loads
MAX_DECLS = 80 # 이 이상은 붙여넣기용으로 의미가 없다 (TOP 전체를 퍼오는 꼴)
MAX_CODE_LINES = 500
# 선언을 못 찾았을 때 "변수처럼 생겼는가" 판정 — 한국 SAP 관행의 접두사(GV_/LT_/LS_/P_ …).
# DDIC 이름(LVC_T_FCAT, W3_QVALUE)이나 키워드가 섞여 들어오지 않게 접두사를 2자 이내로 제한한다.
_VARLIKE = re.compile(r"^[A-Z]{1,2}_[A-Z0-9_]*$")
_FIELD_SYMBOL = re.compile(r"^<[A-Z0-9_]+>$")
# 전역 리포지토리 객체(클래스·인터페이스·예외) — 프로그램 선언이 아니므로 '못 찾았다'고 보고하지 않는다
_REPO_OBJECT = re.compile(r"^(Z?CL|Z?CX|Z?IF|Y?CL)_")
# `X-Y` 형태의 참조가 가리킬 수 있는 선언 종류. `TABLES: BSEG` 작업영역이나 타입풀은 여기 없다 —
# `LIKE BSEG-WRBTR` 은 사전에서 타입을 빌려 쓴 것이라 그 선언을 함께 가져갈 필요가 없다.
_COMPONENT_KINDS = {"data", "types", "constants", "ranges", "select-option", "field-symbol"}
# 호출문의 파라미터 구획 — 이 뒤의 `이름 = 값` 에서 왼쪽 이름은 호출 대상의 인터페이스다
_SECTION_KEYWORDS = {"EXPORTING", "IMPORTING", "CHANGING", "TABLES", "RECEIVING", "EXCEPTIONS"}
def _row(d: sqlite3.Row, via: str) -> dict:
return {
"name": d["name"], "kind": d["kind"], "scope": d["scope"], "include": d["include"],
"line_start": d["line_start"], "line_end": d["line_end"], "code": d["code"],
"type_text": d["type_text"] or "", "depends": loads(d["depends_json"]) or [], "via": via,
}
def _index(con: sqlite3.Connection, program: str) -> tuple[dict, dict]:
"""(전역 선언 {이름: row}, unit 로컬 선언 {(unit_id, 이름): row})
같은 이름이 여러 번 선언돼 있으면(공용 인클루드 중복) 먼저 나온 것을 쓴다 — 결정론적이면 된다.
"""
glob: dict[str, sqlite3.Row] = {}
local: dict[tuple[str, str], sqlite3.Row] = {}
for d in con.execute(
"SELECT * FROM declaration WHERE program=? ORDER BY include, line_start", (program,)
):
if d["scope"] == "unit" and d["unit_id"]:
local.setdefault((d["unit_id"], d["name"]), d)
else:
glob.setdefault(d["name"], d)
return glob, local
def used_names(code: str) -> list[str]:
"""코드에 등장하는 식별자 (등장 순서 유지).
보통은 기본 이름만 남기지만(`GT_LOAD-SEQNO` → GT_LOAD), **TYPE/LIKE 뒤의 참조는 컴포넌트를
붙인 채로** 돌려준다(`LIKE BSEG-WRBTR` → BSEG-WRBTR). 사전 타입을 빌려 쓴 것과 작업영역을
실제로 쓰는 것을 뒤에서 구분하기 위해서다.
"""
out: list[str] = []
def emit(ref: str) -> None:
if base_name(ref) in BUILTIN_TYPES:
return
if (_FIELD_SYMBOL.match(base_name(ref)) or re.match(r"^[A-Z_/][A-Z0-9_/-]*$", ref)) \
and ref not in out:
out.append(ref)
# 문장 단위로 본다 — `CALL FUNCTION` 의 **형식 파라미터 이름**(I_BUKRS = gv_bukrs 의 왼쪽)과
# 예외 이름(NO_RATE_FOUND)은 이 프로그램의 변수가 아니라 호출 대상의 인터페이스다.
# 이름만 보고는 변수와 구분되지 않아(둘 다 I_/P_/NO_ …) 여기서 걸러야 한다.
for st in split_statements(code, "chunk")[0]:
up = st.upper
# 파라미터 구획 키워드가 있으면 `name =` 의 왼쪽은 형식 파라미터다.
# `CALL FUNCTION` 뿐 아니라 함수형 메서드 호출(`cl_x=>f( IMPORTING r = v )`)도 여기 걸린다.
is_call = bool(up) and (up[0] == "CALL" or bool(_SECTION_KEYWORDS & set(up)))
in_exceptions = False
i = 0
while i < len(up):
t = up[i]
if t in {"TYPE", "LIKE"}:
name, i = type_name_after(up, i)
if name:
emit(clean_ref(name))
continue
if is_call and t == "EXCEPTIONS":
in_exceptions = True
elif in_exceptions or (is_call and i + 1 < len(up) and up[i + 1] == "="):
pass # 예외 이름 / 형식 파라미터 이름 — 건너뛴다
else:
emit(base_name(t))
i += 1
return out
def resolve(con: sqlite3.Connection, program: str, unit_id: str | None, code: str,
self_include: str | None = None, self_range: tuple[int, int] | None = None) -> dict:
"""코드 한 덩어리 → 정의부.
self_include/self_range 를 주면 **그 범위 안에 있는 선언은 뺀다** — 이미 코드에 들어 있으므로
앞에 또 붙이면 중복 선언이 된다.
"""
glob, local = _index(con, program)
params = {r["name"] for r in con.execute(
"SELECT name FROM symbol WHERE program=? AND unit_id=? AND kind='param'", (program, unit_id))}
def lookup(name: str, scoped: bool) -> sqlite3.Row | None:
if scoped and unit_id and (unit_id, name) in local:
return local[(unit_id, name)]
return glob.get(name)
def inside_self(d: sqlite3.Row) -> bool:
return bool(self_range and self_include == d["include"]
and d["line_start"] >= self_range[0] and d["line_end"] <= self_range[1])
picked: dict[tuple[str, str, int], dict] = {} # (include, name, line_start) → row
external: list[str] = []
unresolved: list[str] = []
param_hits: list[str] = []
ordered: list[dict] = []
visiting: set[str] = set()
def walk(ref: str, via: str, scoped: bool) -> None:
"""의존을 먼저 방문(post-order)해 TYPES 가 DATA 보다 앞에 오게 한다.
`ref` 는 `GT_DATA` 이거나 컴포넌트까지 붙은 `BKPF-BELNR` 이다. 후자가 DDIC 테이블을
가리키면(= 지역 선언이 `TABLES:` 작업영역뿐이면) 사전 타입을 빌려 쓴 것이므로
**작업영역 선언을 끌어오지 않는다** — 붙여넣는 코드에는 필요 없다.
"""
name = base_name(ref)
if name in visiting:
return
d = lookup(name, scoped)
if d is not None and "-" in ref and d["kind"] not in _COMPONENT_KINDS:
if name not in external:
external.append(name)
return
if d is None:
if name in params:
if name not in param_hits:
param_hits.append(name)
elif via == "dependency":
if name not in external:
external.append(name) # DDIC 타입/구조 — 선언을 가져갈 필요가 없다
elif (_VARLIKE.match(name) or _FIELD_SYMBOL.match(name)) \
and not _REPO_OBJECT.match(name) and name not in unresolved:
unresolved.append(name) # 변수처럼 생겼는데 선언이 없다 (수집 누락 가능)
return
key = (d["include"], d["name"], d["line_start"])
if key in picked or inside_self(d):
return
visiting.add(name)
for dep in loads(d["depends_json"]) or []:
walk(dep, "dependency", d["scope"] == "unit")
visiting.discard(name)
if key not in picked:
row = _row(d, via)
picked[key] = row
ordered.append(row)
for n in used_names(code):
walk(n, "used", True)
truncated = len(ordered) > MAX_DECLS
ordered = ordered[:MAX_DECLS]
return {
"count": len(ordered),
"declarations": ordered,
"code": render(ordered, program),
"external_refs": external,
"params": param_hits,
"unresolved": unresolved,
"truncated": truncated,
}
def render(decls: list[dict], program: str = "") -> str:
"""선언 목록 → 그대로 붙여넣을 수 있는 정의부 한 덩어리 (출처를 주석으로 남긴다)."""
if not decls:
return ""
out = [
"*&---------------------------------------------------------------------*",
f"*& 정의부 — {program} 에서 가져온 선언. 이 로직을 옮겨 붙일 때 함께 필요하다.",
"*& 이미 같은 선언이 있으면 버리고, 이름이 겹치면 바꿔서 쓸 것.",
"*&---------------------------------------------------------------------*",
]
seen_span: set[tuple[str, int, int]] = set()
n_lines = 0
for d in decls:
span = (d["include"], d["line_start"], d["line_end"])
if span in seen_span: # 한 줄 체인(`TABLES: a, b.`)에서 온 형제들
continue
seen_span.add(span)
body = d["code"]
n_lines += body.count("\n") + 1
if n_lines > MAX_CODE_LINES:
out.append(f"* … (정의부가 길어 생략 — 나머지는 GET /programs/{program}/declarations)")
break
out.append(f"* {d['include']} L{d['line_start']}-L{d['line_end']} · {d['kind']}"
f"{' · unit 로컬' if d['scope'] == 'unit' else ''}")
out.append(body)
return "\n".join(out)
def for_chunk(con: sqlite3.Connection, chunk: sqlite3.Row, code: str) -> dict:
"""logic_chunk 행 + 조각 코드 → 정의부."""
return resolve(con, chunk["program"], chunk["unit_id"], code,
self_include=chunk["include"],
self_range=(chunk["line_start"], chunk["line_end"]))
def program_declarations(con: sqlite3.Connection, program: str, scope: str | None = None) -> list[dict]:
"""프로그램의 선언 전체 (위키 `## 정의부` 섹션용)."""
sql = "SELECT * FROM declaration WHERE program=?"
params: list = [program.upper()]
if scope:
sql += " AND scope=?"
params.append(scope)
return [_row(d, "catalog") for d in con.execute(sql + " ORDER BY include, line_start", params)]
+331
View File
@@ -0,0 +1,331 @@
"""Stage 4 로더 — packages.jsonl + parse.json → SQLite upsert.
python -m index.loader [--program X] [--limit N]
증분: program.source_hash(전체 인클루드 해시 결합)가 같으면 스킵.
재적재 시 code_hash 가 같은 unit 의 요약·로직 조각(logic_chunk)은 보존한다.
"""
from __future__ import annotations
import argparse
import hashlib
import json
from pathlib import Path
from config.settings import settings
from .db import bigrams, clean_comment, connect, loads
def load_packages(con) -> int:
path = settings.data_normalized / "packages.jsonl"
if not path.exists():
return 0
n = 0
with path.open(encoding="utf-8") as f:
for line in f:
row = json.loads(line)
con.execute(
"INSERT INTO package(devclass, text_ko) VALUES(?,?) "
"ON CONFLICT(devclass) DO NOTHING",
(row["devclass"], ""),
)
con.execute(
"INSERT INTO program(name, devclass, title_ko, created_on, changed_on) "
"VALUES(?,?,?,?,?) "
"ON CONFLICT(name) DO UPDATE SET devclass=excluded.devclass, "
"title_ko=excluded.title_ko, created_on=excluded.created_on, changed_on=excluded.changed_on",
(row["obj_name"], row["devclass"], row["text"], row["created_on"], row["changed_on"]),
)
n += 1
return n
def load_tcodes(con) -> int:
"""data/normalized/tcodes.jsonl → tcode 테이블.
TSTC 를 못 받은 상태(ASSUMPTIONS.md §5)에서, 덤프 헤더 주석의 `* T_CODE :` 값을
ingest/from_dir.py 가 모아둔 것을 적재한다. tcodes/ 위키 페이지의 입력이 된다.
"""
path = settings.data_normalized / "tcodes.jsonl"
if not path.exists():
return 0
n = 0
with path.open(encoding="utf-8") as f:
for line in f:
if not line.strip():
continue
row = json.loads(line)
con.execute(
"INSERT INTO tcode(tcode, program, text_ko) VALUES(?,?,?) "
"ON CONFLICT(tcode) DO UPDATE SET program=excluded.program, text_ko=excluded.text_ko",
(row["tcode"].upper(), (row.get("program") or "").upper(), row.get("text_ko") or ""),
)
n += 1
return n
def load_parsed(con, parse_path: Path, force: bool = False) -> str:
"""parse.json 한 건 적재. force=True 면 source_hash 가 같아도 구조를 다시 적재한다.
파서나 색인 로직이 바뀐 경우(소스는 그대로인데 산출물이 달라지는 경우) 강제 재적재가 필요하다.
"""
d = json.loads(parse_path.read_text(encoding="utf-8"))
program = d["program"]
source_hash = hashlib.sha256(
"".join(i["sha256"] for i in d["includes"]).encode()
).hexdigest()
cur = con.execute("SELECT source_hash FROM program WHERE name=?", (program,))
row = cur.fetchone()
if row and row["source_hash"] == source_hash and not force:
# 구조는 그대로 — 나중에 추가된 컬럼(텍스트 심볼)만 비어 있으면 채운다
con.execute("UPDATE program SET text_symbols_json=? WHERE name=? AND text_symbols_json IS NULL",
(json.dumps(d.get("text_symbols", []), ensure_ascii=False), program))
return "skip"
title = d.get("description", "")
text_symbols = json.dumps(d.get("text_symbols", []), ensure_ascii=False)
con.execute(
"INSERT INTO program(name, title_ko, source_hash, has_source, text_symbols_json) VALUES(?,?,?,1,?) "
"ON CONFLICT(name) DO UPDATE SET "
"title_ko=CASE WHEN excluded.title_ko!='' THEN excluded.title_ko ELSE program.title_ko END, "
"source_hash=excluded.source_hash, has_source=1, summary_status='stale', "
"text_symbols_json=excluded.text_symbols_json",
(program, title, source_hash, text_symbols),
)
# 재적재 전, code_hash 가 같은 unit 의 기존 요약(얇은 색인)·로직 조각을 보존해 둔다
kept_summaries = {
r["unit_id"]: (r["code_hash"], r["summary_json"], r["prompt_version"], r["chunk_count"])
for r in con.execute(
"SELECT unit_id, code_hash, summary_json, prompt_version, chunk_count FROM unit "
"WHERE program=? AND summary_status='done'", (program,)
).fetchall()
}
# 기존 구조 데이터 삭제 후 재적재 (unit 단위 증분은 v2)
for table in ("include", "unit", "symbol", "declaration", "symbol_write", "symbol_read",
"table_ref", "call_edge", "topo"):
con.execute(f"DELETE FROM {table} WHERE program=?", (program,))
con.execute("DELETE FROM unit_fts WHERE program=?", (program,))
norm_dir = settings.data_normalized / program
for inc in d["includes"]:
code_file = norm_dir / f"{inc['include']}.abap"
code = code_file.read_text(encoding="utf-8") if code_file.exists() else ""
con.execute(
"INSERT INTO include(program, include, code_hash, line_count, code) VALUES(?,?,?,?,?)",
(program, inc["include"], inc["sha256"], inc["line_count"], code),
)
kept_unit_ids: set[str] = set()
for u in d["units"]:
con.execute(
"INSERT INTO unit(unit_id, program, include, unit_type, name, line_start, line_end, "
"code_hash, signature, header_comment, loc, refs_json, sub_chunks_json) "
"VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)",
(u["unit_id"], program, u["include"], u["unit_type"], u["name"],
u["line_start"], u["line_end"], u["code_hash"], u.get("signature", ""),
u.get("header_comment", ""), u.get("loc", 0),
json.dumps(u.get("refs"), ensure_ascii=False) if u.get("refs") else None,
json.dumps(u.get("sub_chunks", []), ensure_ascii=False)),
)
kept = kept_summaries.get(u["unit_id"])
if kept and kept[0] == u["code_hash"]:
con.execute(
"UPDATE unit SET summary_json=?, summary_status='done', prompt_version=?, chunk_count=? "
"WHERE unit_id=?",
(kept[1], kept[2], kept[3] or 0, u["unit_id"]),
)
kept_unit_ids.add(u["unit_id"])
refs = u.get("refs") or {}
for t in refs.get("tables_read", []):
con.execute("INSERT INTO table_ref VALUES(?,?,?,?,?)", (program, u["unit_id"], t, "read", 0))
for t in refs.get("tables_write", []):
con.execute("INSERT INTO table_ref VALUES(?,?,?,?,?)", (program, u["unit_id"], t, "write", 0))
# unit FTS — 얇은 색인 (이름/시그니처/주석; 요약이 있으면 한 줄 요약도)
# 장식 문자('----* INITIALIZATION ----*')를 그대로 넣으면 색인이 쓰레기가 된다 (수정사항 1번)
purpose = clean_comment(u.get("header_comment", ""))
if kept and kept[0] == u["code_hash"] and kept[1]:
purpose = (json.loads(kept[1]).get("purpose_ko") or purpose)
text = " ".join(filter(None, [u["name"], u.get("signature", ""), purpose]))
con.execute(
"INSERT INTO unit_fts(unit_id, program, name, purpose, keywords, bigrams) VALUES(?,?,?,?,?,?)",
(u["unit_id"], program, u["name"], purpose, "", bigrams(text)),
)
# 로직 조각: code_hash 가 같은 unit 의 조각만 남기고 나머지는 삭제 (재추출 대상)
stale_chunks = [
r["chunk_id"] for r in con.execute(
"SELECT chunk_id, unit_id FROM logic_chunk WHERE program=?", (program,)
).fetchall() if r["unit_id"] not in kept_unit_ids
]
for cid in stale_chunks:
con.execute("DELETE FROM logic_chunk WHERE chunk_id=?", (cid,))
con.execute("DELETE FROM chunk_fts WHERE chunk_id=?", (cid,))
for s in d["symbols"]:
con.execute(
"INSERT INTO symbol VALUES(?,?,?,?,?,?,?,?,?)",
(program, s["name"], s["scope"], s.get("unit_id"), s.get("decl_include", ""),
s.get("decl_line", 0), s.get("type_text", ""), s.get("ddic_ref", ""), s.get("kind", "")),
)
# 정의부 — 붙여넣기용 선언 원문 (parser/declarations.py)
for dc in d.get("declarations", []):
con.execute(
"INSERT INTO declaration(program, name, kind, scope, unit_id, include, line_start, "
"line_end, code, type_text, depends_json) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
(program, dc["name"], dc["kind"], dc["scope"], dc.get("unit_id"), dc["include"],
dc["line_start"], dc["line_end"], dc["code"], dc.get("type_text", ""),
json.dumps(dc.get("depends", []), ensure_ascii=False)),
)
for w in d["writes"]:
con.execute(
"INSERT INTO symbol_write(program, symbol, unit_id, include, line, kind, "
"source_symbols, source_tables, stmt_text, callee) VALUES(?,?,?,?,?,?,?,?,?,?)",
(program, w["symbol"], w["unit_id"], w["include"], w["line"], w["kind"],
json.dumps(w.get("source_symbols", [])), json.dumps(w.get("source_tables", [])),
w.get("stmt_text", ""), w.get("callee", "")),
)
for r in d["reads"]:
con.execute(
"INSERT INTO symbol_read VALUES(?,?,?,?,?)",
(program, r["symbol"], r["unit_id"], r["include"], r["line"]),
)
for e in d["call_edges"]:
con.execute(
"INSERT INTO call_edge VALUES(?,?,?,?,?,?)",
(program, e["from_unit"], e.get("to_unit"), e.get("external_name"),
e["call_type"], e.get("line", 0)),
)
for i, uid in enumerate(d.get("topo_order", [])):
con.execute("INSERT OR REPLACE INTO topo VALUES(?,?,?)", (program, uid, i))
return "loaded"
def _identity_row(row) -> tuple:
"""program_fts — 이름·타이틀·패키지만. **짧게 유지한다.**
요약을 여기에 섞으면 bm25 의 길이 정규화 때문에 요약이 있는 프로그램이 오히려 밀린다
(index/db.py 의 주석 참고). 서술 텍스트는 program_desc_fts 로 분리한다.
"""
title = row["title_ko"] or ""
text = " ".join(filter(None, [title, row["pkg_text"]]))
return (row["name"], title,
" ".join(filter(None, [row["devclass"] or "", row["pkg_text"] or ""])),
bigrams(text))
def _desc_row(con, row) -> tuple | None:
"""program_desc_fts — LLM 요약·조각 키워드·테이블·텍스트 심볼 (수정사항 1·3번).
"입고 처리하는 프로그램" 처럼 이름·타이틀에 없는 말로 물어도 걸리게 하는 색인이다.
넣을 게 없으면 None (빈 행을 만들면 길이 정규화만 왜곡된다).
"""
name = row["name"]
summary = loads(row["summary_json"]) or {}
purpose = " ".join(filter(None, [
summary.get("business_purpose_ko") or "",
summary.get("business_purpose_en") or "",
" ".join(summary.get("main_flow") or []),
]))
kw: list[str] = list(summary.get("keywords_ko") or []) + list(summary.get("keywords_en") or [])
kw += list(summary.get("business_tags") or [])
if summary.get("sap_module"):
kw.append(summary["sap_module"])
objs: list[str] = list(summary.get("related_tcodes") or [])
for r in con.execute(
"SELECT keywords_ko, keywords_en, sap_objects FROM logic_chunk WHERE program=?", (name,)
):
kw += (loads(r["keywords_ko"]) or []) + (loads(r["keywords_en"]) or [])
objs += loads(r["sap_objects"]) or []
objs += [t["table_name"] for t in con.execute(
"SELECT DISTINCT table_name FROM table_ref WHERE program=? LIMIT 200", (name,))]
# 텍스트 심볼의 한국어 원문 — LLM 없이도 자연어 앵커가 생긴다 (수정사항 3번)
ts = [t.get("text", "") for t in (loads(row["text_symbols_json"]) or []) if t.get("text")]
kw = list(dict.fromkeys(k for k in kw if k))[:200]
objs = list(dict.fromkeys(o.upper() for o in objs if o))[:200]
purpose_text = " ".join(filter(None, [purpose, " ".join(ts)]))
if not (purpose_text or kw or objs):
return None
return (name, purpose_text, " ".join(kw), " ".join(objs),
bigrams(" ".join(filter(None, [purpose, " ".join(kw), " ".join(ts)]))))
_PROGRAM_FTS_SQL = (
"SELECT p.name, p.title_ko, p.devclass, p.summary_json, p.text_symbols_json, "
"COALESCE(k.text_ko,'') AS pkg_text FROM program p "
"LEFT JOIN package k ON k.devclass = p.devclass"
)
def refresh_program_fts(con, program: str | None = None) -> int:
"""program_fts + program_desc_fts 재구축. program 을 주면 그 한 건만 갱신한다.
전량 재구축을 /ingest 마다 하면 1만 본에서 O(N²) 가 된다 — 단건 경로가 필요하다.
"""
if program:
name = program.upper()
con.execute("DELETE FROM program_fts WHERE name=?", (name,))
con.execute("DELETE FROM program_desc_fts WHERE name=?", (name,))
rows = con.execute(_PROGRAM_FTS_SQL + " WHERE p.name=?", (name,)).fetchall()
else:
con.execute("DELETE FROM program_fts")
con.execute("DELETE FROM program_desc_fts")
rows = con.execute(_PROGRAM_FTS_SQL).fetchall()
for row in rows:
con.execute(
"INSERT INTO program_fts(name, title, keywords, bigrams) VALUES(?,?,?,?)",
_identity_row(row),
)
desc = _desc_row(con, row)
if desc:
con.execute(
"INSERT INTO program_desc_fts(name, purpose, keywords, objects, bigrams) "
"VALUES(?,?,?,?,?)",
desc,
)
return len(rows)
def main() -> None:
ap = argparse.ArgumentParser(description="Stage 4 — 인덱스 적재")
ap.add_argument("--program", default=None)
ap.add_argument("--limit", type=int, default=None)
ap.add_argument("--force", action="store_true",
help="source_hash 가 같아도 재적재 (파서·색인 로직이 바뀐 경우)")
args = ap.parse_args()
con = connect()
n_pkg = load_packages(con)
n_tcode = load_tcodes(con)
parsed = sorted(settings.data_parsed.glob("*.parse.json")) if settings.data_parsed.exists() else []
if args.program:
parsed = [p for p in parsed if p.stem.replace(".parse", "") == args.program.upper()]
if args.limit:
parsed = parsed[: args.limit]
stats = {"package_rows": n_pkg, "tcodes": n_tcode, "loaded": 0, "skipped": 0, "errors": 0}
con.commit()
for p in parsed:
try:
r = load_parsed(con, p, force=args.force)
con.commit() # 프로그램 단위 커밋 — 실패 시 해당 프로그램만 롤백
stats["loaded" if r == "loaded" else "skipped"] += 1
except Exception as e: # noqa: BLE001
con.rollback()
stats["errors"] += 1
print(f"[FAIL] {p.name}: {type(e).__name__}: {e}")
refresh_program_fts(con)
con.commit()
con.close()
print(json.dumps(stats, ensure_ascii=False))
if __name__ == "__main__":
main()
+56
View File
@@ -0,0 +1,56 @@
-- 계획서 §6.1 원안 — PostgreSQL 16 + pgvector 전환 시 사용 (ASSUMPTIONS.md §1)
-- 임베딩 차원(1024)은 모델 확정 후 조정할 것.
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE IF NOT EXISTS package(
devclass TEXT PRIMARY KEY, text_ko TEXT, summary_json JSONB, embedding vector(1024)
);
CREATE TABLE IF NOT EXISTS program(
name TEXT PRIMARY KEY, devclass TEXT, title_ko TEXT,
created_on DATE, changed_on DATE, source_hash TEXT,
summary_json JSONB, summary_status TEXT DEFAULT 'none', has_source BOOLEAN DEFAULT FALSE,
embedding vector(1024), fts tsvector
);
CREATE TABLE IF NOT EXISTS include(
program TEXT, include TEXT, code_hash TEXT, line_count INT, code TEXT,
PRIMARY KEY(program, include)
);
CREATE TABLE IF NOT EXISTS unit(
unit_id TEXT PRIMARY KEY, program TEXT, include TEXT, unit_type TEXT, name TEXT,
line_start INT, line_end INT, code_hash TEXT, signature TEXT, header_comment TEXT,
loc INT, refs_json JSONB, sub_chunks_json JSONB,
summary_json JSONB, summary_status TEXT DEFAULT 'none', prompt_version INT DEFAULT 0,
embedding vector(1024), fts tsvector
);
CREATE TABLE IF NOT EXISTS symbol(
program TEXT, name TEXT, scope TEXT, unit_id TEXT, decl_include TEXT,
decl_line INT, type_text TEXT, ddic_ref TEXT, kind TEXT
);
CREATE TABLE IF NOT EXISTS symbol_write(
id BIGSERIAL PRIMARY KEY,
program TEXT, symbol TEXT, unit_id TEXT, include TEXT, line INT, kind TEXT,
source_symbols TEXT[], source_tables TEXT[], stmt_text TEXT, callee TEXT
);
CREATE TABLE IF NOT EXISTS symbol_read(
program TEXT, symbol TEXT, unit_id TEXT, include TEXT, line INT
);
CREATE TABLE IF NOT EXISTS table_ref(
program TEXT, unit_id TEXT, table_name TEXT, mode TEXT, line INT
);
CREATE TABLE IF NOT EXISTS call_edge(
program TEXT, from_unit TEXT, to_unit TEXT, external_name TEXT, call_type TEXT, line INT
);
CREATE TABLE IF NOT EXISTS topo(program TEXT, unit_id TEXT, ord INT, PRIMARY KEY(program, unit_id));
CREATE TABLE IF NOT EXISTS ddic_table(name TEXT PRIMARY KEY, text_ko TEXT);
CREATE TABLE IF NOT EXISTS ddic_field(tabname TEXT, field TEXT, text_ko TEXT, data_element TEXT);
CREATE TABLE IF NOT EXISTS tcode(tcode TEXT PRIMARY KEY, program TEXT, text_ko TEXT);
CREATE TABLE IF NOT EXISTS eval_question(id TEXT PRIMARY KEY, question TEXT, expected_programs TEXT[], type TEXT);
CREATE INDEX IF NOT EXISTS idx_unit_fts ON unit USING GIN(fts);
CREATE INDEX IF NOT EXISTS idx_program_fts ON program USING GIN(fts);
CREATE INDEX IF NOT EXISTS idx_unit_emb ON unit USING hnsw(embedding vector_cosine_ops);
CREATE INDEX IF NOT EXISTS idx_program_emb ON program USING hnsw(embedding vector_cosine_ops);
CREATE INDEX IF NOT EXISTS idx_symbol_write ON symbol_write(program, symbol);
CREATE INDEX IF NOT EXISTS idx_table_ref ON table_ref(table_name);
CREATE INDEX IF NOT EXISTS idx_edge_to ON call_edge(to_unit);
CREATE INDEX IF NOT EXISTS idx_edge_ext ON call_edge(external_name);
View File
+242
View File
@@ -0,0 +1,242 @@
"""Stage 1 대안 입력 — 프로그램별 디렉토리에 인클루드가 .txt 로 있는 덤프를 정규화한다.
`ingest/normalize.py` 는 수집 API 의 JSON(.txt) 을 받는다. 이 모듈은 다른 형태를 받는다:
<root>/<program>/<include>.txt 인클루드별 ABAP 원문 (CP949)
<root>/<program>/screens/*.txt 화면·GUI 타이틀 텍스트 (참고용, 파싱 대상 아님)
<root>/<program>/dictionary_objects/ DDIC 오브젝트 HTML (참고용)
python -m ingest.from_dir <root> [--out DIR] [--limit N] [--dry-run]
핵심 차이 두 가지:
1. **인코딩** — 파일이 CP949(EUC-KR) 다. UTF-8 로 읽으면 깨진다.
2. **메타가 코드 주석에 있다** — 수집 JSON 의 DESCRIPTION/T_CODE 에 해당하는 값이 주 프로그램
헤더 주석 박스에 들어 있다:
* Report : ZFIR0010
* Module/Sub-Module : FI / GL
* T_CODE : ZFIR0010
* Description : 거래처마스터 I/F 이력조회
이걸 긁어 수집 JSON 과 같은 모양의 payload 로 만들고 `normalize.write_program` 을 그대로
재사용한다 — 정규화 경로를 두 개로 갈라두지 않기 위해서다.
텍스트 심볼(TEXT-nnn → 한국어)은 이 덤프에 없다. 텍스트 풀이 별도 오브젝트라 소스에 포함되지
않는다 → `text_symbols` 는 빈 배열이고, 그만큼 조각의 자연어 앵커가 줄어든다.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
from .normalize import clean_text_field, write_program
ENCODINGS = ("cp949", "utf-8-sig", "utf-8", "latin-1")
SKIP_DIRS = {"screens", "dictionary_objects"}
# 헤더 주석 박스의 "* 키 : 값 *" 한 줄. 세 가지 서식이 섞여 있다:
# * Description : 거래처마스터 I/F 이력조회 *
# * P/G desc : 매입 채무 Interface 내역
# *& 프로그램 명 : [FI] 전자세금계산서 매입전표 생성
_HEADER_FIELD = re.compile(
r"^\*&?\s*([A-Za-z가-힣_/][A-Za-z0-9가-힣 _/\-]*?)\s*:\s*(.*?)\s*\*?\s*$"
)
_REPORT_STMT = re.compile(r"^\s*(?:REPORT|PROGRAM)\s+([A-Za-z0-9_/]+)", re.I | re.M)
# 헤더에서 의미 있게 쓰는 키만 남긴다 — 주석 처리된 ABAP 선언문(`* TABLES: BKPF,`)이
# 키:값 모양이라 그대로 두면 잡동사니가 섞인다.
_KNOWN_KEYS = {
"DESCRIPTION", "P/G_DESC", "PROGRAM_DESC", "TITLE", "프로그램_명", "프로그램명", "개요",
"T_CODE", "TCODE", "MODULE/SUB-MODULE", "MODULE", "모듈", "TYPE", "REPORT",
"PROGRAM_ID", "프로그램_ID", "시스템", "AUTHOR", "생성자", "DATE", "생성일", "PROJECT",
}
# 설명으로 쓸 키의 우선순위
_DESC_KEYS = ("DESCRIPTION", "P/G_DESC", "PROGRAM_DESC", "프로그램_명", "프로그램명", "개요", "TITLE")
_TCODE_KEYS = ("T_CODE", "TCODE")
_MODULE_KEYS = ("MODULE/SUB-MODULE", "MODULE", "모듈")
# 덤프 도구가 모든 인클루드 끝에 붙이는 꼬리말. ABAP 이 아니라 추출기 서명이다:
# ----------------------------------------------------------------------
# Extracted by Mass Download version 1.4.4 - E.G.Mellodew. 1998-2013. Sap Release 731
# 그냥 두면 인클루드마다 미인식 문장 7건이 생기고(176개 × 7 ≈ 1,200), 마지막 unit 의 줄 범위에
# 섞여 들어가 LLM 프롬프트에도 그대로 보인다.
_TRAILER = re.compile(
r"\n-{20,}\s*\n\s*Extracted by Mass Download.*?$", re.S | re.I
)
def strip_trailer(text: str) -> str:
"""덤프 도구 꼬리말 제거. 없으면 그대로 돌려준다."""
stripped = _TRAILER.sub("\n", text)
if stripped != text:
return stripped
# 구분선이 없는 변형도 처리 — 마지막 'Extracted by ...' 줄만 떼낸다
lines = text.split("\n")
while lines and (not lines[-1].strip() or lines[-1].lstrip().lower().startswith("extracted by ")):
if lines[-1].lstrip().lower().startswith("extracted by "):
lines.pop()
while lines and set(lines[-1].strip()) <= {"-"} and lines[-1].strip():
lines.pop()
return "\n".join(lines) + "\n"
lines.pop()
return text
def read_text(path: Path) -> str:
"""CP949 우선으로 디코드. 줄바꿈 통일 + 덤프 도구 꼬리말 제거."""
raw = path.read_bytes()
for enc in ENCODINGS:
try:
text = raw.decode(enc)
break
except UnicodeDecodeError:
continue
else: # pragma: no cover — latin-1 은 실패하지 않는다
text = raw.decode("cp949", errors="replace")
return strip_trailer(text.replace("\r\n", "\n").replace("\r", "\n"))
def header_fields(text: str, max_lines: int = 60) -> dict[str, str]:
"""주 프로그램 앞부분 주석 박스에서 키:값 을 긁는다 (알려진 키만)."""
out: dict[str, str] = {}
for line in text.split("\n")[:max_lines]:
if not line.startswith("*"):
continue
m = _HEADER_FIELD.match(line)
if not m:
continue
key = m.group(1).strip().upper().replace(" ", "_")
if key not in _KNOWN_KEYS:
continue
val = clean_text_field(m.group(2).rstrip("*").strip())
# 구분선(*----*)이나 빈 값, N/A 는 버린다
if val and not set(val) <= {"-", "*", "="} and val.upper() != "N/A" and key not in out:
out[key] = val
return out
def _pick(fields: dict[str, str], keys: tuple[str, ...]) -> str:
for k in keys:
if fields.get(k):
return fields[k]
return ""
def include_files(prog_dir: Path) -> list[Path]:
"""인클루드 후보 .txt — 주 프로그램 파일을 맨 앞에 둔다 (REPORT 문이 먼저 보이도록)."""
main = prog_dir / f"{prog_dir.name}.txt"
others = sorted(
p for p in prog_dir.glob("*.txt")
if p.is_file() and p != main and p.parent.name not in SKIP_DIRS
)
return ([main] if main.exists() else []) + others
def program_payload(prog_dir: Path) -> dict:
"""프로그램 디렉토리 → 수집 JSON 과 같은 모양의 payload."""
files = include_files(prog_dir)
if not files:
raise ValueError(f"인클루드 .txt 가 없습니다: {prog_dir}")
main_text = read_text(files[0])
fields = header_fields(main_text)
program = prog_dir.name.upper()
m = _REPORT_STMT.search(main_text)
if m:
program = m.group(1).upper() # REPORT 문이 진짜 프로그램명 (디렉토리명과 다를 수 있다)
description = _pick(fields, _DESC_KEYS)
return {
"MAIN_PROGRAM": program,
"DESCRIPTION": description,
"TEXT_SYMBOL": [], # 이 덤프에는 텍스트 풀이 없다
"INCLUDE_PROGRAM": [
{"INCLUDE": p.stem.upper(), "SOURCE_CODE": read_text(p)} for p in files
],
# 아래는 이 포맷에서만 나오는 부가 정보 — meta 에 실어 로더가 tcode 로 적재한다
"_HEADER": fields,
"_TCODE": _pick(fields, _TCODE_KEYS),
"_MODULE": _pick(fields, _MODULE_KEYS),
}
def run(root: Path, out_dir: Path, limit: int | None = None, dry_run: bool = False) -> dict:
prog_dirs = sorted(d for d in root.iterdir() if d.is_dir() and d.name not in SKIP_DIRS)
if limit:
prog_dirs = prog_dirs[:limit]
stats = {"programs": 0, "includes": 0, "total_lines": 0, "tcodes": 0, "errors": 0}
errors: list[str] = []
extras: list[dict] = []
for d in prog_dirs:
try:
payload = program_payload(d)
except Exception as e: # noqa: BLE001 — 실패 기록 후 계속
stats["errors"] += 1
errors.append(f"{d}\t{type(e).__name__}: {e}")
continue
if dry_run:
stats["programs"] += 1
stats["includes"] += len(payload["INCLUDE_PROGRAM"])
continue
meta = write_program(payload, out_dir)
stats["programs"] += 1
stats["includes"] += len(meta["includes"])
stats["total_lines"] += meta["total_lines"]
# 헤더에서 뽑은 부가 정보를 meta.json 에 덧붙인다 (write_program 이 쓴 뒤에 갱신)
meta_path = out_dir / meta["program"] / f"{meta['program']}.meta.json"
meta["tcode"] = payload["_TCODE"]
meta["sap_module"] = payload["_MODULE"]
meta["header"] = payload["_HEADER"]
meta["source_dir"] = str(d)
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
if payload["_TCODE"]:
stats["tcodes"] += 1
extras.append({"tcode": payload["_TCODE"], "program": meta["program"],
"text_ko": meta["description"]})
if not dry_run:
out_dir.mkdir(parents=True, exist_ok=True)
if extras:
# 로더가 읽어 tcode 테이블에 넣는다 (ASSUMPTIONS.md §5 의 TSTC 미확보를 일부 대체)
(out_dir / "tcodes.jsonl").write_text(
"\n".join(json.dumps(e, ensure_ascii=False) for e in extras) + "\n",
encoding="utf-8")
if errors:
(out_dir / "_errors.log").write_text("\n".join(errors) + "\n", encoding="utf-8")
return stats
def main() -> None:
ap = argparse.ArgumentParser(description="Stage 1 대안 — 프로그램 디렉토리 덤프 정규화")
ap.add_argument("root", help="프로그램 디렉토리들이 들어 있는 루트")
ap.add_argument("--out", default=None, help="출력 디렉토리 (기본 data/normalized)")
ap.add_argument("--limit", type=int, default=None)
ap.add_argument("--dry-run", action="store_true")
args = ap.parse_args()
from config.settings import settings
root = Path(args.root)
if not root.is_dir():
print(f"입력 디렉토리 없음: {root}", file=sys.stderr)
sys.exit(1)
out_dir = Path(args.out) if args.out else settings.data_normalized
print(json.dumps(run(root, out_dir, args.limit, args.dry_run), ensure_ascii=False))
if __name__ == "__main__":
main()
+171
View File
@@ -0,0 +1,171 @@
"""Stage 1 — 정규화.
수집 API 결과 JSON(.txt)의 줄바꿈 아티팩트를 제거하고 프로그램/인클루드 단위 파일로 저장한다.
입력 파일 두 종류 (계획서 §1):
- 패키지-프로그램 목록: 최상위가 배열이고 DEVCLASS 키 → packages.jsonl 로 병합
- 프로그램 소스: 최상위가 객체이고 MAIN_PROGRAM 키 → <PROGRAM>/<INCLUDE>.abap + meta.json
핵심: 파일이 strict JSON이 아니다. 문자열 내부에 raw 줄바꿈(0x0A)+공백 패딩이
word-wrap 아티팩트로 들어 있고, 실제 코드 줄바꿈은 이스케이프된 \\n 두 글자다.
따라서 JSON 파싱 전에 raw 텍스트 단계에서 정규화한다.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import sys
from pathlib import Path
# 검증된 정규화 규칙: raw 줄바꿈 + 양쪽 패딩 → 공백 1개 (계획서 §1.2)
_WRAP_ARTIFACT = re.compile(r"[ \t]*\n[ \t]*")
def normalize_raw_text(raw: str) -> str:
"""word-wrap 아티팩트(raw 0x0A + 패딩)를 공백 1개로 치환해 strict JSON으로 만든다."""
return _WRAP_ARTIFACT.sub(" ", raw.replace("\r\n", "\n").replace("\r", "\n"))
def parse_collected_file(raw: str) -> dict | list:
"""정규화 후 strict JSON 파싱."""
return json.loads(normalize_raw_text(raw))
def clean_text_field(value: str | None) -> str:
"""DESCRIPTION/TEXT 등 텍스트 필드의 잔여 아티팩트(연속 공백) 정리."""
if not value:
return ""
return re.sub(r"\s+", " ", value).strip()
def sha256(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def classify(data: object) -> str:
"""파일 종류 판별: package_list | program_source | unknown"""
if isinstance(data, list) and data and isinstance(data[0], dict) and "DEVCLASS" in data[0]:
return "package_list"
if isinstance(data, dict) and "MAIN_PROGRAM" in data:
return "program_source"
return "unknown"
def write_program(data: dict, out_dir: Path) -> dict:
"""프로그램 소스 → data/normalized/<PROGRAM>/ 에 인클루드별 .abap + meta.json 저장."""
program = clean_text_field(data["MAIN_PROGRAM"]).upper()
prog_dir = out_dir / program
prog_dir.mkdir(parents=True, exist_ok=True)
includes_meta = []
total_lines = 0
for inc in data.get("INCLUDE_PROGRAM", []):
name = clean_text_field(inc.get("INCLUDE", "")).upper()
if not name:
continue
# 이 시점에서 SOURCE_CODE 안의 "\n"(이스케이프)은 json.loads 가 진짜 줄바꿈으로 복원한 상태
code = inc.get("SOURCE_CODE", "")
(prog_dir / f"{name}.abap").write_text(code, encoding="utf-8", newline="\n")
lines = code.count("\n") + 1 if code else 0
total_lines += lines
includes_meta.append({"include": name, "sha256": sha256(code), "line_count": lines})
meta = {
"program": program,
"description": clean_text_field(data.get("DESCRIPTION")),
"includes": includes_meta,
"text_symbols": [
{"symbol": clean_text_field(t.get("SYMBOL")), "text": clean_text_field(t.get("TEXT"))}
for t in data.get("TEXT_SYMBOL", []) or []
],
"total_lines": total_lines,
}
(prog_dir / f"{program}.meta.json").write_text(
json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8"
)
return meta
def run(raw_dir: Path, out_dir: Path, limit: int | None = None, dry_run: bool = False) -> dict:
out_dir.mkdir(parents=True, exist_ok=True)
errors_log = out_dir / "_errors.log"
packages_path = out_dir / "packages.jsonl"
stats = {"files": 0, "programs": 0, "includes": 0, "package_rows": 0, "total_lines": 0, "errors": 0}
package_rows: list[dict] = []
error_lines: list[str] = []
files = sorted(p for p in raw_dir.rglob("*") if p.is_file() and p.suffix.lower() in {".txt", ".json"})
if limit:
files = files[:limit]
for path in files:
stats["files"] += 1
try:
data = parse_collected_file(path.read_text(encoding="utf-8"))
except Exception as e: # noqa: BLE001 — 실패 기록 후 계속 (계획서 §3)
stats["errors"] += 1
error_lines.append(f"{path}\t{type(e).__name__}: {e}")
continue
kind = classify(data)
if kind == "package_list":
for row in data:
package_rows.append(
{
"devclass": clean_text_field(row.get("DEVCLASS")).upper(),
"obj_name": clean_text_field(row.get("OBJ_NAME")).upper(),
"text": clean_text_field(row.get("TEXT")),
"created_on": clean_text_field(row.get("CREATED_ON")),
"changed_on": clean_text_field(row.get("CHANGED_ON")),
}
)
stats["package_rows"] += len(data)
elif kind == "program_source":
if dry_run:
stats["programs"] += 1
stats["includes"] += len(data.get("INCLUDE_PROGRAM", []))
else:
meta = write_program(data, out_dir)
stats["programs"] += 1
stats["includes"] += len(meta["includes"])
stats["total_lines"] += meta["total_lines"]
else:
stats["errors"] += 1
error_lines.append(f"{path}\tUNKNOWN_FORMAT")
if not dry_run:
if package_rows:
with packages_path.open("w", encoding="utf-8") as f:
for row in package_rows:
f.write(json.dumps(row, ensure_ascii=False) + "\n")
if error_lines:
errors_log.write_text("\n".join(error_lines) + "\n", encoding="utf-8")
return stats
def main() -> None:
ap = argparse.ArgumentParser(description="Stage 1 — 수집 JSON 정규화")
ap.add_argument("raw_dir", nargs="?", default=None, help="수집 원본 디렉토리 (기본 data/raw)")
ap.add_argument("--out", default=None, help="출력 디렉토리 (기본 data/normalized)")
ap.add_argument("--limit", type=int, default=None)
ap.add_argument("--dry-run", action="store_true")
args = ap.parse_args()
from config.settings import settings
raw_dir = Path(args.raw_dir) if args.raw_dir else settings.data_raw
out_dir = Path(args.out) if args.out else settings.data_normalized
if not raw_dir.exists():
print(f"입력 디렉토리 없음: {raw_dir}", file=sys.stderr)
sys.exit(1)
stats = run(raw_dir, out_dir, limit=args.limit, dry_run=args.dry_run)
print(json.dumps(stats, ensure_ascii=False))
if __name__ == "__main__":
main()
View File
+327
View File
@@ -0,0 +1,327 @@
"""심볼 선언 + 쓰기/읽기 지점 추출. (계획서 §4.4)
"gt_head 가 어디서 채워지는가" 질문의 근간. 정확한 의미 분석이 아니라
문장 패턴별 쓰기 대상 변수를 결정론적으로 뽑는다.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from .declarations import type_name_after
from .statements import Statement, assignment_eq_index
_NAME = re.compile(r"^[A-Za-z_/][A-Za-z0-9_/]*")
@dataclass
class SymbolDecl:
name: str
scope: str # global | unit
unit_id: str | None
decl_include: str
decl_line: int
type_text: str = ""
ddic_ref: str = ""
kind: str = "data" # data/types/constants/tables/field-symbol/parameter/select-option
@dataclass
class WritePoint:
symbol: str
unit_id: str
include: str
line: int
kind: str
source_symbols: list[str] = field(default_factory=list)
source_tables: list[str] = field(default_factory=list)
stmt_text: str = ""
callee: str = "" # kind=via_perform 일 때
def base_symbol(token: str) -> str:
"""gs_head-belnr → GS_HEAD, gt_tab[] → GT_TAB, <fs>-f → <FS>"""
t = token.upper()
if t.startswith("<"):
return t.split(">")[0] + ">"
t = t.split("(")[0].split("[")[0].split("-")[0].split("->")[0].split("=>")[0]
return t
def _decl_from_stmt(st: Statement, unit_id: str | None, scope: str) -> SymbolDecl | None:
up = st.upper
head = up[0]
kind_map = {
"DATA": "data", "TYPES": "types", "CONSTANTS": "constants", "STATICS": "data",
"TABLES": "tables", "FIELD-SYMBOLS": "field-symbol", "CLASS-DATA": "data",
"PARAMETERS": "parameter", "PARAMETER": "parameter",
"SELECT-OPTIONS": "select-option", "RANGES": "data",
"NODES": "tables",
}
if head not in kind_map or len(up) < 2:
return None
name = base_symbol(up[1])
if not name or name in {"BEGIN", "END"}: # DATA BEGIN OF ... 구조 선언은 v1 스킵
return None
type_text = ""
for kw in ("TYPE", "LIKE"):
if kw in up:
ki = up.index(kw)
type_text = " ".join(st.tokens[ki : ki + 5])[:120]
break
ddic_ref = ""
m = re.search(r"(?:TYPE|LIKE)\s+(?:TABLE\s+OF\s+|STANDARD\s+TABLE\s+OF\s+|SORTED\s+TABLE\s+OF\s+|HASHED\s+TABLE\s+OF\s+)?([A-Z0-9_/]+-?[A-Z0-9_/]*)", type_text.upper())
if m:
ddic_ref = m.group(1)
return SymbolDecl(
name=name, scope=scope, unit_id=unit_id, decl_include=st.include,
decl_line=st.line_start, type_text=type_text, ddic_ref=ddic_ref, kind=kind_map[head],
)
def extract_declarations(statements: list[Statement], stmt_indexes: list[int],
unit_id: str | None, scope: str,
unit_type: str = "", signature: str = "") -> list[SymbolDecl]:
decls: list[SymbolDecl] = []
for idx in stmt_indexes:
d = _decl_from_stmt(statements[idx], unit_id, scope)
if d:
decls.append(d)
# FORM 파라미터 (USING/CHANGING/TABLES)
#
# 타입을 붙인 파라미터가 여러 개면(`USING p_date LIKE x p_days LIKE y …`) **타입 이름만 건너뛰고
# 계속 읽어야** 한다. 예전 구현은 TYPE/LIKE 를 만나면 멈춰서 첫 파라미터만 잡았고, 나머지는
# 선언 없는 이름으로 남았다 (정의부 조립에서 '선언을 못 찾음'으로 새어 나와 드러났다).
if unit_type == "FORM" and signature:
sig_up = signature.upper().split()
section = ""
i = 0
while i < len(sig_up):
t = sig_up[i].strip(".")
if t in {"USING", "CHANGING", "TABLES"}:
section = t
elif t in {"TYPE", "LIKE", "STRUCTURE"}:
_type, i = type_name_after([x.strip(".") for x in sig_up], i)
continue # 타입식(이름이 없을 수도 있다)을 건너뛴다
elif t in {"VALUE", "REFERENCE", "(", ")"}:
pass # VALUE(p_x) 표기
elif section:
name = base_symbol(t)
if _NAME.match(name):
decls.append(SymbolDecl(name=name, scope="unit", unit_id=unit_id,
decl_include="", decl_line=0, kind="param"))
i += 1
return decls
_ASSIGN_KINDS = [
# (조건 함수, kind, 대상 위치 결정)
# 아래 extract_writes 에서 절차적으로 처리
]
_CLEAR_HEADS = {"CLEAR": "clear", "REFRESH": "clear", "FREE": "clear", "SORT": "sort"}
def extract_writes(statements: list[Statement], stmt_indexes: list[int], unit_id: str,
known_symbols: set[str]) -> tuple[list[WritePoint], list[dict]]:
"""(writes, reads). reads 는 {symbol, unit_id, include, line} 수준."""
writes: list[WritePoint] = []
reads: list[dict] = []
def known(sym: str) -> bool:
return sym in known_symbols
def add_write(sym_token: str, st: Statement, kind: str, sources: list[str] | None = None,
tables: list[str] | None = None, callee: str = "") -> None:
sym = base_symbol(sym_token)
if not sym or not re.match(r"^[<A-Z_/]", sym):
return
field = ""
tu = sym_token.upper()
if tu.startswith("<"):
# <fs>-comp / <fs>->attr — 필드심볼 이름(<...>) 뒤의 접근자만 떼낸다
rest = tu.split(">", 1)[1] if ">" in tu else ""
field = rest.lstrip("->")
elif "-" in tu:
field = tu.split("-", 1)[1]
wp = WritePoint(symbol=sym, unit_id=unit_id, include=st.include, line=st.line_start,
kind=kind if not field else f"{kind}:field={field[:30]}",
source_symbols=sorted({s for s in (sources or []) if known(s)}),
source_tables=sorted(set(tables or [])),
stmt_text=st.raw_text[:200], callee=callee)
writes.append(wp)
def add_read(sym: str, st: Statement) -> None:
if known(sym):
reads.append({"symbol": sym, "unit_id": unit_id, "include": st.include, "line": st.line_start})
for idx in stmt_indexes:
st = statements[idx]
up = st.upper
if not up:
continue
head = up[0]
rhs_syms = [base_symbol(t) for t in up[1:] if re.match(r"^[<A-Z_/]", t)]
# SELECT ... INTO [CORRESPONDING FIELDS OF] [TABLE] x / APPENDING TABLE x
if head in {"SELECT", "FETCH"}:
tables = []
for j, t in enumerate(up):
if t in {"FROM", "JOIN"} and j + 1 < len(up):
cand = up[j + 1].lstrip("@")
if re.match(r"^[A-Z0-9_/]+$", cand):
tables.append(cand)
for kw in ("INTO", "APPENDING"):
if kw in up:
j = up.index(kw)
k = j + 1
while k < len(up) and up[k] in {"CORRESPONDING", "FIELDS", "OF", "TABLE", "(", "@"}:
k += 1
if k < len(up):
add_write(up[k].lstrip("@("), st, "select", tables=tables)
break
continue
# APPEND ... TO x / INSERT ... INTO [TABLE] x / COLLECT ... INTO x
if head in {"APPEND", "COLLECT"}:
kw = "TO" if "TO" in up else ("INTO" if "INTO" in up else None)
if kw:
j = up.index(kw)
if j + 1 < len(up):
add_write(up[j + 1], st, "append", sources=rhs_syms)
elif len(up) >= 2: # APPEND x. (헤더라인)
add_write(up[1], st, "append", sources=rhs_syms)
continue
if head == "INSERT" and ("INTO" in up):
j = up.index("INTO")
k = j + 1
if k < len(up) and up[k] == "TABLE":
k += 1
if k < len(up) and base_symbol(up[k]) in known_symbols:
add_write(up[k], st, "insert", sources=rhs_syms)
continue
# 아니면 DB 쓰기 → refs 에서 처리
if head == "MODIFY" and len(up) >= 2 and base_symbol(up[1]) in known_symbols:
add_write(up[1], st, "modify", sources=rhs_syms)
continue
if head == "DELETE" and len(up) >= 2:
t1 = up[1]
if t1 == "ADJACENT" and "FROM" in up:
j = up.index("FROM")
if j + 1 < len(up):
add_write(up[j + 1], st, "delete")
continue
if base_symbol(t1) in known_symbols:
add_write(t1, st, "delete")
continue
if head in _CLEAR_HEADS:
for t in up[1:]:
if re.match(r"^[<A-Z_/]", t) and t not in {"BY", "ASCENDING", "DESCENDING", "STABLE", "WITH", "INITIAL", "LINE", "OF", "TABLE"}:
add_write(t, st, _CLEAR_HEADS[head])
continue
# READ TABLE ... INTO x / ASSIGNING <fs>
if head == "READ" and len(up) >= 2 and up[1] == "TABLE":
src = base_symbol(up[2]) if len(up) >= 3 else ""
if src:
add_read(src, st)
for kw, kind in (("INTO", "read_into"), ("ASSIGNING", "assign_fs")):
if kw in up:
j = up.index(kw)
if j + 1 < len(up):
add_write(up[j + 1], st, kind, sources=[src] if src else [])
continue
# LOOP AT x INTO y / ASSIGNING <fs>
if head == "LOOP" and "AT" in up:
j = up.index("AT")
src = base_symbol(up[j + 1]) if j + 1 < len(up) else ""
if src:
add_read(src, st)
for kw in ("INTO", "ASSIGNING"):
if kw in up:
k = up.index(kw)
if k + 1 < len(up):
add_write(up[k + 1], st, "iterate", sources=[src] if src else [])
continue
# MOVE / MOVE-CORRESPONDING ... TO x
if head in {"MOVE", "MOVE-CORRESPONDING"} and "TO" in up:
j = up.index("TO")
if j + 1 < len(up):
add_write(up[j + 1], st, "assign", sources=rhs_syms[:j])
continue
# SPLIT/CONCATENATE ... INTO x1 x2...
if head in {"SPLIT", "CONCATENATE"} and "INTO" in up:
j = up.index("INTO")
for t in up[j + 1 :]:
if t in {"SEPARATED", "BY", "IN", "CHARACTER", "BYTE", "MODE", "TABLE"}:
continue
if re.match(r"^[<A-Z_/]", t):
add_write(t, st, "assign", sources=rhs_syms[:j])
continue
# CALL FUNCTION ... IMPORTING a = x / TABLES t = x / CHANGING c = x
if head == "CALL" and len(up) >= 3 and up[1] == "FUNCTION":
fname = up[2].strip("'")
section = ""
k = 3
while k < len(up):
t = up[k]
if t in {"EXPORTING", "IMPORTING", "TABLES", "CHANGING", "EXCEPTIONS", "DESTINATION", "STARTING", "PERFORMING"}:
section = t
elif t == "=" and k + 1 < len(up) and section in {"IMPORTING", "TABLES", "CHANGING"}:
add_write(up[k + 1], st, "call_function", callee=fname)
elif t == "=" and k + 1 < len(up) and section == "EXPORTING":
add_read(base_symbol(up[k + 1]), st)
k += 1
continue
# PERFORM f USING a CHANGING x / TABLES x
if head == "PERFORM":
callee = up[1] if len(up) >= 2 else ""
section = ""
for t in up[2:]:
if t in {"USING", "CHANGING", "TABLES", "IN", "PROGRAM", "IF", "FOUND"}:
section = t
continue
if section in {"CHANGING", "TABLES"} and re.match(r"^[<A-Z_/]", t):
add_write(t, st, "via_perform", callee=callee)
elif section == "USING" and re.match(r"^[<A-Z_/]", t):
add_read(base_symbol(t), st)
continue
# IMPORT ... FROM MEMORY / GET PARAMETER ID ... FIELD x
if head == "IMPORT" and "FROM" in up:
j = up.index("FROM")
for t in up[1:j]:
if t not in {"TO", "="} and re.match(r"^[<A-Z_/]", t):
add_write(t, st, "import_memory")
continue
if head == "GET" and len(up) >= 2 and up[1] == "PARAMETER" and "FIELD" in up:
j = up.index("FIELD")
if j + 1 < len(up):
add_write(up[j + 1], st, "get_parameter")
continue
# 일반 대입: x = ... / x[] = ... / x-f = ... / <fs>-f = ...
eq = assignment_eq_index(up)
if eq is not None:
rhs = up[eq + 1 :]
kind = "assign"
joined = " ".join(rhs)
if "VALUE #(" in joined or (rhs and rhs[0].startswith("VALUE")):
kind = "assign_value"
elif rhs and rhs[0].startswith("CORRESPONDING"):
kind = "assign_corresponding"
add_write(up[0], st, kind, sources=[base_symbol(t) for t in rhs if re.match(r"^[<A-Z_/]", t)])
continue
# 그 밖의 문장에서 알려진 심볼 등장 → read
for t in up:
b = base_symbol(t)
if known(b):
add_read(b, st)
return writes, reads
+287
View File
@@ -0,0 +1,287 @@
"""정의부(선언 블록) 추출 — 조각 코드를 **붙여넣을 때 함께 가야 하는 코드**.
인덱스가 담는 코드 원문은 **로직만**이다. ABAP 은 내부테이블·스트럭처·상수·필드심볼을
TOP 인클루드(또는 FORM 머리)에 따로 선언하므로, 로직만 복사해 붙여넣으면 컴파일되지 않는다.
그래서 선언을 **별도로** 수집해 둔다. 무엇을 쓸지(이미 있으면 버리고, 이름이 겹치면 바꾸고)는
붙여넣는 쪽 LLM 이 정한다 — 여기서는 판단하지 않고 **원문 그대로, 붙여넣을 수 있는 형태로** 모은다.
parser/dataflow.py 의 `SymbolDecl` 과 겹쳐 보이지만 목적이 다르다:
- `symbol` : "gt_head 가 어디서 채워지는가" 추적용 — 이름·타입 문자열만 있으면 된다.
- `declaration` : 붙여넣기용 — **선언 원문**, **줄 범위**, **의존하는 다른 선언**이 필요하다.
그리고 `DATA: BEGIN OF ... END OF ...` 구조 선언을 반드시 한 덩어리로 잡아야 한다
(dataflow 는 이걸 건너뛴다).
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from .statements import Statement
from .tokenizer import split_comment
# 선언 문장의 머리 키워드 → 선언 종류
DECL_HEADS = {
"TYPES": "types",
"DATA": "data",
"CLASS-DATA": "data",
"STATICS": "data",
"CONSTANTS": "constants",
"TABLES": "tables",
"NODES": "tables",
"FIELD-SYMBOLS": "field-symbol",
"PARAMETERS": "parameter",
"PARAMETER": "parameter", # 단수형도 유효한 ABAP 이다 (실측 119건)
"CONTROLS": "controls",
"SELECT-OPTIONS": "select-option",
"RANGES": "ranges",
"TYPE-POOLS": "type-pool",
"TYPE-POOL": "type-pool",
}
# TYPE/LIKE 뒤에서 건너뛰는 수식어 — 이 다음에 오는 식별자가 실제 참조 대상이다
TYPE_MODIFIERS = {
"STANDARD", "SORTED", "HASHED", "INDEX", "ANY", "TABLE", "OF", "LINE", "RANGE",
"REF", "TO", "OCCURS", "WITH", "HEADER", "INITIAL", "SIZE", "DEFAULT", "VALUE",
"READ-ONLY", "UNIQUE", "NON-UNIQUE", "KEY", "EMPTY", "LENGTH", "DECIMALS",
}
# 내장 타입 · 시스템 구조 — 참조로 기록하지 않는다 (어디에나 있으므로 붙여넣기에 방해만 된다)
BUILTIN_TYPES = {
"C", "N", "I", "F", "P", "D", "T", "X", "B", "S", "INT1", "INT2", "INT4", "INT8",
"STRING", "XSTRING", "DECFLOAT16", "DECFLOAT34", "UTCLONG", "FLOAT",
"SY", "SYST", "SPACE", "ABAP_BOOL", "ABAP_TRUE", "ABAP_FALSE",
}
_IDENT = re.compile(r"^[A-Z_/][A-Z0-9_/]*$")
@dataclass
class Declaration:
"""붙여넣기 가능한 선언 한 건."""
name: str
kind: str # types/data/constants/tables/field-symbol/parameter/select-option/
# ranges/type-pool/class/interface/macro
scope: str # global | unit
unit_id: str | None
include: str
line_start: int
line_end: int
code: str # 원문 (체인 항목이면 헤드를 다시 붙인 **유효한 한 문장**)
type_text: str = ""
depends: list[str] = field(default_factory=list) # 이 선언이 참조하는 다른 이름(대문자)
def base_name(token: str) -> str:
"""GS_HEAD-BELNR → GS_HEAD, GT_TAB[] → GT_TAB, <FS>-F → <FS>, ZCL_X=>TY → ZCL_X"""
t = token.upper()
if t.startswith("<"):
return t.split(">")[0] + ">"
t = t.split("(")[0].split("[")[0].split("->")[0].split("=>")[0].split("-")[0]
return t.rstrip(",.")
def _is_ident(t: str) -> bool:
return bool(_IDENT.match(t)) or (t.startswith("<") and t.endswith(">"))
def clean_ref(token: str) -> str:
"""참조 토큰을 정규화 — `GT_DATA[]` → GT_DATA, `BKPF-BELNR` → BKPF-BELNR (컴포넌트 유지)."""
t = token.upper().split("(")[0].split("[")[0].rstrip(",.")
return t
# 타입식의 머리 — 이 뒤에는 보통 `OF`/`TO` 로 실제 타입 이름이 이어진다 (없을 수도 있다)
_TABLE_HEADS = {"STANDARD", "SORTED", "HASHED", "INDEX", "ANY", "TABLE", "LINE", "RANGE", "REF"}
def type_name_after(up: list[str], i: int) -> tuple[str | None, int]:
"""`up[i]` 가 TYPE/LIKE 일 때 (참조하는 타입 이름, 다음에 읽을 위치).
타입식에 이름이 없을 수도 있다 — `TYPE ANY`, `TYPE ANY TABLE`, `TYPE C` 처럼.
그때 `None` 과 함께 **수식어 바로 다음 위치**를 돌려줘야 그 뒤 토큰(다음 FORM 파라미터 등)을
타입 이름으로 잘못 삼키지 않는다.
"""
j = i + 1
if j >= len(up):
return None, j
if up[j] not in _TABLE_HEADS:
return up[j], j + 1
k = j
while k < len(up) and up[k] not in {"OF", "TO"}:
if up[k] not in _TABLE_HEADS:
return None, k # `TYPE ANY` 뒤에 다른 토큰이 온 경우
k += 1
if k + 1 < len(up): # … OF <타입> / REF TO <클래스>
return up[k + 1], k + 2
return None, k
def type_refs(up: list[str]) -> list[str]:
"""선언 토큰열이 참조하는 다른 이름들 — TYPE/LIKE/INCLUDE STRUCTURE/FOR 뒤의 식별자.
`LIKE GT_DATA OCCURS 0` → [GT_DATA], `TYPE TABLE OF TY_ITEM` → [TY_ITEM],
`TYPE C` → [] (내장 타입).
**컴포넌트까지 그대로 남긴다**: `LIKE BKPF-BELNR` → [BKPF-BELNR]. 이건 DDIC 사전의 필드
타입을 빌려 쓰는 것이지 `TABLES: BKPF` 작업영역이 있어야 한다는 뜻이 아니다 — 정의부를 조립할
때(index/decls.py) 이 둘을 구분해야 TOP 의 TABLES 선언이 딸려 오지 않는다.
같은 이름을 여러 번 참조하면(구조체 필드들이 전부 같은 테이블을 볼 때) 첫 참조만 남긴다.
"""
out: list[str] = []
seen: set[str] = set()
def add(token: str) -> None:
ref = clean_ref(token)
base = base_name(ref)
if not _is_ident(base) or base in BUILTIN_TYPES or base in seen:
return
seen.add(base)
out.append(ref)
i = 0
while i < len(up):
t = up[i]
if t in {"TYPE", "LIKE"}:
name, nxt = type_name_after(up, i)
if name:
add(name)
i = nxt
continue
elif t == "INCLUDE" and i + 1 < len(up) and up[i + 1] in {"STRUCTURE", "TYPE"}:
if i + 2 < len(up):
add(up[i + 2])
i += 2
elif t == "FOR" and i + 1 < len(up): # SELECT-OPTIONS s_bukrs FOR bkpf-bukrs
add(up[i + 1])
i += 1
i += 1
return out
def _terminate(line: str) -> str:
"""체인에서 잘라낸 마지막 줄의 ',''.' 로 바꾼다 (주석은 보존)."""
code, comment, full = split_comment(line)
if full:
return line
stripped = code.rstrip()
if stripped.endswith(","):
stripped = stripped[:-1] + "."
elif not stripped.endswith("."):
stripped = stripped + "."
return stripped + (f' " {comment}' if comment else "")
def render_code(lines: list[str], st_start: int, st_end: int,
chain_head: str, chain_start: int, chain_end: int) -> str:
"""선언 원문을 **그대로 붙여넣을 수 있는 한 문장**으로 잘라낸다.
- 문장이 체인 전체와 같으면 원문 그대로 (이미 '.' 로 끝난다)
- 체인의 첫 항목이면 헤드가 첫 줄에 있으므로 그대로 잘라 '.' 로 닫는다
- 중간·마지막 항목이면 헤드(`DATA:`)가 없으므로 앞에 다시 붙인다
"""
body = [lines[no - 1] for no in range(st_start, st_end + 1) if 1 <= no <= len(lines)]
if not body:
return ""
if st_start == chain_start and st_end == chain_end:
return "\n".join(body).rstrip()
body[-1] = _terminate(body[-1])
if st_start > chain_start and chain_head:
return f"{chain_head}:\n" + "\n".join(body).rstrip()
return "\n".join(body).rstrip()
def _block_name(up: list[str]) -> str:
"""['DATA','BEGIN','OF','GT_UPLOAD','OCCURS','0'] → GT_UPLOAD"""
return base_name(up[3]) if len(up) >= 4 else ""
def extract_declaration_blocks(
statements: list[Statement], stmt_indexes: list[int], lines: list[str],
include: str, scope: str, unit_id: str | None = None,
) -> list[Declaration]:
"""선언 문장들 → Declaration 목록. `BEGIN OF … END OF` 는 한 덩어리로 묶는다."""
out: list[Declaration] = []
depth = 0
block: list[Statement] = []
block_kind = ""
block_name = ""
for idx in stmt_indexes:
st = statements[idx]
up = st.upper
if not up:
continue
kind = DECL_HEADS.get(up[0])
if kind is None:
# 구조 블록 안에서는 선언 키워드가 없는 문장(INCLUDE STRUCTURE …)도 함께 모은다
if depth and up[0] == "INCLUDE":
block.append(st)
continue
is_begin = len(up) >= 3 and up[1] == "BEGIN" and up[2] == "OF"
is_end = len(up) >= 3 and up[1] == "END" and up[2] == "OF"
if is_begin:
if depth == 0:
block, block_kind, block_name = [], kind, _block_name(up)
depth += 1
block.append(st)
continue
if depth:
block.append(st)
if is_end:
depth -= 1
if depth == 0 and block:
first, last = block[0], block[-1]
tokens: list[str] = []
for b in block:
tokens += b.upper
out.append(Declaration(
name=block_name or _block_name(first.upper), kind=block_kind, scope=scope,
unit_id=unit_id, include=include,
line_start=first.line_start, line_end=last.line_end,
code=render_code(lines, first.line_start, last.line_end,
first.chain_head, first.chain_start, last.chain_end),
type_text="BEGIN OF …",
depends=[d for d in type_refs(tokens) if base_name(d) != block_name],
))
block = []
continue
if len(up) < 2:
continue
if kind == "type-pool": # TYPE-POOLS: slis, vrm → 항목마다 한 건
name = base_name(up[1])
else:
name = base_name(up[1])
if not name or name in {"BEGIN", "END"} or not _is_ident(name):
continue
type_text = ""
for kw in ("TYPE", "LIKE"):
if kw in up:
ki = up.index(kw)
type_text = " ".join(st.tokens[ki : ki + 5])[:120]
break
out.append(Declaration(
name=name, kind=kind, scope=scope, unit_id=unit_id, include=include,
line_start=st.line_start, line_end=st.line_end,
code=render_code(lines, st.line_start, st.line_end,
st.chain_head, st.chain_start, st.chain_end),
type_text=type_text,
depends=[d for d in type_refs(up) if base_name(d) != name],
))
return out
def unit_declaration(unit, lines: list[str], kind: str) -> Declaration:
"""CLASS … DEFINITION / DEFINE … END-OF-DEFINITION 처럼 **unit 통째가 정의부**인 것.
로컬 클래스를 쓰는 조각도, 매크로를 호출하는 조각도 그 정의가 없으면 붙여넣어 봐야 안 돈다.
"""
body = "\n".join(lines[unit.line_start - 1 : unit.line_end]).rstrip()
return Declaration(
name=unit.name.upper(), kind=kind, scope="global", unit_id=None, include=unit.include,
line_start=unit.line_start, line_end=unit.line_end, code=body,
type_text=kind, depends=[],
)
+169
View File
@@ -0,0 +1,169 @@
"""참조 추출 — unit 별 DB 접근/호출/권한/메시지/선택화면/UI 신호/텍스트심볼. (계획서 §4.3)"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from .statements import Statement
_SQL_WRITE = {"INSERT", "UPDATE", "MODIFY", "DELETE"}
_UI_FM = re.compile(r"^(REUSE_ALV_|SSF_|FP_JOB_|GUI_DOWNLOAD|GUI_UPLOAD)")
# "외부 호출"로 볼 call kind. 요약·위키·엔티티 페이지가 같은 기준을 써야 한다.
# 제외하는 것들과 이유:
# perform / perform_unresolved — 프로그램 내부 호출
# call_method / method_ref — `CL_GUI_ALV_GRID=>MC_FC_AUF` 처럼 상수 읽기가 대량 섞인다
# call_screen — 대상이 화면번호('100')라 호출 대상 이름이 아니다
EXTERNAL_CALL_KINDS = (
"perform_external", "call_function", "call_function_rfc", "call_function_task",
"submit", "call_transaction", "call_dialog",
)
# functions/ 엔티티 페이지를 만드는 호출 종류 (FM · BAPI · RFC)
FUNCTION_CALL_KINDS = ("call_function", "call_function_rfc", "call_function_task")
@dataclass
class UnitRefs:
tables_read: list[str] = field(default_factory=list)
tables_write: list[str] = field(default_factory=list)
calls: list[dict] = field(default_factory=list) # {kind, target, extra?, line}
authority_checks: list[str] = field(default_factory=list)
messages: list[str] = field(default_factory=list)
select_params: list[dict] = field(default_factory=list) # {kind, name, for}
output_signals: list[str] = field(default_factory=list)
text_symbols: list[str] = field(default_factory=list)
macro_calls: list[str] = field(default_factory=list)
def _strip_quote(t: str) -> str:
return t[1:-1] if len(t) >= 2 and t[0] == "'" and t[-1] == "'" else t
def _is_name(t: str) -> bool:
return bool(re.match(r"^[A-Z_/][A-Z0-9_/]*$", t))
def extract_refs(statements: list[Statement], stmt_indexes: list[int],
macro_names: set[str], known_symbols: set[str]) -> UnitRefs:
r = UnitRefs()
def add(lst: list, v) -> None:
if v and v not in lst:
lst.append(v)
for idx in stmt_indexes:
st = statements[idx]
up = st.upper
if not up:
continue
head = up[0]
line = st.line_start
# --- DB 읽기: SELECT/OPEN CURSOR ... FROM t, JOIN t ---
if head in {"SELECT", "OPEN"} or (head == "WITH"):
for j, t in enumerate(up):
if t in {"FROM", "JOIN"} and j + 1 < len(up):
cand = up[j + 1]
if cand in {"(", "@"}:
continue
cand = cand.lstrip("@")
if _is_name(cand) and cand not in known_symbols:
add(r.tables_read, cand)
# --- DB 쓰기: INSERT/UPDATE/MODIFY/DELETE <db테이블> ---
if head in _SQL_WRITE and len(up) >= 2:
t1 = up[1]
if t1 == "INTO" and len(up) >= 3: # INSERT INTO t VALUES ...
t1 = up[2]
if t1 == "FROM" and head == "DELETE" and len(up) >= 3: # DELETE FROM t
t1 = up[2]
if _is_name(t1) and t1 not in known_symbols and t1 not in {"TABLE", "LINES", "ADJACENT", "REPORT", "DATASET", "SCREEN"}:
add(r.tables_write, t1)
# --- 호출 ---
if head == "PERFORM" and len(up) >= 2:
target = up[1]
m = re.match(r"^([A-Z0-9_]+)\(([A-Z0-9_]+)\)$", target) # 구문 f(prog)
if m:
r.calls.append({"kind": "perform_external", "target": m.group(1), "program": m.group(2), "line": line})
elif "IN" in up and "PROGRAM" in up:
pi = up.index("PROGRAM")
prog = up[pi + 1] if pi + 1 < len(up) else ""
r.calls.append({"kind": "perform_external", "target": target, "program": _strip_quote(prog), "line": line})
else:
r.calls.append({"kind": "perform", "target": target, "line": line})
if head == "CALL" and len(up) >= 2:
what = up[1]
if what == "FUNCTION" and len(up) >= 3:
fname = _strip_quote(up[2])
kind = "call_function"
if "DESTINATION" in up:
kind = "call_function_rfc"
add(r.output_signals, "RFC")
if "TASK" in up:
kind = "call_function_task"
r.calls.append({"kind": kind, "target": fname, "line": line})
if _UI_FM.match(fname):
add(r.output_signals, "ALV" if fname.startswith("REUSE_ALV_") else fname.split("_")[0])
if fname.startswith(("GUI_DOWNLOAD", "GUI_UPLOAD")):
add(r.output_signals, "FILE")
elif what == "METHOD" and len(up) >= 3:
r.calls.append({"kind": "call_method", "target": up[2], "line": line})
elif what == "TRANSACTION" and len(up) >= 3:
r.calls.append({"kind": "call_transaction", "target": _strip_quote(up[2]), "line": line})
if "USING" in up:
add(r.output_signals, "BDC")
elif what == "SCREEN" and len(up) >= 3:
r.calls.append({"kind": "call_screen", "target": up[2], "line": line})
add(r.output_signals, "SCREEN")
elif what == "DIALOG" and len(up) >= 3:
r.calls.append({"kind": "call_dialog", "target": _strip_quote(up[2]), "line": line})
if head == "SUBMIT" and len(up) >= 2:
r.calls.append({"kind": "submit", "target": up[1], "line": line})
# 함수형 메서드 호출: zcl_x=>meth( ... ) / lo_obj->meth( ... )
for t in st.tokens:
tu = t.upper()
if "=>" in tu or "->" in tu:
base = tu.split("(")[0]
if re.match(r"^[A-Z0-9_/<>]+(=>|->)[A-Z0-9_~]+$", base):
if not any(c["target"] == base for c in r.calls):
r.calls.append({"kind": "method_ref", "target": base, "line": line})
if base.startswith("CL_GUI_ALV_GRID") or "ALV" in base.split("=>")[0].split("->")[0]:
add(r.output_signals, "ALV_GRID")
# --- 권한/메시지 ---
if head == "AUTHORITY-CHECK" and "OBJECT" in up:
oi = up.index("OBJECT")
if oi + 1 < len(up):
add(r.authority_checks, _strip_quote(up[oi + 1]))
if head == "MESSAGE" and len(up) >= 2:
add(r.messages, _strip_quote(up[1])[:40])
# --- 선택화면 ---
if head == "PARAMETERS" and len(up) >= 2:
r.select_params.append({"kind": "parameter", "name": up[1].split("(")[0], "line": line})
if head == "SELECT-OPTIONS" and len(up) >= 2:
target = ""
if "FOR" in up:
fi = up.index("FOR")
target = up[fi + 1] if fi + 1 < len(up) else ""
r.select_params.append({"kind": "select_option", "name": up[1], "for": target, "line": line})
# --- UI 신호 ---
if head == "WRITE":
add(r.output_signals, "LIST_WRITE")
if head == "CALL" and len(up) >= 3 and up[1] == "SCREEN":
add(r.output_signals, "SCREEN")
# --- 텍스트 심볼 / 매크로 ---
for t in up:
if t.startswith("TEXT-"):
add(r.text_symbols, t.replace("TEXT-", ""))
if head in macro_names:
add(r.macro_calls, head)
return r
+276
View File
@@ -0,0 +1,276 @@
"""Stage 2 실행 — data/normalized/<PROGRAM>/ → data/parsed/<PROGRAM>.parse.json
python -m parser.run [--program ZFIR10070] [--limit N] [--dry-run]
"""
from __future__ import annotations
import argparse
import json
from collections import Counter
from dataclasses import asdict
from pathlib import Path
from .dataflow import SymbolDecl, extract_declarations, extract_writes
from .declarations import Declaration, extract_declaration_blocks, unit_declaration
from .refs import extract_refs
from .statements import Statement, assignment_eq_index, split_statements
from .units import Unit, extract_units, finalize_units
# 파서가 의미를 아는 문장 머리 키워드 (미인식 리포트용 — 계획서 §4.6)
RECOGNIZED_HEADS = {
"REPORT", "PROGRAM", "INCLUDE", "TYPE-POOLS", "TYPES", "DATA", "CONSTANTS", "STATICS",
"TABLES", "NODES", "FIELD-SYMBOLS", "RANGES", "CLASS-DATA", "PARAMETERS", "PARAMETER",
"CONTROLS", "SELECT-OPTIONS",
"SELECTION-SCREEN", "INITIALIZATION", "START-OF-SELECTION", "END-OF-SELECTION",
"TOP-OF-PAGE", "END-OF-PAGE", "LOAD-OF-PROGRAM", "AT", "GET", "FORM", "ENDFORM",
"FUNCTION", "ENDFUNCTION", "MODULE", "ENDMODULE", "METHOD", "ENDMETHOD", "CLASS",
"ENDCLASS", "INTERFACE", "ENDINTERFACE", "DEFINE", "END-OF-DEFINITION",
"SELECT", "ENDSELECT", "FETCH", "OPEN", "CLOSE", "INSERT", "UPDATE", "MODIFY", "DELETE",
"COMMIT", "ROLLBACK", "APPEND", "COLLECT", "READ", "LOOP", "ENDLOOP", "SORT", "CLEAR",
"REFRESH", "FREE", "MOVE", "MOVE-CORRESPONDING", "SPLIT", "CONCATENATE", "CONDENSE",
"TRANSLATE", "REPLACE", "SHIFT", "OVERLAY", "SEARCH", "FIND", "ASSIGN", "UNASSIGN",
"IF", "ELSEIF", "ELSE", "ENDIF", "CASE", "WHEN", "ENDCASE", "DO", "ENDDO", "WHILE",
"ENDWHILE", "CHECK", "EXIT", "CONTINUE", "RETURN", "LEAVE", "STOP", "REJECT",
"PERFORM", "CALL", "SUBMIT", "EXPORT", "IMPORT", "SET", "AUTHORITY-CHECK", "MESSAGE",
"WRITE", "ULINE", "SKIP", "NEW-LINE", "NEW-PAGE", "FORMAT", "HIDE", "WINDOW",
"DESCRIBE", "ADD", "SUBTRACT", "MULTIPLY", "DIVIDE", "COMPUTE", "CONVERT", "CREATE",
"RAISE", "TRY", "CATCH", "CLEANUP", "ENDTRY", "WAIT", "SUPPRESS", "SCROLL",
"PRINT-CONTROL", "BREAK-POINT", "ASSERT", "FIELD", "CHAIN", "ENDCHAIN", "ENDAT",
"SUMMARY", "DETAIL", "BACK", "RESERVE", "POSITION", "EDITOR-CALL", "RETURN",
# 클래스/인터페이스 정의부
"METHODS", "CLASS-METHODS", "CLASS-EVENTS", "EVENTS", "INTERFACES", "ALIASES",
"PUBLIC", "PROTECTED", "PRIVATE", "SECTION", "IMPLEMENTATION", "DEFINITION",
"TYPE-POOL", "FUNCTION-POOL",
}
EVENT_ORDER = ["LOAD-OF-PROGRAM", "INITIALIZATION", "AT SELECTION-SCREEN", "START-OF-SELECTION",
"GET", "END-OF-SELECTION", "TOP-OF-PAGE", "END-OF-PAGE", "AT LINE-SELECTION",
"AT USER-COMMAND"]
def _event_rank(u: Unit) -> int:
for i, ev in enumerate(EVENT_ORDER):
if u.name.startswith(ev):
return i
return len(EVENT_ORDER)
def parse_program(prog_dir: Path) -> dict:
program = prog_dir.name
meta = json.loads((prog_dir / f"{program}.meta.json").read_text(encoding="utf-8"))
code_by_include: dict[str, str] = {}
statements_by_include: dict[str, list[Statement]] = {}
for inc in meta["includes"]:
name = inc["include"]
code = (prog_dir / f"{name}.abap").read_text(encoding="utf-8")
code_by_include[name] = code
statements_by_include[name], _ = split_statements(code, name)
# 1) 매크로 이름 선수집 (매크로 호출 인식용)
macro_names: set[str] = set()
for sts in statements_by_include.values():
for st in sts:
if st.upper and st.upper[0] == "DEFINE" and len(st.upper) >= 2:
macro_names.add(st.upper[1])
# 2) unit 추출
units: list[Unit] = []
for name, sts in statements_by_include.items():
units.extend(extract_units(program, name, sts, code_by_include[name].count("\n") + 1))
finalize_units(units, statements_by_include, code_by_include)
# unit_id 유일화 — 같은 이벤트(AT SELECTION-SCREEN ON VALUE-REQUEST 등)가 반복되면 라인 번호를 붙인다
seen_ids: set[str] = set()
for u in units:
if u.unit_id in seen_ids:
u.unit_id = f"{u.unit_id}@L{u.line_start}"
seen_ids.add(u.unit_id)
# 3) 심볼 선언 — 전역(DECLARATION unit) + unit 로컬
symbols: list[SymbolDecl] = []
for u in units:
sts = statements_by_include[u.include]
if u.unit_type == "DECLARATION":
symbols.extend(extract_declarations(sts, u.stmt_indexes, None, "global"))
elif u.unit_type in {"FORM", "METHOD", "FUNCTION", "MODULE", "EVENT"}:
symbols.extend(extract_declarations(sts, u.stmt_indexes, u.unit_id, "unit",
unit_type=u.unit_type, signature=u.signature))
# `TABLES: zfit0030.` / `NODES: ...` 는 **DDIC 테이블 작업영역** 선언이다. 이 이름을
# known_symbols 에 넣으면 extract_refs 의 "심볼이면 DB 테이블이 아니다" 가드가
# `SELECT ... FROM zfit0030` 을 걸러버린다 — 정작 업무의 핵심인 커스텀 Z 테이블이 통째로
# 누락된다(실측: ZFIR0010 의 tables_read 에 ZFIT0030 이 없었다).
# 가드의 목적은 내부테이블(DATA gt_x TYPE TABLE OF ...)을 DB 테이블로 오인하지 않는 것이므로
# DDIC 작업영역은 제외한다.
DDIC_WORK_AREA = {"tables", "nodes"}
global_syms = {s.name for s in symbols if s.scope == "global" and s.kind not in DDIC_WORK_AREA}
local_syms_by_unit: dict[str, set[str]] = {}
for s in symbols:
if s.scope == "unit" and s.unit_id:
local_syms_by_unit.setdefault(s.unit_id, set()).add(s.name)
# 3-b) 정의부(선언 블록) — 붙여넣기용 원문. symbol 과 목적이 다르다(parser/declarations.py)
lines_by_include = {name: code.split("\n") for name, code in code_by_include.items()}
declarations: list[Declaration] = []
for u in units:
sts = statements_by_include[u.include]
lns = lines_by_include[u.include]
if u.unit_type == "DECLARATION":
declarations.extend(extract_declaration_blocks(sts, u.stmt_indexes, lns, u.include, "global"))
elif u.unit_type == "CLASS_DEF":
declarations.append(unit_declaration(u, lns, "class"))
elif u.unit_type == "MACRO":
declarations.append(unit_declaration(u, lns, "macro"))
elif u.unit_type in {"FORM", "METHOD", "FUNCTION", "MODULE", "EVENT"}:
declarations.extend(
extract_declaration_blocks(sts, u.stmt_indexes, lns, u.include, "unit", u.unit_id))
# 4) unit 별 참조/데이터플로우
all_writes, all_reads, unit_refs = [], [], {}
for u in units:
if u.unit_type in {"DECLARATION", "CLASS_DEF"}:
continue
sts = statements_by_include[u.include]
known = global_syms | local_syms_by_unit.get(u.unit_id, set())
r = extract_refs(sts, u.stmt_indexes, macro_names, known)
unit_refs[u.unit_id] = r
writes, reads = extract_writes(sts, u.stmt_indexes, u.unit_id, known)
all_writes.extend(writes)
all_reads.extend(reads)
# 5) 호출 그래프
form_by_name = {u.name: u.unit_id for u in units if u.unit_type == "FORM"}
module_by_name = {u.name.split(" ")[0]: u.unit_id for u in units if u.unit_type == "MODULE"}
edges: list[dict] = []
for u in units:
r = unit_refs.get(u.unit_id)
if not r:
continue
for c in r.calls:
kind, target = c["kind"], c["target"]
edge = {"from_unit": u.unit_id, "to_unit": None, "external_name": None,
"call_type": kind, "line": c.get("line", 0)}
if kind == "perform":
if target in form_by_name:
edge["to_unit"] = form_by_name[target]
else:
edge["external_name"] = target
edge["call_type"] = "perform_unresolved"
elif kind == "perform_external":
edge["external_name"] = f"{c.get('program', '?')}:{target}"
else:
edge["external_name"] = target
edges.append(edge)
# 6) 실행 순서(topo_order) — 이벤트 순 → PERFORM 체인 DFS (순환은 끊음)
adj: dict[str, list[str]] = {}
for e in edges:
if e["to_unit"]:
adj.setdefault(e["from_unit"], []).append(e["to_unit"])
topo: list[str] = []
visited: set[str] = set()
def dfs(uid: str) -> None:
if uid in visited:
return
visited.add(uid)
topo.append(uid)
for nxt in adj.get(uid, []):
dfs(nxt)
for u in sorted([x for x in units if x.unit_type == "EVENT"], key=_event_rank):
dfs(u.unit_id)
for u in units: # 이벤트에서 도달 못한 unit (PBO/PAI 모듈 등)
if u.unit_type in {"MODULE", "FORM", "METHOD", "FUNCTION"} and u.unit_id not in visited:
dfs(u.unit_id)
# 7) 미인식 문장 리포트
unknown = Counter()
total_stmts = 0
for sts in statements_by_include.values():
for st in sts:
if not st.upper:
continue
total_stmts += 1
head = st.upper[0]
base = head.split("(")[0]
if (
base in RECOGNIZED_HEADS
or base in macro_names
or base.startswith("&") # 매크로 치환 파라미터(&1 = …) — 템플릿이라 분석 대상 아님
or "->" in base or "=>" in base # 메서드 호출식 문장
or assignment_eq_index(st.upper) is not None
):
continue
unknown[base] += 1
return {
"program": program,
"description": meta.get("description", ""),
"includes": meta["includes"],
"text_symbols": meta.get("text_symbols", []),
"units": [
{**{k: v for k, v in asdict(u).items() if k != "stmt_indexes"},
"refs": asdict(unit_refs[u.unit_id]) if u.unit_id in unit_refs else None}
for u in units
],
"symbols": [asdict(s) for s in symbols],
"declarations": [asdict(d) for d in declarations],
"writes": [asdict(w) for w in all_writes],
"reads": all_reads,
"call_edges": edges,
"topo_order": topo,
"stats": {
"units": len(units),
"statements": total_stmts,
"symbols": len(symbols),
"declarations": len(declarations),
"writes": len(all_writes),
"unknown_statements": sum(unknown.values()),
"unknown_ratio": round(sum(unknown.values()) / total_stmts, 4) if total_stmts else 0,
"unknown_top": unknown.most_common(15),
},
}
def main() -> None:
ap = argparse.ArgumentParser(description="Stage 2 — 구조 파싱")
ap.add_argument("--program", default=None, help="특정 프로그램만")
ap.add_argument("--limit", type=int, default=None)
ap.add_argument("--dry-run", action="store_true")
args = ap.parse_args()
from config.settings import settings
norm, out = settings.data_normalized, settings.data_parsed
out.mkdir(parents=True, exist_ok=True)
prog_dirs = sorted(d for d in norm.iterdir() if d.is_dir()) if norm.exists() else []
if args.program:
prog_dirs = [d for d in prog_dirs if d.name == args.program.upper()]
if args.limit:
prog_dirs = prog_dirs[: args.limit]
agg = {"programs": 0, "units": 0, "statements": 0, "unknown": 0, "errors": 0}
for d in prog_dirs:
try:
result = parse_program(d)
except Exception as e: # noqa: BLE001
agg["errors"] += 1
print(f"[FAIL] {d.name}: {type(e).__name__}: {e}")
continue
agg["programs"] += 1
agg["units"] += result["stats"]["units"]
agg["statements"] += result["stats"]["statements"]
agg["unknown"] += result["stats"]["unknown_statements"]
if not args.dry_run:
(out / f"{d.name}.parse.json").write_text(
json.dumps(result, ensure_ascii=False), encoding="utf-8"
)
print(f"[OK] {d.name}: units={result['stats']['units']} stmts={result['stats']['statements']} "
f"unknown={result['stats']['unknown_statements']} ({result['stats']['unknown_ratio']:.1%}) "
f"top={result['stats']['unknown_top'][:5]}")
print(json.dumps(agg, ensure_ascii=False))
if __name__ == "__main__":
main()
+174
View File
@@ -0,0 +1,174 @@
"""문장 분리 — '.' 종결, 체인(:` `,`) 전개. (계획서 §4.1)"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from .tokenizer import Line, read_lines, tokenize_code
_NAME_HEAD = re.compile(r"^[<A-Za-z_/]")
def assignment_eq_index(up: list[str]) -> int | None:
"""평문 대입이면 최상위 '=' 토큰의 위치, 아니면 None.
받아들이는 형태 — 쓰기 지점 추출(dataflow)과 미인식 리포트(run)가 같은 판정을 써야 하므로
여기 한 곳에만 둔다:
X = ... → 1
X[] = ... → 3 (내부테이블 본문 대입 — 흔한 관용구)
X[ key = v ] = ... → 대괄호를 건너뛴 위치 (테이블 표현식)
X(3) = ... → 괄호를 건너뛴 위치 (오프셋·길이 쓰기)
X+4(2) = ... → 위와 같음
"""
if len(up) < 3 or not _NAME_HEAD.match(up[0]):
return None
i = 1
# 오프셋 표기 X+4(2) — '+'와 숫자를 먼저 건너뛴다
if i + 1 < len(up) and up[i] == "+" and up[i + 1].isdigit():
i += 2
for opener, closer in (("[", "]"), ("(", ")")):
if i < len(up) and up[i] == opener:
depth = 0
while i < len(up):
if up[i] == opener:
depth += 1
elif up[i] == closer:
depth -= 1
if depth == 0:
i += 1
break
i += 1
return i if i < len(up) and up[i] == "=" else None
@dataclass
class Statement:
include: str
line_start: int
line_end: int
tokens: list[str] # 원문 케이스
comment_text: str = "" # 같은 줄 / 직전 주석
raw_text: str = ""
# --- 체인(`DATA: a, b, c.`) 항목의 원문 복원용 (parser/declarations.py) ---
chain_head: str = "" # ':' 앞 토큰들 ("DATA") — 체인에서 전개된 항목만
chain_start: int = 0 # 체인 전체의 시작 줄
chain_end: int = 0 # 체인 전체의 끝 줄('.' 이 있는 줄)
@property
def upper(self) -> list[str]:
return [t.upper() for t in self.tokens]
def text(self) -> str:
return " ".join(self.tokens)
def _expand_chain(tokens: list[str], token_lines: list[int]) -> list[tuple[list[str], int, int]]:
"""'DATA: a TYPE i, b TYPE i' → [(['DATA','a','TYPE','i'], 줄, 줄), (['DATA','b',...], 줄, 줄)]
괄호 안의 ','는 분리하지 않는다. 항목마다 **자기 토큰이 놓인 줄 범위**를 함께 돌려준다 —
체인 항목의 선언 원문을 그 항목만큼만 잘라내려면(정의부 수집) 항목별 줄이 있어야 한다.
체인이 아니면 빈 목록을 돌려준다(호출 측이 문장 전체 범위를 쓴다).
"""
if ":" not in tokens:
return []
ci = tokens.index(":")
head, head_lines = tokens[:ci], token_lines[:ci]
rest, rest_lines = tokens[ci + 1 :], token_lines[ci + 1 :]
groups: list[tuple[list[str], list[int]]] = []
cur: list[str] = []
cur_lines: list[int] = []
depth = 0
for t, ln in zip(rest, rest_lines):
if t == "(":
depth += 1
elif t == ")":
depth = max(0, depth - 1)
if t == "," and depth == 0:
groups.append((cur, cur_lines))
cur, cur_lines = [], []
else:
cur.append(t)
cur_lines.append(ln)
groups.append((cur, cur_lines))
out: list[tuple[list[str], int, int]] = []
fallback = head_lines[0] if head_lines else (token_lines[0] if token_lines else 0)
for g, gl in groups:
if not (head or g):
continue
start = gl[0] if gl else fallback
end = gl[-1] if gl else start
out.append((head + g, start, end))
return out
def split_statements(text: str, include: str) -> tuple[list[Statement], list[Line]]:
"""인클루드 소스 → 문장 목록. 주석 줄은 직후 문장의 comment_text 로 전달."""
lines = read_lines(text)
statements: list[Statement] = []
pending_tokens: list[str] = []
pending_lines: list[int] = [] # pending_tokens 와 같은 길이 — 토큰이 놓인 줄
pending_start: int | None = None
pending_comments: list[str] = []
inline_comments: list[str] = []
def flush(end_line: int) -> None:
nonlocal pending_tokens, pending_lines, pending_start, pending_comments, inline_comments
if pending_tokens:
comment = " ".join(c for c in (pending_comments + inline_comments) if c).strip()
start = pending_start or end_line
items = _expand_chain(pending_tokens, pending_lines)
if not items: # 체인이 아니면 문장 = 전체 범위
items = [(pending_tokens, start, end_line)]
chain_head = ""
else:
chain_head = " ".join(pending_tokens[: pending_tokens.index(":")])
for i, (toks, ls, le) in enumerate(items):
if not toks:
continue
statements.append(
Statement(
include=include,
line_start=ls,
# 마지막 항목은 종결 '.' 이 있는 줄까지 — 원문을 잘라도 문장이 닫힌다
line_end=end_line if i == len(items) - 1 else le,
tokens=toks,
comment_text=comment[:500],
chain_head=chain_head,
chain_start=start,
chain_end=end_line,
)
)
pending_tokens = []
pending_lines = []
pending_start = None
pending_comments = []
inline_comments = []
for ln in lines:
if ln.is_full_comment:
if pending_tokens:
inline_comments.append(ln.comment)
else:
pending_comments.append(ln.comment)
continue
if ln.comment:
inline_comments.append(ln.comment)
toks = tokenize_code(ln.code)
if not toks:
if not pending_tokens:
# 빈 줄이 이어지면 이전 주석은 다음 문장과 무관해질 수 있으나, 그대로 유지(요약 컨텍스트용)
pass
continue
for t in toks:
if t == ".":
flush(ln.no)
else:
if pending_start is None:
pending_start = ln.no
pending_tokens.append(t)
pending_lines.append(ln.no)
flush(len(lines))
for st in statements:
st.raw_text = st.text()[:400]
return statements, lines
+98
View File
@@ -0,0 +1,98 @@
"""ABAP 라인 → (코드, 주석) 분리와 토큰화.
목표는 완전한 ABAP 렉서가 아니라 (계획서 §11.3) 문장 패턴 매칭에 충분한 토큰열이다.
- 줄 첫 문자 '*' → 전체 주석 줄
- 코드 중 '"' 부터 줄 끝 → 인라인 주석 (단, '...' / `...` / |...| 리터럴 내부 제외)
- 식별자는 -, ->, =>, / 를 포함해 한 토큰으로 취급한다
(gs_head-belnr, zcl_x=>meth, lo_obj->meth, TEXT-004, /bic/xxx 등)
- 필드심볼도 컴포넌트까지 한 토큰이다 (<ls_fcat>-fieldname). 이걸 쪼개면 일반 대입
판정(up[1] == "=")이 깨져 쓰기 지점이 누락되고 미인식 문장으로 잡힌다 — ALV 필드카탈로그를
채우는 관용구라 프로그램에 따라 미인식률을 12% 까지 끌어올렸다.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
@dataclass
class Line:
no: int # 1-based
code: str # 주석 제거된 코드 부분
comment: str # 이 줄의 주석 텍스트 (전체주석/인라인 모두)
is_full_comment: bool
_TOKEN_RE = re.compile(
r"""
'(?:[^']|'')*' # '문자열' ('' 이스케이프)
| `[^`]*` # `문자열`
| \|[^|]*\| # |템플릿|
| <[A-Za-z_][A-Za-z0-9_]*>(?:->|=>|[A-Za-z0-9_~/-])* # <필드심볼>[-컴포넌트 | ->메서드]
| &[0-9]+ # 매크로(DEFINE) 치환 파라미터 &1 &2 …
| [A-Za-z_/](?:->|=>|[A-Za-z0-9_~/-])*\(? # 식별자(-, ->, =>, /, ~ 포함; 단독 = 는 제외), 직결 여는괄호 허용
| [0-9]+
| ##[A-Za-z_]+ # pragma
| . # 그 외 1문자 ( ) . , : = 등
""",
re.VERBOSE,
)
def split_comment(line: str) -> tuple[str, str, bool]:
"""한 줄을 (코드, 주석, 전체주석여부)로 분리."""
if line.startswith("*"):
return "", line[1:].strip(), True
code_chars: list[str] = []
i, n = 0, len(line)
while i < n:
ch = line[i]
if ch == "'":
j = i + 1
while j < n:
if line[j] == "'":
if j + 1 < n and line[j + 1] == "'":
j += 2
continue
break
j += 1
code_chars.append(line[i : min(j + 1, n)])
i = j + 1
elif ch == "`":
j = line.find("`", i + 1)
j = n - 1 if j < 0 else j
code_chars.append(line[i : j + 1])
i = j + 1
elif ch == "|":
j = line.find("|", i + 1)
j = n - 1 if j < 0 else j
code_chars.append(line[i : j + 1])
i = j + 1
elif ch == '"':
return "".join(code_chars), line[i + 1 :].strip(), False
else:
code_chars.append(ch)
i += 1
return "".join(code_chars), "", False
def tokenize_code(code: str) -> list[str]:
tokens = []
for m in _TOKEN_RE.finditer(code):
t = m.group(0)
if t.strip():
# 식별자에 붙은 여는 괄호는 분리한다: "meth(" → "meth", "("
if len(t) > 1 and t.endswith("(") and not t.startswith(("'", "`", "|")):
tokens.append(t[:-1])
tokens.append("(")
else:
tokens.append(t)
return tokens
def read_lines(text: str) -> list[Line]:
out: list[Line] = []
for i, raw in enumerate(text.split("\n"), start=1):
code, comment, full = split_comment(raw.rstrip("\r"))
out.append(Line(no=i, code=code, comment=comment, is_full_comment=full))
return out
+253
View File
@@ -0,0 +1,253 @@
"""코드 단위(unit) 추출. (계획서 §4.2)
unit_type: FORM / METHOD / FUNCTION / MODULE / EVENT / CLASS_DEF / DECLARATION / MACRO
unit_id : PROGRAM#INCLUDE#TYPE#NAME
"""
from __future__ import annotations
import hashlib
from dataclasses import dataclass, field
from .statements import Statement
EVENT_KEYWORDS = {
"INITIALIZATION",
"START-OF-SELECTION",
"END-OF-SELECTION",
"TOP-OF-PAGE",
"END-OF-PAGE",
"AT", # AT SELECTION-SCREEN..., AT LINE-SELECTION, AT USER-COMMAND
"GET", # 논리DB GET
"LOAD-OF-PROGRAM",
}
DECL_KEYWORDS = {
"TYPES", "DATA", "CONSTANTS", "TABLES", "STATICS", "FIELD-SYMBOLS",
"SELECT-OPTIONS", "PARAMETERS", "PARAMETER", "SELECTION-SCREEN", "RANGES", "CONTROLS",
"CLASS-DATA", "INCLUDE", "TYPE-POOLS", "REPORT", "PROGRAM", "NODES",
}
SUB_CHUNK_LIMIT = 300 # 이 줄 수를 넘으면 최상위 IF/CASE/LOOP 경계로 서브청크 기록
@dataclass
class Unit:
unit_id: str
program: str
include: str
unit_type: str
name: str
line_start: int
line_end: int
signature: str = ""
header_comment: str = ""
code_hash: str = ""
loc: int = 0
stmt_indexes: list[int] = field(default_factory=list) # 이 unit 에 속한 문장 인덱스
sub_chunks: list[dict] = field(default_factory=list)
def _event_name(up: list[str]) -> str | None:
head = up[0]
if head in {"INITIALIZATION", "START-OF-SELECTION", "END-OF-SELECTION",
"TOP-OF-PAGE", "END-OF-PAGE", "LOAD-OF-PROGRAM"}:
return head
if head == "AT" and len(up) >= 2 and up[1] in {"SELECTION-SCREEN", "LINE-SELECTION", "USER-COMMAND"}:
# AT NEW/END/FIRST/LAST 는 LOOP 제어문이므로 이벤트가 아니다
return " ".join(up[:4])[:60]
if head == "GET" and len(up) == 2 and up[1] not in {
"PARAMETER", "TIME", "CURSOR", "BADI", "REFERENCE", "PF-STATUS", "RUN"
} and not up[1].startswith("'"):
return f"GET {up[1]}" # 논리DB 이벤트
return None
def extract_units(program: str, include: str, statements: list[Statement], source_lines: int) -> list[Unit]:
units: list[Unit] = []
open_unit: Unit | None = None # FORM/FUNCTION/MODULE/MACRO/METHOD
open_event: Unit | None = None
open_class: Unit | None = None # CLASS_DEF (DEFINITION)
in_class_impl: str | None = None # CLASS ... IMPLEMENTATION 의 클래스명
decl_stmts: list[int] = []
def uid(utype: str, name: str) -> str:
return f"{program}#{include}#{utype}#{name.upper()}"
def close_decl() -> None:
nonlocal decl_stmts
if decl_stmts:
first, last = decl_stmts[0], decl_stmts[-1]
u = Unit(
unit_id=uid("DECLARATION", f"DECL_{statements[first].line_start}"),
program=program, include=include, unit_type="DECLARATION",
name=f"DECL_{statements[first].line_start}",
line_start=statements[first].line_start,
line_end=statements[last].line_end,
stmt_indexes=list(decl_stmts),
)
units.append(u)
decl_stmts = []
def close_event(end_line: int) -> None:
nonlocal open_event
if open_event:
open_event.line_end = end_line
units.append(open_event)
open_event = None
for i, st in enumerate(statements):
up = st.upper
head = up[0] if up else ""
# ---- 열려 있는 유닛 닫기 판단 ----
if open_unit:
open_unit.stmt_indexes.append(i)
end_kw = {"FORM": "ENDFORM", "FUNCTION": "ENDFUNCTION", "MODULE": "ENDMODULE",
"METHOD": "ENDMETHOD", "MACRO": "END-OF-DEFINITION"}[open_unit.unit_type]
if head == end_kw:
open_unit.line_end = st.line_end
units.append(open_unit)
open_unit = None
continue
if open_class:
open_class.stmt_indexes.append(i)
if head == "ENDCLASS":
open_class.line_end = st.line_end
units.append(open_class)
open_class = None
continue
# ---- 새 유닛 시작 ----
if head == "FORM" and len(up) >= 2:
close_decl(); close_event(st.line_start - 1)
open_unit = Unit(uid("FORM", up[1]), program, include, "FORM", up[1],
st.line_start, st.line_end,
signature=" ".join(st.tokens[2:])[:300],
header_comment=st.comment_text, stmt_indexes=[i])
continue
if head == "FUNCTION" and len(up) >= 2:
close_decl(); close_event(st.line_start - 1)
open_unit = Unit(uid("FUNCTION", up[1]), program, include, "FUNCTION", up[1],
st.line_start, st.line_end, header_comment=st.comment_text,
stmt_indexes=[i])
continue
if head == "MODULE" and len(up) >= 2:
close_decl(); close_event(st.line_start - 1)
mode = up[2] if len(up) >= 3 and up[2] in {"OUTPUT", "INPUT"} else ""
open_unit = Unit(uid("MODULE", f"{up[1]}_{mode}" if mode else up[1]), program, include,
"MODULE", f"{up[1]} {mode}".strip(), st.line_start, st.line_end,
header_comment=st.comment_text, stmt_indexes=[i])
continue
if head == "DEFINE" and len(up) >= 2:
close_decl(); close_event(st.line_start - 1)
open_unit = Unit(uid("MACRO", up[1]), program, include, "MACRO", up[1],
st.line_start, st.line_end, stmt_indexes=[i])
continue
if head == "METHOD" and in_class_impl and len(up) >= 2:
close_decl()
name = f"{in_class_impl}~{up[1]}"
open_unit = Unit(uid("METHOD", name), program, include, "METHOD", name,
st.line_start, st.line_end, header_comment=st.comment_text,
stmt_indexes=[i])
continue
if head == "CLASS" and len(up) >= 3:
# `CLASS lcl_x DEFINITION DEFERRED.` / `… DEFINITION LOAD.` 는 **한 줄 선언**이다 —
# ENDCLASS 가 없다. 블록으로 열면 그 뒤 인클루드 전체가 CLASS_DEF 하나로 삼켜져
# TOP 의 선언이 통째로 사라진다 (실측: ZCO_ALV 의 GO_ALV1 이하 전부).
if "DEFERRED" in up[2:] or "LOAD" in up[2:]:
if open_event:
open_event.stmt_indexes.append(i)
open_event.line_end = st.line_end
else:
decl_stmts.append(i)
continue
if "DEFINITION" in up[2:4]:
close_decl(); close_event(st.line_start - 1)
open_class = Unit(uid("CLASS_DEF", up[1]), program, include, "CLASS_DEF", up[1],
st.line_start, st.line_end, stmt_indexes=[i])
continue
if "IMPLEMENTATION" in up[2:4]:
close_decl(); close_event(st.line_start - 1)
in_class_impl = up[1]
continue
if head == "ENDCLASS":
in_class_impl = None
continue
ev = _event_name(up) if up else None
if ev:
close_decl(); close_event(st.line_start - 1)
open_event = Unit(uid("EVENT", ev.replace(" ", "_")), program, include, "EVENT", ev,
st.line_start, st.line_end, header_comment=st.comment_text,
stmt_indexes=[i])
continue
# ---- 유닛 밖 문장 ----
if open_event:
open_event.stmt_indexes.append(i)
open_event.line_end = st.line_end
continue
if head.split("-")[0].split(":")[0] in DECL_KEYWORDS or head in DECL_KEYWORDS:
decl_stmts.append(i)
continue
# 그 밖의 최상위 문장(드묾)은 DECLARATION 블록에 편입하지 않고 무시하되 카운트는 파서 리포트에서
decl_stmts.append(i)
# 파일 끝 정리
if open_unit:
open_unit.line_end = statements[-1].line_end if statements else source_lines
units.append(open_unit)
if open_class:
open_class.line_end = statements[-1].line_end if statements else source_lines
units.append(open_class)
close_event(statements[-1].line_end if statements else source_lines)
close_decl()
for u in units:
u.loc = u.line_end - u.line_start + 1
return units
def finalize_units(units: list[Unit], statements_by_include: dict[str, list[Statement]],
code_by_include: dict[str, str]) -> None:
"""code_hash / sub_chunks 계산."""
for u in units:
code = code_by_include.get(u.include, "")
lines = code.split("\n")
body = "\n".join(lines[u.line_start - 1 : u.line_end])
u.code_hash = hashlib.sha256(body.encode("utf-8")).hexdigest()
if u.loc > SUB_CHUNK_LIMIT:
u.sub_chunks = _sub_chunks(u, statements_by_include.get(u.include, []))
def _sub_chunks(u: Unit, statements: list[Statement]) -> list[dict]:
"""최상위 IF/CASE/LOOP/WHILE/DO 블록 경계로 unit 을 서브청크 목록으로 나눈다."""
opens = {"IF": "ENDIF", "CASE": "ENDCASE", "LOOP": "ENDLOOP", "WHILE": "ENDWHILE", "DO": "ENDDO"}
boundaries: list[int] = [u.line_start]
depth = 0
for idx in u.stmt_indexes:
st = statements[idx]
head = st.upper[0] if st.tokens else ""
if head in opens:
if depth == 0:
boundaries.append(st.line_start)
depth += 1
elif head in opens.values():
depth = max(0, depth - 1)
if depth == 0:
boundaries.append(st.line_end + 1)
boundaries.append(u.line_end + 1)
bounds = sorted(set(boundaries))
chunks = []
for a, b in zip(bounds, bounds[1:]):
if b - a > 0:
chunks.append({"line_start": a, "line_end": b - 1})
# 너무 잘게 쪼개지면 SUB_CHUNK_LIMIT 안쪽으로 병합
merged: list[dict] = []
for c in chunks:
if merged and (c["line_end"] - merged[-1]["line_start"]) <= SUB_CHUNK_LIMIT:
merged[-1]["line_end"] = c["line_end"]
else:
merged.append(dict(c))
return merged
+23
View File
@@ -0,0 +1,23 @@
[project]
name = "abap-indexing"
version = "0.1.0"
description = "ABAP 소스코드 인덱싱 서버 — 정규화/구조파싱/요약/인덱싱/질의 API"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.110",
"uvicorn[standard]>=0.29",
"pydantic>=2.6",
"pydantic-settings>=2.2",
]
[project.optional-dependencies]
dev = ["pytest>=8", "httpx>=0.27"]
# 운영 DB를 PostgreSQL로 전환할 때 (ASSUMPTIONS.md 참고)
postgres = ["psycopg[binary]>=3.1", "pgvector>=0.2"]
# flat-layout 자동 탐색이 data/ wiki/ 까지 패키지로 오인해 실패한다 — 명시 지정.
[tool.setuptools]
packages = ["config", "ingest", "parser", "index", "summarize", "query", "wiki_out", "eval"]
[tool.pytest.ini_options]
testpaths = ["tests"]
View File
+428
View File
@@ -0,0 +1,428 @@
"""Stage 5 — 질의 API (FastAPI).
opencode-be 의 커스텀 툴(.opencode/tool/_index_client.ts)이 호출하는 HTTP 서비스.
실행: python -m query.api (기본 127.0.0.1:8100)
자연어 로직 질의는 /search/logic (로직 조각) 이 1차 진입점이다 — docs/logic-chunk-design.md
응답 크기 원칙: 툴 응답이 LLM 컨텍스트에 그대로 들어가므로 상한을 두고 자른다.
(책임 경계 — 컨텍스트 초과는 인덱스 쪽 책임: 계획서 리스크 항목)
"""
from __future__ import annotations
import json
import re
import sqlite3
from datetime import datetime, timezone
import threading
from fastapi import BackgroundTasks, Body, FastAPI, HTTPException, Query
from fastapi.responses import HTMLResponse
from config.settings import ROOT, settings
from index.db import connect, db_path
from index.loader import load_parsed, refresh_program_fts
from ingest.normalize import write_program
from parser.run import parse_program
from summarize.runner import PROMPT_VERSION, summarize_units
from . import tools
from .dashboard import render_dashboard
app = FastAPI(title="abap-indexing", version="0.1.0")
MAX_CODE_LINES = 400 # unit 코드 응답 상한 (초과 시 write 지점 위주로 잘라야 하나 v1은 앞부분+안내)
def _wrap(fn, *args, **kwargs):
try:
return fn(*args, **kwargs)
except tools.NotFound as e:
raise HTTPException(status_code=404, detail=str(e)) from e
except sqlite3.OperationalError as e:
raise HTTPException(status_code=503, detail=f"인덱스 DB 오류: {e}") from e
# 프로세스 시작 시각 — 코드를 고쳤는데 반영이 안 될 때 서버가 낡았는지 판단하는 근거.
# (템플릿 html 은 요청마다 다시 읽지만 파이썬 모듈은 sys.modules 에 캐시된다.)
STARTED_AT = datetime.now(timezone.utc).isoformat(timespec="seconds")
@app.get("/health")
def health() -> dict:
con = connect()
try:
programs = con.execute("SELECT COUNT(*) AS c FROM program").fetchone()["c"]
with_source = con.execute("SELECT COUNT(*) AS c FROM program WHERE has_source=1").fetchone()["c"]
units = con.execute("SELECT COUNT(*) AS c FROM unit").fetchone()["c"]
chunks = con.execute("SELECT COUNT(*) AS c FROM logic_chunk").fetchone()["c"]
wiki_programs = (
sum(1 for _ in (settings.wiki_dir / "programs").glob("*.md"))
if (settings.wiki_dir / "programs").exists() else 0
)
return {
"status": "ok",
"started_at": STARTED_AT,
"db": str(db_path()),
"programs": programs,
"programs_with_source": with_source,
"units": units,
"logic_chunks": chunks,
"wiki_programs": wiki_programs,
"wiki_search_backend": settings.wiki_search_backend, # qmd | pg (M3.5 파일럿으로 확정)
}
finally:
con.close()
# 인제스트 후 백그라운드 요약 — 같은 프로그램 중복 실행 방지
_summarizing: set[str] = set()
_summarizing_lock = threading.Lock()
def _summarize_bg(program: str) -> None:
try:
summarize_units(program, limit=None, dry_run=False, trigger="ingest",
backend=settings.summarize_backend)
except Exception as e: # noqa: BLE001
print(f"[summarize:{program}] {type(e).__name__}: {e}")
finally:
with _summarizing_lock:
_summarizing.discard(program)
# 요약 후 위키 재생성 (계획서 v4 §5.7 — 실패해도 요약 결과에는 영향 없음)
try:
from wiki_out.okf_writer import write_program_wiki
write_program_wiki(program)
except Exception as e: # noqa: BLE001
print(f"[wiki:{program}] {type(e).__name__}: {e}")
@app.post("/ingest")
def ingest(payload: dict = Body(), background_tasks: BackgroundTasks = None) -> dict:
"""수집 API/gateway가 넘겨준 프로그램 소스 JSON 한 건을 정규화→파싱→적재까지 처리한다.
body 는 수집 원본과 같은 형태: {MAIN_PROGRAM, DESCRIPTION, INCLUDE_PROGRAM[], TEXT_SYMBOL?}
(gateway 를 거친 응답은 이미 strict JSON 이므로 raw 정규화 단계는 불필요)
"""
if not isinstance(payload, dict) or not payload.get("MAIN_PROGRAM"):
raise HTTPException(status_code=400, detail="MAIN_PROGRAM이 포함된 소스 JSON이 필요합니다")
try:
meta = write_program(payload, settings.data_normalized)
program = meta["program"]
parsed = parse_program(settings.data_normalized / program)
settings.data_parsed.mkdir(parents=True, exist_ok=True)
parse_path = settings.data_parsed / f"{program}.parse.json"
parse_path.write_text(json.dumps(parsed, ensure_ascii=False), encoding="utf-8")
except Exception as e: # noqa: BLE001
raise HTTPException(status_code=422, detail=f"정규화/파싱 실패: {type(e).__name__}: {e}") from e
con = connect()
try:
status = load_parsed(con, parse_path)
# 단건만 갱신 — 전량 재구축을 /ingest 마다 하면 1만 본에서 O(N²) 가 된다
refresh_program_fts(con, program)
con.commit()
# 요약이 안 됐거나(failed 포함) 프롬프트 버전이 지난 unit 수 — runner 의 스킵 조건과 동일 기준
pending = con.execute(
"SELECT COUNT(*) AS c FROM unit WHERE program=? AND unit_type IN "
"('FORM','METHOD','FUNCTION','MODULE','EVENT') "
"AND (summary_status!='done' OR prompt_version!=?)",
(program, PROMPT_VERSION),
).fetchone()["c"]
except Exception as e: # noqa: BLE001
con.rollback()
raise HTTPException(status_code=500, detail=f"인덱스 적재 실패: {type(e).__name__}: {e}") from e
finally:
con.close()
# LLM 요약을 백그라운드로 이어 돌린다 (응답은 즉시 반환).
# 소스가 안 바뀐(skip) 프로그램이라도 요약 미완료 unit 이 있으면 이어서 돌린다(재인덱싱 = 요약 재시도).
# SUMMARIZE_BACKEND=file 이면 키 없이 프롬프트만 큐에 쌓는다 (/summaries/jobs 로 확인).
backend = settings.summarize_backend
summarize = "disabled"
if backend != "off" and (backend == "file" or (settings.llm_base_url and settings.llm_api_key)):
if status == "loaded" or pending:
with _summarizing_lock:
already = program in _summarizing
if not already:
_summarizing.add(program)
if already:
summarize = "already_running"
else:
background_tasks.add_task(_summarize_bg, program)
summarize = "scheduled" if status == "loaded" else "resumed"
else:
summarize = "skip" # 소스 동일 + 요약도 전부 완료
return {
"program": program,
"status": status, # loaded | skip(소스 변경 없음)
"summarize": summarize, # scheduled | resumed(미완료 요약 이어서) | already_running | skip | disabled
"summarize_backend": backend, # api | file(프롬프트 큐) | off
"pending_units": pending, # 요약 대기/실패 unit 수
"includes": len(meta["includes"]),
"units": parsed["stats"]["units"],
"statements": parsed["stats"]["statements"],
"unknown_ratio": parsed["stats"]["unknown_ratio"],
}
@app.get("/search/programs")
def search_programs(q: str = Query(min_length=1), top_k: int = Query(default=10, le=30)) -> dict:
return {"query": q, "results": _wrap(tools.search_programs, q, top_k)}
@app.get("/search/units")
def search_units(q: str = Query(min_length=1), program: str | None = None,
top_k: int = Query(default=10, le=30)) -> dict:
return {"query": q, "results": _wrap(tools.search_units, q, program, top_k)}
@app.get("/search/logic")
def search_logic(q: str = Query(min_length=1), program: str | None = None, kind: str | None = None,
top_k: int = Query(default=10, le=30)) -> dict:
"""로직 조각 검색 — 자연어 로직 질의의 1차 진입점. 프로그램 단위로 묶어 반환.
응답의 `expansion` 은 용어 사전으로 펼친 동의어를 보여준다. 조각마다 붙는 `matched_by` 가
'동의어 확장' 이면 원질의가 아니라 동의어로 걸린 결과다 (점수에 감쇠가 적용됨).
"""
return {"query": q, **_wrap(tools.search_logic, q, top_k, program, kind)}
@app.get("/chunks/{chunk_id}")
def chunk(chunk_id: str, decls: bool = True) -> dict:
"""조각 메타 + 코드 원문 + 정의부. chunk_id 의 '#' 은 URL 인코딩(%23) 해야 한다.
`code` 는 로직만이라 그대로 붙여넣으면 선언이 없어 문법 오류가 난다. 함께 가야 하는 선언은
`declaration_code`(붙여넣기용 한 덩어리)와 `declarations`(건별 목록)로 준다.
필요 없으면 `?decls=false` 로 끈다.
"""
return _wrap(tools.get_chunk, chunk_id, decls)
@app.get("/programs/{name}/declarations")
def program_declarations(name: str, scope: str | None = None) -> dict:
"""프로그램의 정의부 전체 (scope=global|unit 로 거를 수 있다)."""
return _wrap(tools.get_declarations, name, scope)
@app.get("/programs/{name}/chunks")
def program_chunks(name: str, unit_id: str | None = None) -> dict:
return {"program": name.upper(), "chunks": _wrap(tools.list_chunks, name, unit_id)}
@app.get("/programs/{name}/summary")
def program_summary(name: str) -> dict:
return _wrap(tools.get_program_summary, name)
@app.get("/programs/{name}/source")
def program_source(name: str) -> dict:
return _wrap(tools.get_program_source, name)
@app.get("/programs/{name}/units/{unit}/code")
def unit_code(name: str, unit: str) -> dict:
result = _wrap(tools.get_unit_code, name, unit)
lines = result["code"].split("\n")
if len(lines) > MAX_CODE_LINES:
result["code"] = "\n".join(lines[:MAX_CODE_LINES])
result["truncated"] = True
result["total_lines"] = len(lines)
return result
@app.get("/programs/{name}/trace/{symbol}")
def trace(name: str, symbol: str) -> dict:
return _wrap(tools.trace_variable, name, symbol)
@app.get("/programs/{name}/call-graph")
def call_graph(name: str, unit: str | None = None) -> dict:
return _wrap(tools.get_call_graph, name, unit)
@app.get("/programs/{name}/who-calls/{unit}")
def who_calls_route(name: str, unit: str) -> dict:
return _wrap(tools.who_calls, name, unit)
@app.get("/tables/{name}/usage")
def table_usage(name: str) -> dict:
return _wrap(tools.get_table_usage, name)
@app.get("/wiki/{doc_path:path}")
def wiki_doc(doc_path: str) -> dict:
"""위키 문서 원문 반환 (계획서 v4 §7.2).
경로 규약: **wiki/ 루트 상대 경로** (예: programs/ZFIR10070.md).
경로 A(qmd) 채택 시 qmd 검색 결과의 경로가 그대로 들어온다.
"""
from wiki_out.merge import is_human_verified, split_frontmatter
root = settings.wiki_dir.resolve()
if not doc_path.endswith(".md"):
doc_path += ".md"
target = (root / doc_path).resolve()
if not target.is_relative_to(root):
raise HTTPException(status_code=400, detail="잘못된 경로입니다 (wiki/ 루트 상대 경로만 허용)")
if not target.is_file():
raise HTTPException(
status_code=404,
detail=f"위키 문서 '{doc_path}' 이(가) 없습니다. "
f"경로는 wiki/ 루트 상대(예: programs/ZFIR10070.md)여야 합니다.",
)
text = target.read_text(encoding="utf-8")
fm, _body = split_frontmatter(text)
return {
"path": target.relative_to(root).as_posix(),
"human_verified": is_human_verified(fm), # 사람 교정 문서를 더 신뢰 (계획서 §6-3 규칙 0)
"content": text,
}
@app.get("/wiki-viewer", response_class=HTMLResponse)
def wiki_viewer() -> str:
"""위키 뷰어 — 요청 시점의 wiki/ 를 스캔해 렌더 (실시간)."""
from wiki_out.viewer import build_html
return build_html()
@app.get("/dashboard", response_class=HTMLResponse)
def dashboard() -> str:
"""관측소 대시보드 — 요청 시점의 index.db 상태를 담아 렌더 (실시간)."""
with _summarizing_lock:
running = sorted(_summarizing)
return render_dashboard(running)
@app.get("/summaries/status")
def summaries_status() -> dict:
"""요약 진행 현황과 LLM 사용량/비용 로그 (관측소 대시보드·gateway용)."""
con = connect()
try:
per_program = [dict(r) for r in con.execute(
"SELECT u.program, COUNT(*) AS eligible, "
"SUM(CASE WHEN u.summary_status='done' THEN 1 ELSE 0 END) AS done, "
"SUM(CASE WHEN u.summary_status='failed' THEN 1 ELSE 0 END) AS failed, "
"COALESCE(SUM(u.chunk_count),0) AS chunks, "
"MAX(p.summary_status) AS program_summary "
"FROM unit u JOIN program p ON p.name=u.program "
"WHERE u.unit_type IN ('FORM','METHOD','FUNCTION','MODULE','EVENT') "
"GROUP BY u.program ORDER BY u.program")]
totals = con.execute(
"SELECT COUNT(*) AS runs, COALESCE(SUM(calls),0) AS calls, "
"COALESCE(SUM(prompt_tokens),0) AS prompt_tokens, "
"COALESCE(SUM(completion_tokens),0) AS completion_tokens, "
"COALESCE(SUM(cost_usd),0) AS cost_usd FROM llm_usage_log").fetchone()
log = [dict(r) for r in con.execute(
"SELECT * FROM llm_usage_log ORDER BY id DESC LIMIT 50")]
failures = [dict(r) for r in con.execute(
"SELECT program, unit_id, include, unit_type, name, summary_error "
"FROM unit WHERE summary_status='failed' ORDER BY program, include, line_start")]
with _summarizing_lock:
running = sorted(_summarizing)
return {
"model": settings.llm_model,
"llm_enabled": bool(settings.llm_base_url and settings.llm_api_key),
"running": running,
"programs": per_program,
"failures": failures,
"usage_totals": dict(totals),
"usage_log": log,
"job_queue": _job_queue_status(),
}
finally:
con.close()
def _job_queue_status() -> dict:
"""file 백엔드 작업 큐 현황 — LLM 키가 없을 때 진행 상황을 여기로 본다."""
from summarize.jobs import _index_rows, _status, jobs_dir
d = jobs_dir()
rows = _index_rows(d)
pending = [r for r in rows if _status(d, r) == "pending"]
return {
"dir": str(d),
"total": len(rows),
"pending": len(pending),
"answered": len(rows) - len(pending),
"next": [
{"job_id": r["job_id"], "task": r.get("task", ""), "program": r.get("program", ""),
"unit_id": r.get("unit_id", "")}
for r in pending[:20]
],
}
@app.get("/summaries/jobs")
def summaries_jobs() -> dict:
"""LLM 키 없이 돌리는 작업 큐 조회 (프롬프트 대기 목록).
채우는 방법은 `python -m summarize.jobs` 참고. API 로 응답을 받지는 않는다 —
프롬프트 본문이 커서 파일로 주고받는 쪽이 안전하다.
"""
return _job_queue_status()
# ===== LLM 설정 (관측소 대시보드에서 요약 모델 전환) =====
_MODEL_RE = re.compile(r"^[A-Za-z0-9._\-/:]+$")
ENV_PATH = ROOT / ".env" # 테스트에서 monkeypatch
def _persist_env(key: str, value: str) -> None:
"""ENV_PATH 의 key= 줄을 교체(없으면 추가) — 서버 재시작 후에도 유지되게."""
lines = ENV_PATH.read_text(encoding="utf-8").splitlines() if ENV_PATH.exists() else []
for i, ln in enumerate(lines):
if ln.startswith(f"{key}="):
lines[i] = f"{key}={value}"
break
else:
lines.append(f"{key}={value}")
ENV_PATH.write_text("\n".join(lines) + "\n", encoding="utf-8")
@app.get("/settings/llm")
def get_llm_settings() -> dict:
return {
"model": settings.llm_model,
"enabled": bool(settings.llm_base_url and settings.llm_api_key),
}
@app.put("/settings/llm")
def put_llm_settings(payload: dict = Body()) -> dict:
model = str(payload.get("model", "")).strip()
if not model or not _MODEL_RE.match(model):
raise HTTPException(status_code=400, detail="model 형식이 잘못됐습니다 (영숫자 · . - / : 만 허용)")
settings.llm_model = model # 즉시 반영 — 다음 요약 배치부터 이 모델로 호출
_persist_env("LLM_MODEL", model)
return {"model": model}
def main() -> None:
import argparse
import uvicorn
ap = argparse.ArgumentParser(description="Stage 5 — 질의 API / 관측소 / 위키 뷰어")
ap.add_argument("--host", default=settings.index_host)
ap.add_argument("--port", type=int, default=settings.index_port)
ap.add_argument("--reload", action="store_true",
help="소스 변경 시 자동 재시작. 템플릿(html)은 요청마다 다시 읽지만 "
"파이썬 모듈은 프로세스에 캐시되므로, 코드를 고치는 중이면 이 옵션이 필요하다")
args = ap.parse_args()
if args.reload:
uvicorn.run("query.api:app", host=args.host, port=args.port, reload=True)
else:
uvicorn.run(app, host=args.host, port=args.port)
if __name__ == "__main__":
main()
+123
View File
@@ -0,0 +1,123 @@
"""관측소 대시보드 — index.db 현재 상태를 담아 서버가 직접 서빙한다 (GET /dashboard).
정적 아티팩트 스냅샷과 같은 화면이며, 요청 시점마다 DB를 다시 읽어 HTML에 주입한다.
템플릿: query/dashboard.html (플레이스홀더 __DATA_JSON__, __GENERATED_AT__)
"""
from __future__ import annotations
import json
from datetime import datetime
from pathlib import Path
from config.settings import settings
from index.db import connect, loads
TEMPLATE = Path(__file__).with_name("dashboard.html")
ELIGIBLE = "('FORM','METHOD','FUNCTION','MODULE','EVENT')"
def _rows(con, sql: str, params: tuple = ()) -> list[dict]:
return [dict(r) for r in con.execute(sql, params)]
def build_data(con, running: list[str]) -> dict:
counts = {
t: con.execute(f"SELECT COUNT(*) FROM {t}").fetchone()[0]
for t in ("program", "include", "unit", "symbol", "symbol_write",
"symbol_read", "table_ref", "call_edge", "topo")
}
counts["table_read"] = con.execute("SELECT COUNT(*) FROM table_ref WHERE mode='read'").fetchone()[0]
counts["table_write"] = con.execute("SELECT COUNT(*) FROM table_ref WHERE mode='write'").fetchone()[0]
unit_counts = {r["program"]: r["n"] for r in con.execute(
"SELECT program, COUNT(*) n FROM unit GROUP BY program")}
chunk_counts = {r["program"]: r["n"] for r in con.execute(
"SELECT program, COUNT(*) n FROM logic_chunk GROUP BY program")}
counts["logic_chunk"] = sum(chunk_counts.values())
programs = [
{"name": r["name"], "devclass": r["devclass"], "title": r["title_ko"],
"units": unit_counts.get(r["name"], 0), "chunks": chunk_counts.get(r["name"], 0)}
for r in con.execute("SELECT name, devclass, title_ko FROM program ORDER BY name")
]
return {
"counts": counts,
"programs": programs,
"includes": _rows(con, "SELECT program, include, line_count FROM include ORDER BY program, include"),
"units": _rows(con, "SELECT unit_id, program, include, unit_type, name, line_start, line_end, loc FROM unit"),
"edges": _rows(con, "SELECT program, from_unit, to_unit, external_name, call_type, line FROM call_edge"),
"table_refs": _rows(con, "SELECT program, unit_id, table_name, mode FROM table_ref"),
"rw": _rows(con, """
SELECT program, symbol, SUM(w) w, SUM(r) r FROM (
SELECT program, symbol, COUNT(*) w, 0 r FROM symbol_write GROUP BY program, symbol
UNION ALL
SELECT program, symbol, 0 w, COUNT(*) r FROM symbol_read GROUP BY program, symbol
) GROUP BY program, symbol ORDER BY (SUM(w)+SUM(r)) DESC"""),
"summaries": {
r["unit_id"]: {k: (loads(r["summary_json"]) or {}).get(k)
for k in ("purpose_ko", "chunk_count", "coverage")}
for r in con.execute("SELECT unit_id, summary_json FROM unit WHERE summary_status='done'")
},
"chunks": _rows(con, "SELECT chunk_id, program, unit_id, include, line_start, line_end, kind, "
"purpose_ko, confidence FROM logic_chunk ORDER BY program, include, line_start"),
"llm": {
# 설정된 모델과 **실제로 쓰인 백엔드**를 구분해 보여준다 — file 백엔드로 돌린 결과를
# 쓰지도 않은 모델 이름으로 표기하면 관측 결과가 거짓이 된다.
"model": settings.llm_model,
"backend": settings.summarize_backend,
"api_key_set": bool(settings.llm_base_url and settings.llm_api_key),
"last_used_model": (con.execute(
"SELECT model FROM llm_usage_log ORDER BY id DESC LIMIT 1").fetchone() or [None])[0],
"running": running,
"progress": _rows(con, f"""
SELECT program, COUNT(*) eligible,
SUM(CASE WHEN summary_status='done' THEN 1 ELSE 0 END) done,
SUM(CASE WHEN summary_status='failed' THEN 1 ELSE 0 END) failed,
COALESCE(SUM(chunk_count),0) chunks
FROM unit WHERE unit_type IN {ELIGIBLE} GROUP BY program ORDER BY program"""),
"failures": _rows(con, """
SELECT program, unit_id, include, unit_type, name, summary_error
FROM unit WHERE summary_status='failed' ORDER BY program, include, line_start"""),
"totals": dict(con.execute(
"SELECT COUNT(*) runs, COALESCE(SUM(calls),0) calls, "
"COALESCE(SUM(prompt_tokens),0) pt, COALESCE(SUM(completion_tokens),0) ct, "
"COALESCE(SUM(cost_usd),0) cost FROM llm_usage_log").fetchone()),
"log": _rows(con, "SELECT * FROM llm_usage_log ORDER BY id DESC LIMIT 50"),
},
}
def render_dashboard(running: list[str] | None = None) -> str:
con = connect()
try:
data = build_data(con, running or [])
finally:
con.close()
payload = json.dumps(data, ensure_ascii=False).replace("</", "<\\/")
return (TEMPLATE.read_text(encoding="utf-8")
.replace("__DATA_JSON__", payload)
.replace("__GENERATED_AT__", datetime.now().strftime("%Y-%m-%d %H:%M:%S")))
def main() -> None:
"""관측소를 단일 HTML 파일로 내보낸다 — 다른 PC 로 옮겨 브라우저로만 열어보는 용도.
python -m query.dashboard [--out PATH]
데이터가 파일 안에 박히므로 파이썬·서버·index.db 없이 열린다 (뷰어 스냅샷과 같은 방식).
"""
import argparse
ap = argparse.ArgumentParser(description="관측소 대시보드 HTML 스냅샷")
ap.add_argument("--out", default=None)
args = ap.parse_args()
out = Path(args.out) if args.out else settings.data_raw.parent / "dashboard.html"
out.parent.mkdir(parents=True, exist_ok=True)
html = render_dashboard([])
out.write_text(html, encoding="utf-8")
print(f"{out} ({len(html) / 1024:.0f} KB)")
if __name__ == "__main__":
main()
+56
View File
@@ -0,0 +1,56 @@
"""질의 확장 — 도메인 용어 사전으로 동의어를 펼친다 (계획서 §5.6, 수정사항 4번).
"입고"로 물었을 때 GR / MSEG / 101 로 색인된 문서도 찾아야 한다.
## 왜 한 MATCH 식에 동의어를 섞지 않는가
`index.db.fts_or` 는 토큰과 한글 2-gram 을 전부 OR 로 잇는다. 이미 재현율 편향이라
동의어까지 같은 식에 넣으면 정밀도가 더 나빠진다 ("총계정원장" 질의가 "원장" 2-gram 하나로
걸린 문서와 동일 가중치가 된다).
그래서 **2단 검색**을 한다:
1단 — 원질의만으로 검색. 이걸 항상 상위에 둔다.
2단 — 결과가 부족할 때만(top_k 미만) 동의어 식으로 보충하고, 점수에 감쇠 계수를 걸고
matched_by='동의어 확장' 으로 표시해 호출 측이 구분할 수 있게 한다.
감쇠 계수(EXPANDED_WEIGHT)는 원질의 히트가 항상 앞서도록 하는 장치다.
"""
from __future__ import annotations
from config.glossary import synonym_map
from index.db import fts_or, query_tokens
# 동의어 히트에 곱하는 점수 감쇠 — 원질의 히트보다 항상 뒤에 놓이게 한다
EXPANDED_WEIGHT = 0.35
MAX_SYNONYMS = 24
def expansion_terms(q: str) -> list[str]:
"""질의 토큰의 동의어 목록 (원질의 토큰 자체는 제외)."""
syn = synonym_map()
tokens = query_tokens(q)
seen = {t.upper() for t in tokens}
out: list[str] = []
for t in tokens:
for s in syn.get(t.upper(), []):
if s.upper() in seen:
continue
seen.add(s.upper())
out.append(s)
if len(out) >= MAX_SYNONYMS:
return out
return out
def match_exprs(q: str) -> tuple[str, str | None]:
"""(원질의 MATCH 식, 동의어 MATCH 식 | None)"""
terms = expansion_terms(q)
return fts_or(query_tokens(q)), (fts_or(terms) if terms else None)
def explain(q: str) -> dict:
"""확장 결과 설명 — API 응답에 실어 "왜 이게 나왔나"를 보이게 한다."""
terms = expansion_terms(q)
return {"query": q, "tokens": query_tokens(q), "synonyms": terms,
"expanded": bool(terms), "expanded_weight": EXPANDED_WEIGHT}
+614
View File
@@ -0,0 +1,614 @@
"""Stage 5 — 질의 도구 구현. API(query/api.py)와 향후 에이전트가 공용으로 사용한다.
검색의 1차 단위는 로직 조각(search_logic → logic_chunk). search_programs / search_units 는
프로그램·unit 이름·주석·한 줄 요약 기반의 얇은 색인이다 (docs/logic-chunk-design.md).
요약(Stage 3)이 아직 없는 프로그램은 파서 구조 정보로 대체 요약을 만들어 반환한다
(사실은 파서가, 해석은 LLM이 — 구조 정보만으로도 흐름/추적 질문에는 답할 수 있다).
"""
from __future__ import annotations
import json
import sqlite3
from typing import Any
from index import decls as decls_mod
from index.db import bm25_rank, clean_comment, connect, fts_escape, loads
from parser.refs import EXTERNAL_CALL_KINDS
from .expand import EXPANDED_WEIGHT, explain, match_exprs
MAX_TRACE_DEPTH = 5
# 서술 색인(요약·키워드) 히트에 곱하는 계수. 이름·타이틀 일치가 우선이고 요약은 보강 신호다.
# 정답셋(수정사항 7번)이 생기면 EXPANDED_WEIGHT 와 함께 측정해 조정할 값이다.
_DESC_WEIGHT = 0.6
_REASON_RANK = {"이름/타이틀 일치": 0, "키워드 일치": 1, "요약 일치": 2, "동의어 확장": 3}
# clean_comment 는 index.db 로 옮겼다 (loader 도 써야 하는데 index → query 의존은 역방향).
# 기존 import 경로(query.tools.clean_comment)를 쓰는 호출부가 있어 이름은 여기서도 노출한다.
__all__ = ["clean_comment", "search_programs", "search_units", "search_logic", "get_chunk",
"list_chunks", "get_program_summary", "get_program_source", "get_unit_code",
"get_declarations", "trace_variable", "get_call_graph", "get_table_usage",
"who_calls", "NotFound"]
def _fts_rows(con: sqlite3.Connection, sql: str, base_params: list, q: str,
limit: int) -> list[tuple[sqlite3.Row, float]]:
"""2단 검색 실행 — 원질의 → (부족하면) 동의어 확장. 반환: [(row, 가중치)]
sql 은 '... MATCH ?' 자리표시자 하나를 첫 파라미터로 받는 형태여야 한다.
"""
primary, expanded = match_exprs(q)
out: list[tuple[sqlite3.Row, float]] = []
try:
out = [(r, 1.0) for r in con.execute(sql, [primary, *base_params, limit]).fetchall()]
except sqlite3.OperationalError:
out = []
if expanded and len(out) < limit:
seen = {tuple(r) for r, _ in out}
try:
for r in con.execute(sql, [expanded, *base_params, limit]).fetchall():
if tuple(r) not in seen:
out.append((r, EXPANDED_WEIGHT))
except sqlite3.OperationalError:
pass
return out
class NotFound(Exception):
pass
def _program_or_404(con: sqlite3.Connection, name: str) -> sqlite3.Row:
row = con.execute(
"SELECT p.*, COALESCE(k.text_ko,'') AS pkg_text FROM program p "
"LEFT JOIN package k ON k.devclass=p.devclass WHERE p.name=?",
(name.upper(),),
).fetchone()
if not row:
raise NotFound(f"프로그램 '{name}' 이(가) 인덱스에 없습니다")
return row
def search_programs(q: str, top_k: int = 10) -> list[dict]:
con = connect()
try:
results: dict[str, dict] = {}
def add(name: str, score: float, reason: str) -> None:
e = results.setdefault(name, {"name": name, "score": 0.0, "reason": reason})
e["score"] += score
# 더 강한 근거로 덮어쓴다: 이름/타이틀 > 요약·키워드 > 동의어
if _REASON_RANK.get(reason, 9) < _REASON_RANK.get(e["reason"], 9):
e["reason"] = reason
# 1) 이름·타이틀 색인 (원질의 우선, 부족하면 동의어 확장 — 수정사항 4번)
rank = bm25_rank("program_fts")
for row, weight in _fts_rows(
con,
f"SELECT name, {rank} AS rank FROM program_fts WHERE program_fts MATCH ? "
f"ORDER BY {rank} LIMIT ?",
[], q, top_k * 3,
):
add(row["name"], -float(row["rank"] or 0) * weight,
"키워드 일치" if weight >= 1.0 else "동의어 확장")
# 2) 서술 색인 (LLM 요약·조각 키워드·텍스트 심볼 — 수정사항 1·3번).
# 별 테이블이라 요약이 길어도 이름·타이틀 점수를 밀어내지 않고 **더하기만** 한다.
drank = bm25_rank("program_desc_fts")
for row, weight in _fts_rows(
con,
f"SELECT name, {drank} AS rank FROM program_desc_fts WHERE program_desc_fts MATCH ? "
f"ORDER BY {drank} LIMIT ?",
[], q, top_k * 3,
):
add(row["name"], -float(row["rank"] or 0) * weight * _DESC_WEIGHT,
"요약 일치" if weight >= 1.0 else "동의어 확장")
# 3) 이름/타이틀 LIKE 보조
like = f"%{q.strip().upper()}%"
like_ko = f"%{q.strip()}%"
for row in con.execute(
"SELECT name FROM program WHERE name LIKE ? OR title_ko LIKE ? LIMIT ?",
(like, like_ko, top_k * 2),
).fetchall():
add(row["name"], 5.0, "이름/타이틀 일치")
ranked = sorted(results.values(), key=lambda x: -x["score"])[:top_k]
out = []
for r in ranked:
p = con.execute(
"SELECT p.name, p.devclass, p.title_ko, p.changed_on, p.has_source, p.summary_json, "
"COALESCE(k.text_ko,'') AS pkg_text FROM program p "
"LEFT JOIN package k ON k.devclass=p.devclass WHERE p.name=?",
(r["name"],),
).fetchone()
summary = loads(p["summary_json"]) or {}
out.append(
{
"program": p["name"],
"devclass": p["devclass"],
"title_ko": p["title_ko"],
"changed_on": p["changed_on"],
"has_source": bool(p["has_source"]),
"purpose": (summary.get("business_purpose_ko") or "")[:200],
"reason": r["reason"],
}
)
return out
finally:
con.close()
def _chunk_row(r: sqlite3.Row) -> dict:
return {
"chunk_id": r["chunk_id"], "program": r["program"], "include": r["include"],
"unit": r["unit_id"].split("#")[3] if r["unit_id"].count("#") >= 3 else r["unit_id"],
"unit_type": r["unit_id"].split("#")[2] if r["unit_id"].count("#") >= 3 else "",
"line_start": r["line_start"], "line_end": r["line_end"], "kind": r["kind"],
"purpose_ko": r["purpose_ko"], "purpose_en": r["purpose_en"],
"keywords_ko": loads(r["keywords_ko"]) or [], "keywords_en": loads(r["keywords_en"]) or [],
"sap_objects": loads(r["sap_objects"]) or [],
"tables_read": loads(r["tables_read"]) or [], "tables_write": loads(r["tables_write"]) or [],
"calls": loads(r["calls"]) or [], "confidence": r["confidence"],
}
def search_logic(q: str, top_k: int = 10, program: str | None = None,
kind: str | None = None) -> dict:
"""로직 조각(logic_chunk) 검색 — 인덱스의 1차 단위. 결과는 프로그램 단위로 묶어 돌려준다.
반환: {"total": n, "programs": [{program, title_ko, purpose, score, chunks: [...]}]}
"""
con = connect()
try:
crank = bm25_rank("chunk_fts")
sql = (f"SELECT c.*, {crank} AS rank FROM chunk_fts f JOIN logic_chunk c "
f"ON c.chunk_id=f.chunk_id WHERE chunk_fts MATCH ?")
params: list[Any] = []
if program:
sql += " AND c.program=?"
params.append(program.upper())
if kind:
sql += " AND c.kind=?"
params.append(kind)
sql += f" ORDER BY {crank} LIMIT ?"
rows = _fts_rows(con, sql, params, q, top_k * 4)
grouped: dict[str, dict] = {}
for r, weight in rows:
g = grouped.get(r["program"])
if not g:
p = con.execute(
"SELECT name, devclass, title_ko, summary_json FROM program WHERE name=?", (r["program"],)
).fetchone()
summary = (loads(p["summary_json"]) or {}) if p else {}
g = grouped[r["program"]] = {
"program": r["program"], "devclass": p["devclass"] if p else None,
"title_ko": p["title_ko"] if p else "",
"purpose": (summary.get("business_purpose_ko") or "")[:200],
"score": 0.0, "chunks": [],
}
score = -float(r["rank"] or 0) * weight
g["score"] += score
if len(g["chunks"]) < top_k:
g["chunks"].append({
**_chunk_row(r), "score": round(score, 3),
"matched_by": "원질의" if weight >= 1.0 else "동의어 확장",
})
programs = sorted(grouped.values(), key=lambda g: -g["score"])[:top_k]
for g in programs:
g["score"] = round(g["score"], 3)
# total/matched_chunks 는 top_k*4 상한 안에서 매칭된 조각 수다(전체 건수가 아님).
# total 은 opencode-be 가 이미 쓰는 키라 이름을 유지하고, 뜻이 분명한 별칭을 함께 준다.
return {"total": len(rows), "matched_chunks": len(rows), "programs": programs,
"expansion": explain(q)}
finally:
con.close()
def get_chunk(chunk_id: str, with_decls: bool = True) -> dict:
"""조각 메타 + 코드 원문 + **정의부**.
`code` 는 로직만이다. 그대로 붙여넣으면 내부테이블·스트럭처 선언이 없어 문법 오류가 난다.
그래서 `declarations`(선언 목록)와 `declaration_code`(붙여넣기용 한 덩어리)를 함께 준다 —
쓸지 말지, 이름을 바꿀지는 붙여넣는 쪽이 정한다 (index/decls.py).
"""
con = connect()
try:
r = con.execute("SELECT * FROM logic_chunk WHERE chunk_id=?", (chunk_id,)).fetchone()
if not r:
raise NotFound(f"조각 '{chunk_id}' 이(가) 인덱스에 없습니다")
inc = con.execute("SELECT code FROM include WHERE program=? AND include=?",
(r["program"], r["include"])).fetchone()
code = ""
if inc and inc["code"]:
code = "\n".join(inc["code"].split("\n")[r["line_start"] - 1 : r["line_end"]])
u = con.execute("SELECT name, unit_type, line_start, line_end FROM unit WHERE unit_id=?",
(r["unit_id"],)).fetchone()
out = _chunk_row(r)
out["unit_id"] = r["unit_id"]
out["unit_lines"] = f"{u['line_start']}-{u['line_end']}" if u else ""
out["code"] = code
if with_decls:
out.update(_decl_fields(decls_mod.for_chunk(con, r, code)))
return out
finally:
con.close()
def _decl_fields(d: dict) -> dict:
"""index.decls 결과 → API 응답 필드."""
return {
"declarations": [{k: v for k, v in x.items() if k != "depends"} for x in d["declarations"]],
"declaration_code": d["code"],
"declaration_count": d["count"],
"declaration_external_refs": d["external_refs"], # DDIC 등 — 선언을 가져갈 필요가 없다
"declaration_params": d["params"], # FORM 파라미터 — 호출 측에서 들어온다
"declaration_unresolved": d["unresolved"], # 선언을 못 찾은 이름 (수집 누락 가능)
"declaration_truncated": d["truncated"],
}
def list_chunks(program: str, unit_id: str | None = None) -> list[dict]:
con = connect()
try:
sql = "SELECT * FROM logic_chunk WHERE program=?"
params: list[Any] = [program.upper()]
if unit_id:
sql += " AND unit_id=?"
params.append(unit_id)
return [_chunk_row(r) for r in con.execute(sql + " ORDER BY include, line_start", params)]
finally:
con.close()
def search_units(q: str, program: str | None = None, top_k: int = 10) -> list[dict]:
con = connect()
try:
urank = bm25_rank("unit_fts")
sql = "SELECT unit_id, program, name, purpose FROM unit_fts WHERE unit_fts MATCH ?"
params: list[Any] = []
if program:
sql += " AND program=?"
params.append(program.upper())
sql += f" ORDER BY {urank} LIMIT ?"
return [
{**dict(r), "matched_by": "원질의" if w >= 1.0 else "동의어 확장"}
for r, w in _fts_rows(con, sql, params, q, top_k)
][:top_k]
finally:
con.close()
def _structure_summary(con: sqlite3.Connection, program: str) -> dict:
"""LLM 요약이 없을 때 파서 구조 정보로 만드는 대체 요약."""
units = con.execute(
"SELECT u.*, t.ord FROM unit u LEFT JOIN topo t ON t.unit_id=u.unit_id "
"WHERE u.program=? ORDER BY COALESCE(t.ord, 9999), u.include, u.line_start",
(program,),
).fetchall()
tables_read, tables_write, external, sel_params, outputs = set(), set(), set(), [], set()
unit_list = []
for u in units:
refs = loads(u["refs_json"]) or {}
tables_read.update(refs.get("tables_read", []))
tables_write.update(refs.get("tables_write", []))
outputs.update(refs.get("output_signals", []))
sel_params.extend(refs.get("select_params", []))
for c in refs.get("calls", []):
if c["kind"] in EXTERNAL_CALL_KINDS:
external.add(f"{c.get('program', '')}:{c['target']}".lstrip(":"))
if u["unit_type"] in {"FORM", "METHOD", "FUNCTION", "MODULE", "EVENT"}:
summ = loads(u["summary_json"]) or {}
unit_list.append(
{
"unit": u["name"], "unit_type": u["unit_type"], "include": u["include"],
"lines": f"{u['line_start']}-{u['line_end']}", "loc": u["loc"],
"purpose_ko": summ.get("purpose_ko") or clean_comment(u["header_comment"]),
"chunk_count": u["chunk_count"] or 0,
}
)
chunks = [
{"chunk_id": c["chunk_id"], "unit": c["unit_id"].split("#")[3] if c["unit_id"].count("#") >= 3 else "",
"include": c["include"], "lines": f"{c['line_start']}-{c['line_end']}", "kind": c["kind"],
"purpose_ko": c["purpose_ko"]}
for c in con.execute("SELECT * FROM logic_chunk WHERE program=? ORDER BY include, line_start", (program,))
]
# main_flow: 이벤트 unit + 직접 호출 FORM 나열
flow = []
for u in units:
if u["unit_type"] != "EVENT":
continue
refs = loads(u["refs_json"]) or {}
performs = [c["target"] for c in refs.get("calls", []) if c["kind"] == "perform"]
flow.append(f"{u['name']}: " + ("".join(performs[:8]) if performs else "(직접 처리)"))
return {
"units": unit_list,
"logic_chunks": chunks,
"main_flow": flow,
"selection_screen": sel_params[:30],
"tables_read": sorted(tables_read),
"tables_write": sorted(tables_write),
"external_calls": sorted(external)[:30],
"output_type": sorted(outputs),
}
def get_program_summary(name: str) -> dict:
con = connect()
try:
p = _program_or_404(con, name)
base = {
"program": p["name"], "devclass": p["devclass"], "title_ko": p["title_ko"],
"package_text": p["pkg_text"], "changed_on": p["changed_on"],
"has_source": bool(p["has_source"]),
"summary_status": p["summary_status"],
}
llm = loads(p["summary_json"])
if llm:
base["summary"] = llm
if p["has_source"]:
base["structure"] = _structure_summary(con, p["name"])
return base
finally:
con.close()
def get_program_source(name: str) -> dict:
con = connect()
try:
p = _program_or_404(con, name)
rows = con.execute(
"SELECT include, line_count, code FROM include WHERE program=? ORDER BY rowid",
(p["name"],),
).fetchall()
if not rows:
raise NotFound(f"'{name}' 의 소스가 인덱스에 없습니다 (목록만 있는 프로그램)")
return {
"program": p["name"], "title_ko": p["title_ko"],
"includes": [{"include": r["include"], "line_count": r["line_count"], "code": r["code"]} for r in rows],
}
finally:
con.close()
def _find_unit(con: sqlite3.Connection, program: str, unit_name: str) -> sqlite3.Row:
un = unit_name.upper()
row = con.execute(
"SELECT * FROM unit WHERE program=? AND (UPPER(name)=? OR unit_id LIKE ?) "
"ORDER BY CASE unit_type WHEN 'FORM' THEN 0 WHEN 'METHOD' THEN 1 ELSE 2 END LIMIT 1",
(program, un, f"%#{un}"),
).fetchone()
if not row:
cands = con.execute(
"SELECT name FROM unit WHERE program=? AND unit_type IN "
"('FORM','METHOD','FUNCTION','MODULE','EVENT') AND UPPER(name) LIKE ? LIMIT 10",
(program, f"%{un}%"),
).fetchall()
hint = ", ".join(c["name"] for c in cands) or "(유사한 unit 없음)"
raise NotFound(f"unit '{unit_name}' 을 찾을 수 없습니다. 유사: {hint}")
return row
def get_unit_code(program: str, unit_name: str) -> dict:
con = connect()
try:
p = _program_or_404(con, program)
u = _find_unit(con, p["name"], unit_name)
inc = con.execute(
"SELECT code FROM include WHERE program=? AND include=?", (p["name"], u["include"])
).fetchone()
code = ""
if inc and inc["code"]:
lines = inc["code"].split("\n")
code = "\n".join(lines[u["line_start"] - 1 : u["line_end"]])
chunks = [_chunk_row(c) for c in con.execute(
"SELECT * FROM logic_chunk WHERE unit_id=? ORDER BY seq", (u["unit_id"],))]
return {
"unit_id": u["unit_id"], "program": p["name"], "include": u["include"],
"unit_type": u["unit_type"], "name": u["name"],
"line_start": u["line_start"], "line_end": u["line_end"],
"summary": loads(u["summary_json"]),
"chunks": chunks,
"code": code,
**_decl_fields(decls_mod.resolve(con, p["name"], u["unit_id"], code,
self_include=u["include"],
self_range=(u["line_start"], u["line_end"]))),
}
finally:
con.close()
def get_declarations(program: str, scope: str | None = None) -> dict:
"""프로그램의 정의부 전체 — 선언 카탈로그 (TOP 인클루드 전역 + FORM 로컬).
조각 단위로 필요한 만큼만 가져가는 것이 기본(get_chunk)이고, 이건 "이 프로그램의 선언을
통째로 보고 싶다"는 경우다.
"""
con = connect()
try:
p = _program_or_404(con, program)
rows = decls_mod.program_declarations(con, p["name"], scope)
return {
"program": p["name"], "count": len(rows),
"declarations": [{k: v for k, v in r.items() if k != "via"} for r in rows],
}
finally:
con.close()
def trace_variable(program: str, symbol: str) -> dict:
con = connect()
try:
p = _program_or_404(con, program)
sym = symbol.upper().split("-")[0].split("[")[0]
decls = [
dict(r) for r in con.execute(
"SELECT name, scope, unit_id, decl_include, decl_line, type_text, ddic_ref, kind "
"FROM symbol WHERE program=? AND name=?",
(p["name"], sym),
).fetchall()
]
if not decls:
cands = con.execute(
"SELECT DISTINCT name FROM symbol WHERE program=? AND name LIKE ? LIMIT 10",
(p["name"], f"%{sym.strip('<>')}%"),
).fetchall()
hint = ", ".join(c["name"] for c in cands) or "(유사한 심볼 없음)"
raise NotFound(f"심볼 '{symbol}' 선언을 찾을 수 없습니다. 유사: {hint}")
topo_rank = {
r["unit_id"]: r["ord"]
for r in con.execute("SELECT unit_id, ord FROM topo WHERE program=?", (p["name"],)).fetchall()
}
def unit_writes(sym_name: str, depth: int, seen: set[str]) -> list[dict]:
rows = con.execute(
"SELECT * FROM symbol_write WHERE program=? AND symbol=?", (p["name"], sym_name)
).fetchall()
out = []
for w in sorted(rows, key=lambda r: (topo_rank.get(r["unit_id"], 9999), r["line"])):
entry = {
"unit": w["unit_id"].split("#")[-1],
"unit_id": w["unit_id"],
"include": w["include"],
"line": w["line"],
"kind": w["kind"],
"stmt": w["stmt_text"],
"source_symbols": json.loads(w["source_symbols"] or "[]"),
"source_tables": json.loads(w["source_tables"] or "[]"),
}
if w["kind"].startswith("via_perform") and w["callee"] and depth < MAX_TRACE_DEPTH:
callee_key = f"{w['callee']}@{sym_name}"
if callee_key not in seen:
seen.add(callee_key)
entry["callee"] = w["callee"]
entry["callee_writes"] = _callee_writes(con, p["name"], w["callee"], depth + 1, seen, topo_rank)
out.append(entry)
return out
def _callee_writes(con, program, callee, depth, seen, topo_rank) -> list[dict]:
# callee unit 내부의 쓰기 — 파라미터(kind=param) 심볼 우선, 없으면 전체
u = con.execute(
"SELECT unit_id FROM unit WHERE program=? AND UPPER(name)=? AND unit_type='FORM' LIMIT 1",
(program, callee.upper()),
).fetchone()
if not u:
return []
params = {
r["name"] for r in con.execute(
"SELECT name FROM symbol WHERE program=? AND unit_id=? AND kind='param'",
(program, u["unit_id"]),
).fetchall()
}
rows = con.execute(
"SELECT * FROM symbol_write WHERE unit_id=?", (u["unit_id"],)
).fetchall()
selected = [r for r in rows if r["symbol"] in params] or list(rows)[:10]
out = []
for w in sorted(selected, key=lambda r: r["line"]):
e = {
"unit": callee.upper(), "include": w["include"], "line": w["line"],
"symbol": w["symbol"], "kind": w["kind"], "stmt": w["stmt_text"],
"source_tables": json.loads(w["source_tables"] or "[]"),
}
if w["kind"].startswith("via_perform") and w["callee"] and depth < MAX_TRACE_DEPTH:
key = f"{w['callee']}@{w['symbol']}"
if key not in seen:
seen.add(key)
e["callee"] = w["callee"]
e["callee_writes"] = _callee_writes(con, program, w["callee"], depth + 1, seen, topo_rank)
out.append(e)
return out
reads = con.execute(
"SELECT unit_id, include, line FROM symbol_read WHERE program=? AND symbol=? "
"ORDER BY line LIMIT 50",
(p["name"], sym),
).fetchall()
return {
"program": p["name"],
"symbol": sym,
"declarations": decls,
"writes": unit_writes(sym, 0, set()),
"reads": [dict(r) for r in reads],
}
finally:
con.close()
def get_call_graph(program: str, unit: str | None = None) -> dict:
con = connect()
try:
p = _program_or_404(con, program)
sql = "SELECT from_unit, to_unit, external_name, call_type, line FROM call_edge WHERE program=?"
params: list[Any] = [p["name"]]
if unit:
u = _find_unit(con, p["name"], unit)
sql += " AND (from_unit=? OR to_unit=?)"
params += [u["unit_id"], u["unit_id"]]
rows = con.execute(sql + " ORDER BY from_unit, line", params).fetchall()
edges = [
{
"from": r["from_unit"].split("#")[-1],
"to": (r["to_unit"] or "").split("#")[-1] or r["external_name"],
"external": r["to_unit"] is None,
"call_type": r["call_type"],
"line": r["line"],
}
for r in rows
]
return {"program": p["name"], "unit_filter": unit, "edges": edges[:500], "edge_count": len(edges)}
finally:
con.close()
def get_table_usage(table: str) -> dict:
con = connect()
try:
t = table.upper().strip()
rows = con.execute(
"SELECT tr.program, tr.unit_id, tr.mode, u.name AS unit_name, u.unit_type, p.title_ko "
"FROM table_ref tr JOIN unit u ON u.unit_id=tr.unit_id "
"JOIN program p ON p.name=tr.program WHERE tr.table_name=? ORDER BY tr.program",
(t,),
).fetchall()
if not rows:
cands = con.execute(
"SELECT DISTINCT table_name FROM table_ref WHERE table_name LIKE ? LIMIT 10",
(f"%{t}%",),
).fetchall()
return {"table": t, "usages": [], "similar_tables": [c["table_name"] for c in cands]}
return {
"table": t,
"usages": [
{"program": r["program"], "title_ko": r["title_ko"], "unit": r["unit_name"],
"unit_type": r["unit_type"], "mode": r["mode"]}
for r in rows[:200]
],
}
finally:
con.close()
def who_calls(program: str, unit: str) -> dict:
con = connect()
try:
p = _program_or_404(con, program)
u = _find_unit(con, p["name"], unit)
rows = con.execute(
"SELECT from_unit, call_type, line FROM call_edge WHERE to_unit=?", (u["unit_id"],)
).fetchall()
ext = con.execute(
"SELECT program, from_unit, call_type, line FROM call_edge WHERE external_name LIKE ?",
(f"%{p['name']}:{u['name'].upper()}%",),
).fetchall()
return {
"unit_id": u["unit_id"],
"callers": [{"from": r["from_unit"].split("#")[-1], "call_type": r["call_type"], "line": r["line"]} for r in rows]
+ [{"from": f"{r['program']}:{r['from_unit'].split('#')[-1]}", "call_type": r["call_type"], "line": r["line"]} for r in ext],
}
finally:
con.close()
View File
+154
View File
@@ -0,0 +1,154 @@
"""로직 조각 후처리 — LLM 이 준 조각을 코드와 대조해 확정한다 (docs/logic-chunk-design.md).
원칙: 사실은 파서가, 해석은 LLM 이.
- 줄 번호: first_line 앵커로 검증·보정, 실패하면 버림
- 테이블 · 호출: 조각 코드 범위를 파서로 다시 돌려 채움 (LLM 값은 쓰지 않음)
- 해시: 조각 코드 sha256
"""
from __future__ import annotations
import hashlib
import re
from dataclasses import dataclass, field
from parser.refs import extract_refs
from parser.statements import split_statements
from .schemas import CHUNK_KINDS, LogicChunk
MIN_LINES = 1
_WS = re.compile(r"\s+")
_SQL_HEAD = re.compile(
r"^\s*(SELECT|OPEN\s+CURSOR|INSERT|UPDATE|MODIFY|DELETE|COMMIT\s+WORK|ROLLBACK\s+WORK|"
r"CALL\s+FUNCTION|CALL\s+TRANSACTION|CALL\s+METHOD|SUBMIT|PERFORM|AUTHORITY-CHECK|"
r"LOOP\s+AT|COLLECT|MESSAGE)\b",
re.I,
)
def _norm(s: str) -> str:
return _WS.sub(" ", (s or "").strip()).lower()
@dataclass
class ResolvedChunk:
seq: int
line_start: int
line_end: int
code: str
code_hash: str
kind: str
purpose_ko: str
purpose_en: str
keywords_ko: list[str]
keywords_en: list[str]
sap_objects: list[str]
tables_read: list[str] = field(default_factory=list)
tables_write: list[str] = field(default_factory=list)
calls: list[str] = field(default_factory=list)
confidence: float = 0.5
def numbered_code(lines: list[str], line_start: int, line_end: int) -> str:
"""include 전체 줄 목록에서 [line_start, line_end] 를 ' 12| code' 형식으로."""
out = []
for no in range(line_start, line_end + 1):
if 1 <= no <= len(lines):
out.append(f"{no:5d}| {lines[no - 1]}")
return "\n".join(out)
def parser_hints(lines: list[str], line_start: int, line_end: int, limit: int = 40) -> list[str]:
"""DB 접근 · 호출 · 검증 문장이 시작되는 줄 — LLM 이 자를 후보 지점 힌트."""
hints = []
for no in range(line_start, line_end + 1):
if 1 <= no <= len(lines):
m = _SQL_HEAD.match(lines[no - 1])
if m:
hints.append(f"L{no} {lines[no - 1].strip()[:80]}")
if len(hints) >= limit:
break
return hints
def _find_anchor(lines: list[str], anchor: str, lo: int, hi: int) -> int | None:
"""[lo, hi] 안에서 anchor(정규화) 와 같은 줄 번호. 없으면 접두 일치."""
a = _norm(anchor)
if not a:
return None
for no in range(lo, hi + 1):
if _norm(lines[no - 1]) == a:
return no
head = a[:20]
if len(head) >= 8:
for no in range(lo, hi + 1):
if _norm(lines[no - 1]).startswith(head):
return no
return None
def resolve_chunks(raw: list[LogicChunk], lines: list[str], unit_start: int, unit_end: int,
include: str, known_symbols: set[str]) -> tuple[list[ResolvedChunk], list[str]]:
"""LLM 조각 → 검증·보정·사실 채움. 반환: (확정 조각(줄 순), 버린 사유 목록)."""
dropped: list[str] = []
resolved: list[ResolvedChunk] = []
seen_ranges: set[tuple[int, int]] = set()
for c in raw:
s, e = int(c.line_start), int(c.line_end)
if e < s:
s, e = e, s
length = e - s
# 1) 앵커 검증 — line_start 줄이 first_line 과 다르면 unit 안에서 찾아 보정
if c.first_line:
ok = unit_start <= s <= unit_end and _norm(lines[s - 1]) == _norm(c.first_line) \
if 1 <= s <= len(lines) else False
if not ok:
found = _find_anchor(lines, c.first_line, unit_start, unit_end)
if found is None:
dropped.append(f"L{s}-L{e}: first_line 을 unit 안에서 찾지 못함 ({c.first_line[:40]!r})")
continue
s, e = found, found + length
# 2) 범위 클램프
if s < unit_start or s > unit_end:
dropped.append(f"L{s}-L{e}: unit 범위(L{unit_start}-L{unit_end}) 밖")
continue
e = min(e, unit_end)
if e - s + 1 < MIN_LINES:
dropped.append(f"L{s}-L{e}: 너무 짧음")
continue
if (s, e) in seen_ranges:
dropped.append(f"L{s}-L{e}: 중복 범위")
continue
seen_ranges.add((s, e))
# 3) 코드 · 해시 · 파서 사실
code = "\n".join(lines[s - 1 : e])
stmts, _ = split_statements(code, include)
refs = extract_refs(stmts, list(range(len(stmts))), set(), known_symbols)
kind = c.kind if c.kind in CHUNK_KINDS else "other"
resolved.append(ResolvedChunk(
seq=0, line_start=s, line_end=e, code=code,
code_hash=hashlib.sha256(code.encode("utf-8")).hexdigest(),
kind=kind, purpose_ko=c.purpose_ko.strip(), purpose_en=c.purpose_en.strip(),
keywords_ko=list(dict.fromkeys(k.strip() for k in c.keywords_ko if k.strip()))[:15],
keywords_en=list(dict.fromkeys(k.strip() for k in c.keywords_en if k.strip()))[:15],
sap_objects=list(dict.fromkeys(k.strip().upper() for k in c.sap_objects if k.strip()))[:20],
tables_read=refs.tables_read, tables_write=refs.tables_write,
calls=[x["target"] for x in refs.calls][:20],
confidence=max(0.0, min(1.0, float(c.confidence))),
))
resolved.sort(key=lambda r: (r.line_start, r.line_end))
for i, r in enumerate(resolved, 1):
r.seq = i
return resolved, dropped
def covered_lines(chunks: list[ResolvedChunk]) -> int:
covered: set[int] = set()
for c in chunks:
covered.update(range(c.line_start, c.line_end + 1))
return len(covered)
+204
View File
@@ -0,0 +1,204 @@
"""LLM 작업 큐 CLI — LLM 키 없이 요약/조각 추출을 돌리는 입구.
`--llm file` 로 러너를 돌리면 프롬프트가 `data/llm_jobs/` 에 파일로 쌓인다.
사람이든 코딩 에이전트든 그 프롬프트를 읽고 응답 JSON 을 채워 넣으면, 러너를 다시 돌릴 때
그 응답으로 파이프라인이 이어진다.
# 1) 프롬프트 내놓기 (LLM 호출 0회)
python -m summarize.runner --llm file --program ZFIR10070 --limit 3
# 2) 대기 목록 보기 / 프롬프트 읽기
python -m summarize.jobs list
python -m summarize.jobs show <job_id>
# 3) 응답 채우기 (스키마 검증 후 저장)
python -m summarize.jobs answer <job_id> --file answer.json
python -m summarize.jobs answer <job_id> --stdin < answer.json
# 4) 같은 명령을 다시 — 이번엔 응답을 읽어 조각을 적재한다
python -m summarize.runner --llm file --program ZFIR10070 --limit 3
`answer` 는 저장 전에 프롬프트의 [작업] 종류에 맞는 pydantic 스키마로 검증한다 —
잘못된 JSON 을 큐에 넣어두고 나중에 러너에서 실패하는 걸 막는다.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
from config.settings import settings
from .llm_client import JOB_INDEX
from .schemas import ProgramSummary, UnitExtraction
TASK_SCHEMA = {"program_summary": ProgramSummary, "extract_chunks": UnitExtraction}
def jobs_dir(override: str | None = None) -> Path:
return Path(override) if override else Path(settings.data_llm_jobs)
def _index_rows(d: Path) -> list[dict]:
idx = d / JOB_INDEX
if not idx.exists():
return []
rows = []
for line in idx.read_text(encoding="utf-8").splitlines():
if line.strip():
try:
rows.append(json.loads(line))
except json.JSONDecodeError:
continue
return rows
def _status(d: Path, row: dict) -> str:
return "answered" if (d / f"{row['job_id']}.response.json").exists() else "pending"
def task_of(d: Path, jid: str) -> str:
"""프롬프트 파일에서 [작업] 종류를 읽는다."""
p = d / f"{jid}.prompt.md"
if not p.exists():
return ""
m = re.search(r"^\[작업\]\s*(\S+)", p.read_text(encoding="utf-8"), re.M)
return m.group(1) if m else ""
def cmd_list(args) -> int:
d = jobs_dir(args.dir)
rows = _index_rows(d)
if not rows:
print(f"작업이 없습니다: {d}\n먼저 실행: python -m summarize.runner --llm file --program <PROG> --limit 3")
return 0
shown = 0
for r in rows:
st = _status(d, r)
if args.pending and st != "pending":
continue
print(f"{r['job_id']} {st:9s} {r.get('task',''):16s} {r.get('program','')} "
f"{r.get('unit_id','') or '(프로그램 요약)'}"
+ (f"{r['window']}" if r.get("window") else ""))
shown += 1
n_pending = sum(1 for r in rows if _status(d, r) == "pending")
print(f"\n{len(rows)}건 (표시 {shown}) — 대기 {n_pending}, 응답완료 {len(rows) - n_pending}")
return 0
def cmd_show(args) -> int:
d = jobs_dir(args.dir)
p = d / f"{args.job_id}.prompt.md"
if not p.exists():
print(f"프롬프트 없음: {p}", file=sys.stderr)
return 1
sys.stdout.write(p.read_text(encoding="utf-8"))
return 0
def cmd_answer(args) -> int:
d = jobs_dir(args.dir)
prompt = d / f"{args.job_id}.prompt.md"
if not prompt.exists():
print(f"프롬프트 없음: {prompt}", file=sys.stderr)
return 1
if args.stdin:
raw = sys.stdin.read()
else:
try:
raw = Path(args.file).read_text(encoding="utf-8")
except OSError as e:
print(f"응답 파일을 읽을 수 없습니다: {e}", file=sys.stderr)
return 1
try:
data = json.loads(raw)
except json.JSONDecodeError as e:
s, e2 = raw.find("{"), raw.rfind("}")
if s < 0 or e2 <= s:
print(f"JSON 파싱 실패: {e}", file=sys.stderr)
return 1
data = json.loads(raw[s : e2 + 1])
task = task_of(d, args.job_id)
schema = TASK_SCHEMA.get(task)
if schema and not args.no_validate:
try:
schema.model_validate(data)
except Exception as e: # noqa: BLE001
print(f"[스키마 불일치] task={task}\n{e}", file=sys.stderr)
print("\n무시하고 저장하려면 --no-validate", file=sys.stderr)
return 1
out = d / f"{args.job_id}.response.json"
out.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"저장: {out} (task={task or '?'}, 검증={'skip' if args.no_validate else 'ok'})")
n_pending = sum(1 for r in _index_rows(d) if _status(d, r) == "pending")
print(f"남은 대기: {n_pending}건 — 전부 채우면 러너를 같은 옵션으로 다시 실행하세요")
return 0
def cmd_stats(args) -> int:
d = jobs_dir(args.dir)
rows = _index_rows(d)
by_task: dict[str, list[int]] = {}
for r in rows:
t = r.get("task") or "?"
b = by_task.setdefault(t, [0, 0])
b[0 if _status(d, r) == "pending" else 1] += 1
print(json.dumps({
"dir": str(d),
"total": len(rows),
"pending": sum(b[0] for b in by_task.values()),
"answered": sum(b[1] for b in by_task.values()),
"by_task": {k: {"pending": v[0], "answered": v[1]} for k, v in sorted(by_task.items())},
}, ensure_ascii=False, indent=2))
return 0
def cmd_clear(args) -> int:
d = jobs_dir(args.dir)
n = 0
for p in list(d.glob("*.prompt.md")) + list(d.glob("*.response.json")) + [d / JOB_INDEX]:
if p.exists() and (not args.answered_only or p.name.endswith(".response.json")):
p.unlink()
n += 1
print(f"삭제 {n}개 파일 ({d})")
return 0
def main() -> None:
ap = argparse.ArgumentParser(description="LLM 작업 큐 (키 없이 요약 돌리기)")
ap.add_argument("--dir", default=None, help=f"작업 디렉토리 (기본 {settings.data_llm_jobs})")
sub = ap.add_subparsers(dest="cmd", required=True)
p = sub.add_parser("list", help="작업 목록")
p.add_argument("--pending", action="store_true", help="대기 중인 것만")
p.set_defaults(func=cmd_list)
p = sub.add_parser("show", help="프롬프트 출력")
p.add_argument("job_id")
p.set_defaults(func=cmd_show)
p = sub.add_parser("answer", help="응답 JSON 저장 (스키마 검증)")
p.add_argument("job_id")
g = p.add_mutually_exclusive_group(required=True)
g.add_argument("--file", help="응답 JSON 파일")
g.add_argument("--stdin", action="store_true", help="표준입력에서 읽기")
p.add_argument("--no-validate", action="store_true")
p.set_defaults(func=cmd_answer)
p = sub.add_parser("stats", help="큐 통계")
p.set_defaults(func=cmd_stats)
p = sub.add_parser("clear", help="큐 비우기")
p.add_argument("--answered-only", action="store_true")
p.set_defaults(func=cmd_clear)
args = ap.parse_args()
sys.exit(args.func(args))
if __name__ == "__main__":
main()
+269
View File
@@ -0,0 +1,269 @@
"""LLM 클라이언트 추상화. 백엔드 3종 — api / file / fake.
실제 모델 정보(LLM_BASE_URL/LLM_API_KEY/LLM_MODEL)는 환경변수에서만 읽는다 (계획서 §11.5).
## 백엔드
- `api` : OpenAI 호환 `/chat/completions`. 429/5xx 는 지수 백오프로 재시도한다.
- `file` : **키 없이 돌리는 입구.** 프롬프트를 `data/llm_jobs/` 에 파일로 내놓고, 누군가(사람이든
에이전트든)가 응답 JSON 을 채워 넣으면 그걸 읽어 파이프라인을 계속한다.
키가 없는 상태에서 몇 건만 실제 품질로 돌려보려면 이 경로를 쓴다 → `summarize/jobs.py`
- `fake` : 스키마만 맞는 더미. 파이프라인 배선 검증용(품질 검증 아님).
"""
from __future__ import annotations
import hashlib
import json
import random
import re
import threading
import time
import urllib.error
import urllib.request
from pathlib import Path
from config.settings import settings
class LLMClient:
def complete_json(self, system: str, user: str) -> dict:
raise NotImplementedError
class PendingResponse(Exception):
"""file 백엔드에서 아직 응답이 채워지지 않은 프롬프트 — 실패가 아니라 '대기'다.
runner 는 이걸 failed 로 기록하지 않고 대기 건수만 집계한다.
"""
def __init__(self, job_id: str, prompt_path: Path, response_path: Path) -> None:
super().__init__(f"응답 대기: {job_id}{response_path}")
self.job_id = job_id
self.prompt_path = prompt_path
self.response_path = response_path
def _extract_json(content: str) -> dict:
"""모델이 코드펜스/서문을 붙이는 경우까지 감안해 JSON 을 뽑아낸다."""
try:
return json.loads(content)
except json.JSONDecodeError:
s, e = content.find("{"), content.rfind("}")
if s >= 0 and e > s:
return json.loads(content[s : e + 1])
raise
class OpenAICompatClient(LLMClient):
def __init__(self) -> None:
if not settings.llm_base_url or not settings.llm_api_key:
raise RuntimeError("LLM_BASE_URL / LLM_API_KEY 환경변수가 필요합니다")
self.base = settings.llm_base_url.rstrip("/")
self.key = settings.llm_api_key
self.model = settings.llm_model
# 호출 누적 사용량 — runner가 stats에 실어 보고한다
self.usage = {"calls": 0, "prompt_tokens": 0, "completion_tokens": 0, "cost_usd": 0.0,
"retries": 0}
self._lock = threading.Lock() # 병렬 호출에서 usage 집계가 어긋나지 않게
def _post_once(self, system: str, user: str) -> dict:
body = {
"model": self.model,
"temperature": 0.1,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"response_format": {"type": "json_object"},
}
if "openrouter" in self.base:
body["usage"] = {"include": True} # OpenRouter 확장 — usage.cost(USD 크레딧) 포함
req = urllib.request.Request(
f"{self.base}/chat/completions",
data=json.dumps(body).encode("utf-8"),
headers={"Authorization": f"Bearer {self.key}", "Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=settings.llm_timeout_s) as res:
return json.loads(res.read().decode("utf-8"))
def complete_json(self, system: str, user: str) -> dict:
"""429/5xx 재시도 포함. 동시성을 올리면 429 가 늘어나므로 백오프가 필수다.
(실측: 무료 티어에서 순차 실행만으로도 429 로 unit 6건이 failed 로 굳었다.)
"""
delay = settings.llm_backoff_base
last: Exception | None = None
for attempt in range(settings.llm_max_retries + 1):
try:
data = self._post_once(system, user)
break
except urllib.error.HTTPError as e:
last = e
retryable = e.code == 429 or 500 <= e.code < 600
if not retryable or attempt >= settings.llm_max_retries:
raise
wait = delay
hdr = (e.headers or {}).get("Retry-After") if hasattr(e, "headers") else None
if hdr:
try:
wait = max(wait, float(hdr))
except ValueError:
pass
time.sleep(min(wait, settings.llm_backoff_max) * (1 + random.random() * 0.25))
delay *= 2
with self._lock:
self.usage["retries"] += 1
except (urllib.error.URLError, TimeoutError) as e:
last = e
if attempt >= settings.llm_max_retries:
raise
time.sleep(min(delay, settings.llm_backoff_max))
delay *= 2
with self._lock:
self.usage["retries"] += 1
else: # pragma: no cover — 위 for 는 break/raise 로만 끝난다
raise last or RuntimeError("LLM 호출 실패")
u = data.get("usage") or {}
with self._lock:
self.usage["calls"] += 1
self.usage["prompt_tokens"] += u.get("prompt_tokens", 0)
self.usage["completion_tokens"] += u.get("completion_tokens", 0)
self.usage["cost_usd"] += u.get("cost") or 0.0
return _extract_json(data["choices"][0]["message"]["content"])
# --------------------------------------------------------------------- file 백엔드
JOB_INDEX = "index.jsonl"
def job_id(system: str, user: str) -> str:
"""프롬프트 내용으로 결정되는 id — 같은 프롬프트는 재실행해도 같은 파일을 가리킨다."""
return hashlib.sha256((system + "\n\x00\n" + user).encode("utf-8")).hexdigest()[:16]
def _meta_from_prompt(user: str) -> dict:
"""프롬프트 본문에서 작업 종류·프로그램·unit 을 긁어 index.jsonl 에 남긴다."""
def grab(pattern: str) -> str:
m = re.search(pattern, user, re.M)
return m.group(1).strip() if m else ""
return {
"task": grab(r"^\[작업\]\s*(\S+)"),
"program": grab(r"^\[프로그램\]\s*(\S+)"),
# UNIT_PROMPT 는 '[단위] unit_id: ...' 형태라 줄 앞에 [단위] 가 붙는다
"unit_id": grab(r"^\[단위\]\s*unit_id:\s*(\S+)"),
"unit_type": grab(r"^unit_type:\s*(\w+)"),
"window": grab(r"지금은 (\d+)번째 창"),
}
class FileQueueLLM(LLMClient):
"""프롬프트를 파일로 내놓고 응답 파일을 기다리는 백엔드 (키 불필요).
complete_json 은
- `<id>.response.json` 이 있으면 그 JSON 을 돌려준다 (정상 경로)
- 없으면 `<id>.prompt.md` 를 쓰고 PendingResponse 를 던진다
응답 파일은 사람이 직접 채워도 되고, 코딩 에이전트가 프롬프트를 읽고 채워도 된다.
`python -m summarize.jobs` 가 목록·조회·검증 저장을 돕는다.
"""
def __init__(self, jobs_dir: Path | None = None, *, emit: bool = True) -> None:
self.dir = Path(jobs_dir or settings.data_llm_jobs)
self.emit = emit
self.dir.mkdir(parents=True, exist_ok=True)
self.usage = {"calls": 0, "prompt_tokens": 0, "completion_tokens": 0, "cost_usd": 0.0,
"retries": 0}
self.pending: list[str] = []
self.answered: list[str] = []
self._lock = threading.Lock()
def prompt_path(self, jid: str) -> Path:
return self.dir / f"{jid}.prompt.md"
def response_path(self, jid: str) -> Path:
return self.dir / f"{jid}.response.json"
def _write_prompt(self, jid: str, system: str, user: str) -> None:
meta = _meta_from_prompt(user)
header = [
"<!-- abap-indexing LLM 작업. 이 파일은 읽기용이다.",
f" 응답은 {jid}.response.json 에 JSON 만 써라 (프롬프트 맨 끝 스키마 그대로).",
" 저장·검증: python -m summarize.jobs answer " + jid + " --file <응답.json>",
f" task={meta['task']} program={meta['program']} unit={meta['unit_id']} "
f"window={meta['window'] or '1'} -->",
"",
"## SYSTEM", "", system, "", "## USER", "", user, "",
]
self.prompt_path(jid).write_text("\n".join(header), encoding="utf-8")
line = json.dumps({"job_id": jid, **meta,
"prompt": self.prompt_path(jid).name,
"response": self.response_path(jid).name}, ensure_ascii=False)
idx = self.dir / JOB_INDEX
existing = idx.read_text(encoding="utf-8").splitlines() if idx.exists() else []
if not any(f'"job_id": "{jid}"' in ln for ln in existing):
with idx.open("a", encoding="utf-8") as f:
f.write(line + "\n")
def complete_json(self, system: str, user: str) -> dict:
jid = job_id(system, user)
rp = self.response_path(jid)
if rp.exists():
data = _extract_json(rp.read_text(encoding="utf-8"))
with self._lock:
self.usage["calls"] += 1
self.answered.append(jid)
return data
with self._lock:
if self.emit:
self._write_prompt(jid, system, user)
self.pending.append(jid)
raise PendingResponse(jid, self.prompt_path(jid), rp)
class FakeLLM(LLMClient):
"""테스트/드라이런용 — 프롬프트 종류([작업] 표식)에 맞는 최소 JSON 을 돌려준다.
extract_chunks 는 코드의 첫 실행문 한 줄을 조각 하나로 만든다 (파이프라인 검증용).
"""
def __init__(self) -> None:
self.usage = {"calls": 0, "prompt_tokens": 0, "completion_tokens": 0, "cost_usd": 0.0,
"retries": 0}
def complete_json(self, system: str, user: str) -> dict:
self.usage["calls"] += 1
task = ""
for line in user.splitlines():
if line.startswith("[작업]"):
task = line.split("]", 1)[1].strip()
break
if task == "program_summary":
return {"program": "", "business_purpose_ko": "(fake) 프로그램 요약", "confidence": 0.0}
# extract_chunks — ' 12| code' 형식의 첫 코드 줄을 조각으로
m = re.search(r"^\s*(\d+)\| (?!FORM |ENDFORM|METHOD |ENDMETHOD|FUNCTION |ENDFUNCTION|MODULE |ENDMODULE)(\S.*)$",
user, re.M)
chunks = []
if m:
no, text = int(m.group(1)), m.group(2)
chunks.append({"line_start": no, "line_end": no, "first_line": text, "kind": "other",
"purpose_ko": "(fake) 조각", "confidence": 0.0})
return {"unit_purpose_ko": "(fake) unit 요약", "chunks": chunks}
BACKENDS = ("api", "file", "fake")
def create_llm(fake: bool = False, backend: str | None = None, **kwargs) -> LLMClient:
"""백엔드 선택. `backend` 가 우선이고, 하위호환으로 `fake=True` 도 받는다."""
name = backend or ("fake" if fake else "api")
if name not in BACKENDS:
raise ValueError(f"알 수 없는 LLM 백엔드: {name} (가능: {', '.join(BACKENDS)})")
if name == "fake":
return FakeLLM()
if name == "file":
return FileQueueLLM(**kwargs)
return OpenAICompatClient()
+668
View File
@@ -0,0 +1,668 @@
"""Stage 3 실행기 — 프로그램 요약 → unit 별 로직 조각 추출 (docs/logic-chunk-design.md).
python -m summarize.runner [--program X] [--limit N] [--fake] [--dry-run]
흐름 (프로그램마다):
1. 프로그램 요약(ProgramSummary) — 없거나 stale 이거나 프롬프트 버전이 지났으면 생성
2. unit(FORM/METHOD/FUNCTION/MODULE/EVENT) 하나씩 → 프로그램 요약을 문맥으로 붙여
LLM 이 로직 조각(LogicChunk) 을 골라낸다. 300줄 넘는 unit 은 파서 sub_chunks 창으로 나눠 호출.
3. 조각은 summarize/chunks.py 로 검증(줄 앵커) · 사실 채움(파서) 후 logic_chunk / chunk_fts 에 저장.
멱등성: unit.summary_status='done' 이고 prompt_version 이 같으면 스킵 (code_hash 가 바뀐 unit 은
loader 가 재적재 시 상태를 초기화한다). LLM Key 미확보 상태에서는 --fake 로 파이프라인만 검증.
"""
from __future__ import annotations
import argparse
import json
import re
import sqlite3
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timezone
from config.settings import settings
from index.db import bigrams, clean_comment, connect, loads, text_symbol_phrases
from index.loader import refresh_program_fts
from query.tools import _structure_summary
from .chunks import ResolvedChunk, covered_lines, numbered_code, parser_hints, resolve_chunks
from .llm_client import PendingResponse, create_llm
from .schemas import CHUNK_KINDS, ProgramSummary, UnitExtraction
PROMPT_VERSION = 2
ELIGIBLE = ("FORM", "METHOD", "FUNCTION", "MODULE", "EVENT")
MAX_WINDOW_LINES = 300 # 이보다 긴 unit 은 창으로 나눠 LLM 에 보여준다
FALLBACK_WINDOW = 250 # sub_chunks 가 없을 때의 고정 창 크기
MAX_EVENT_CODE_LINES = 150 # 프로그램 요약에 넣는 이벤트 블록 코드 상한
SYSTEM_PROMPT = (
"당신은 SAP ABAP 시니어 개발자다. 코드를 업무 관점으로 한국어로 설명한다. "
"SAP 표준 영어 용어(예: Goods Receipt, G/L Account)와 기술 객체명(테이블·FM·BAPI)은 함께 적는다. "
"추측이 필요한 부분은 confidence 를 낮추고 unclear 에 기록한다. 출력은 JSON 만."
)
PROGRAM_PROMPT = """[작업] program_summary
다음 ABAP 프로그램을 업무 관점으로 요약하라. ProgramSummary JSON 스키마로만 답하라.
[프로그램] {program}{title}
[패키지] {devclass} {pkg_text}
[선택화면] {selection}
[텍스트 심볼] {text_symbols}
[구조 사실 — 파서 추출]
주 흐름(이벤트 → PERFORM 체인):
{main_flow}
테이블 read: {tables_read}
테이블 write: {tables_write}
외부 호출: {external_calls}
출력 형태: {output_type}
[unit 목록] (유형 이름 — 헤더 주석)
{unit_list}
[이벤트 블록 코드 — 줄번호| 내용]
{event_code}
[출력 JSON 스키마 — 아래 키만 사용]
{{"program": "{program}", "title_ko": "", "business_purpose_ko": "업무 목적 2~3문장", "business_purpose_en": "",
"main_flow": ["처리 순서를 업무 언어로"], "selection_screen": [{{"name": "", "desc_ko": ""}}],
"key_internal_tables": [{{"name": "", "filled_by": [], "consumed_by": [], "desc_ko": ""}}],
"output_type": [], "business_tags": [], "sap_module": "FI/CO/MM/SD 등",
"keywords_ko": ["한국어 검색 키워드"], "keywords_en": ["English search keywords"],
"related_tcodes": [], "notes": [], "confidence": 0.0, "unclear": []}}
"""
UNIT_PROMPT = """[작업] extract_chunks
다음 ABAP 코드 단위에서 **업무적으로 의미 있는 로직 조각**을 골라내고 각각을 설명하라.
UnitExtraction JSON 스키마로만 답하라.
[프로그램] {program}{title}
[프로그램 요약] {program_purpose}
[주 흐름] {main_flow}
[핵심 내부테이블] {key_tables}
[단위] unit_id: {unit_id}
unit_type: {unit_type}, name: {name}
signature: {signature}
header_comment: {header_comment}
{window_note}
[파서 힌트 — DB 접근 · 호출 · 검증이 시작되는 줄]
{hints}
[텍스트 심볼]
{text_symbols}
[코드 — 줄번호| 내용]
{code}
[조각 선정 규칙]
- 조각 = 하나의 업무 로직을 이루는 연속된 줄 범위. 예: 특정 테이블 SELECT 와 그 직후 결과 정리(SORT/DELETE/LOOP 집계)
까지가 한 로직이면 하나로 묶는다. BAPI/FM 호출은 파라미터 채우기 ~ 호출 ~ 결과 처리까지 한 조각.
- 조각으로 만들지 않고 버리는 것: 단순 선언(DATA/TYPES), 변수 초기화(CLEAR/REFRESH/FREE), ALV 필드카탈로그·레이아웃·
컬럼 속성 설정, 화면 속성 LOOP AT SCREEN, 단순 이벤트 등록, 로그성 WRITE, 주석만 있는 구간.
- 조각은 서로 겹치지 않는다. 보통 3~80줄. FORM/METHOD 전체를 통째로 하나의 조각으로 만들지 않는다
(내부에 로직이 하나뿐이면 그 로직 범위만).
- 의미 있는 조각이 없으면 chunks 를 빈 배열로 둔다. 그래도 unit_purpose_ko 는 채운다.
- line_start / line_end 는 위 코드에 붙은 줄번호 그대로. first_line 에는 line_start 줄의 코드를 그대로 복사한다.
- kind 는 다음 중 하나: {kinds}
- purpose_ko 는 "무엇을 왜 하는지" 한 문장(한국어). keywords_ko 는 업무 용어(입고, 반제, 잔액 …),
keywords_en 은 SAP 표준 영어 용어, sap_objects 는 테이블·FM·BAPI·클래스·T-Code 이름.
[출력 JSON 스키마]
{{"unit_purpose_ko": "이 단위의 역할 한 문장",
"chunks": [{{"line_start": 0, "line_end": 0, "first_line": "", "kind": "sql_select",
"purpose_ko": "", "purpose_en": "", "keywords_ko": [], "keywords_en": [], "sap_objects": [],
"confidence": 0.0}}],
"unclear": []}}
"""
_SELSCREEN = re.compile(r"^\s*(PARAMETERS?|SELECT-OPTIONS|SELECTION-SCREEN\s+BEGIN\s+OF\s+BLOCK)\b(.*)$", re.I)
_TEXT_SYM = re.compile(r"TEXT-(\w{3})", re.I)
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def _j(v) -> str:
return json.dumps(v, ensure_ascii=False)
# ------------------------------------------------------------- 프로그램 요약
def _include_lines(con: sqlite3.Connection, program: str) -> dict[str, list[str]]:
return {
r["include"]: (r["code"] or "").split("\n")
for r in con.execute("SELECT include, code FROM include WHERE program=?", (program,))
}
def _text_symbols(p: sqlite3.Row) -> dict[str, str]:
return {t["symbol"].upper(): t["text"] for t in (loads(p["text_symbols_json"]) or []) if t.get("symbol")}
def _program_context(con: sqlite3.Connection, p: sqlite3.Row, lines_by_inc: dict[str, list[str]]) -> dict:
structure = _structure_summary(con, p["name"])
selection = []
for inc_lines in lines_by_inc.values():
for ln in inc_lines:
m = _SELSCREEN.match(ln)
if m:
selection.append(ln.strip()[:100])
units = con.execute(
"SELECT * FROM unit WHERE program=? AND unit_type IN ('FORM','METHOD','FUNCTION','MODULE','EVENT') "
"ORDER BY include, line_start", (p["name"],)).fetchall()
unit_list = [
f"{u['unit_type']} {u['name']} ({u['include']} L{u['line_start']}-{u['line_end']})"
+ (f"{clean_comment(u['header_comment'])}" if clean_comment(u["header_comment"]) else "")
for u in units[:150]
]
event_code, budget = [], MAX_EVENT_CODE_LINES
for u in units:
if u["unit_type"] != "EVENT" or budget <= 0:
continue
lines = lines_by_inc.get(u["include"], [])
end = min(u["line_end"], u["line_start"] + budget - 1)
event_code.append(numbered_code(lines, u["line_start"], end))
budget -= end - u["line_start"] + 1
ts = _text_symbols(p)
return {
"structure": structure,
"selection": "; ".join(selection[:30]) or "-",
"text_symbols": "; ".join(f"{k}={v}" for k, v in list(ts.items())[:40]) or "-",
"unit_list": "\n".join(unit_list) or "-",
"event_code": "\n".join(event_code) or "-",
}
def ensure_program_summary(con: sqlite3.Connection, llm, p: sqlite3.Row,
lines_by_inc: dict[str, list[str]], dry_run: bool) -> tuple[dict, str]:
"""프로그램 요약을 (필요하면) 생성하고 (summary dict, 상태) 를 돌려준다.
상태: done | skipped | failed | dry | pending(file 백엔드에서 응답 대기)
"""
existing = loads(p["summary_json"]) or {}
if p["summary_status"] == "done" and existing.get("prompt_version") == PROMPT_VERSION:
return existing, "skipped"
ctx = _program_context(con, p, lines_by_inc)
st = ctx["structure"]
prompt = PROGRAM_PROMPT.format(
program=p["name"], title=p["title_ko"] or "-", devclass=p["devclass"] or "-", pkg_text=p["pkg_text"] or "",
selection=ctx["selection"], text_symbols=ctx["text_symbols"],
main_flow="\n".join(st["main_flow"]) or "-", tables_read=_j(st["tables_read"][:40]),
tables_write=_j(st["tables_write"][:40]), external_calls=_j(st["external_calls"][:40]),
output_type=_j(st["output_type"]), unit_list=ctx["unit_list"], event_code=ctx["event_code"],
)
if dry_run:
return existing, "dry"
try:
raw = llm.complete_json(SYSTEM_PROMPT, prompt)
except PendingResponse:
# file 백엔드 — 프롬프트만 내놓고 응답을 기다린다. 실패로 기록하지 않는다.
return existing, "pending"
try:
raw["program"] = p["name"]
summary = ProgramSummary.model_validate(raw)
summary.tables_read = st["tables_read"]
summary.tables_write = st["tables_write"]
summary.external_calls = st["external_calls"]
summary.output_type = summary.output_type or st["output_type"]
summary.title_ko = summary.title_ko or p["title_ko"] or ""
summary.prompt_version = PROMPT_VERSION
con.execute("UPDATE program SET summary_json=?, summary_status='done' WHERE name=?",
(summary.model_dump_json(), p["name"]))
con.commit()
return summary.model_dump(), "done"
except Exception as e: # noqa: BLE001
con.execute("UPDATE program SET summary_status='failed' WHERE name=?", (p["name"],))
con.commit()
print(f"[FAIL] program summary {p['name']}: {type(e).__name__}: {e}")
return existing, "failed"
# ------------------------------------------------------------- unit 조각 추출
def _windows(u: sqlite3.Row) -> list[tuple[int, int]]:
if u["loc"] <= MAX_WINDOW_LINES:
return [(u["line_start"], u["line_end"])]
subs = loads(u["sub_chunks_json"]) or []
wins = [(s["line_start"], s["line_end"]) for s in subs if s["line_end"] >= s["line_start"]]
if not wins:
wins = [(a, min(a + FALLBACK_WINDOW - 1, u["line_end"]))
for a in range(u["line_start"], u["line_end"] + 1, FALLBACK_WINDOW)]
return wins
def _known_symbols(con: sqlite3.Connection, program: str, unit_id: str) -> set[str]:
"""조각의 테이블·호출을 파서로 다시 뽑을 때 쓰는 '심볼이라 DB 테이블이 아니다' 집합.
`TABLES:`/`NODES:` 로 선언된 DDIC 작업영역은 제외한다 — 넣으면
`SELECT ... FROM zfit0060` 이 심볼로 오인돼 조각의 tables_read 가 비어버린다
(parser/run.py 의 같은 가드와 기준을 맞춘다).
"""
return {r["name"] for r in con.execute(
"SELECT name FROM symbol WHERE program=? AND (scope='global' OR unit_id=?) "
"AND kind NOT IN ('tables','nodes')", (program, unit_id))}
def _store_chunks(con: sqlite3.Connection, u: sqlite3.Row, chunks: list[ResolvedChunk],
text_symbols: dict[str, str] | None = None) -> None:
for r in con.execute("SELECT chunk_id FROM logic_chunk WHERE unit_id=?", (u["unit_id"],)).fetchall():
con.execute("DELETE FROM chunk_fts WHERE chunk_id=?", (r["chunk_id"],))
con.execute("DELETE FROM logic_chunk WHERE unit_id=?", (u["unit_id"],))
now = _now()
for c in chunks:
chunk_id = f"{u['unit_id']}#C{c.seq}"
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(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(chunk_id, u["program"], u["include"], u["unit_id"], c.seq, c.line_start, c.line_end, c.code_hash,
c.kind, c.purpose_ko, c.purpose_en, _j(c.keywords_ko), _j(c.keywords_en), _j(c.sap_objects),
_j(c.tables_read), _j(c.tables_write), _j(c.calls), c.confidence, PROMPT_VERSION, now),
)
# 조각 코드에 쓰인 TEXT-nnn 의 한국어 원문을 색인에 넣는다 (수정사항 3번) —
# LLM 설명과 무관하게 화면 문구로도 조각에 도달할 수 있어야 한다.
ts_phrases = text_symbol_phrases(c.code, text_symbols or {})
keywords = " ".join(c.keywords_ko + c.keywords_en + [c.kind] + ts_phrases)
objects = " ".join(c.sap_objects + c.tables_read + c.tables_write + c.calls)
con.execute(
"INSERT INTO chunk_fts(chunk_id, program, purpose, keywords, objects, bigrams) VALUES(?,?,?,?,?,?)",
(chunk_id, u["program"], f"{c.purpose_ko} {c.purpose_en}".strip(), keywords, objects,
bigrams(f"{c.purpose_ko} {' '.join(c.keywords_ko)} {' '.join(ts_phrases)}")),
)
def build_unit_prompts(con: sqlite3.Connection, u: sqlite3.Row, program_summary: dict,
title: str, lines: list[str], text_symbols: dict[str, str]) -> list[dict]:
"""unit 의 창별 프롬프트를 만든다 (DB 읽기 — 메인 스레드 전용).
LLM 호출을 병렬화하려면 '프롬프트 조립(DB) → 호출(병렬) → 저장(DB)' 으로 갈라야 한다.
sqlite 커넥션은 스레드 간 공유가 안 되기 때문이다.
"""
windows = _windows(u)
out: list[dict] = []
for wi, (ws, we) in enumerate(windows, 1):
code = numbered_code(lines, ws, we)
used_ts = {m.group(1).upper() for m in _TEXT_SYM.finditer(code)}
ts_lines = [f"TEXT-{k} = {text_symbols[k]}" for k in sorted(used_ts) if k in text_symbols]
prompt = UNIT_PROMPT.format(
program=u["program"], title=title or "-",
program_purpose=program_summary.get("business_purpose_ko") or "(요약 없음 — 구조 사실만 참고)",
main_flow="".join(program_summary.get("main_flow") or [])[:600] or "-",
key_tables=", ".join(f"{t.get('name')}({t.get('desc_ko', '')})"
for t in (program_summary.get("key_internal_tables") or [])[:10]) or "-",
unit_id=u["unit_id"], unit_type=u["unit_type"], name=u["name"], signature=u["signature"] or "-",
header_comment=clean_comment(u["header_comment"]) or "-",
window_note=(f"[창] 이 단위는 길어서 {len(windows)}개 창으로 나눠 보여준다. 지금은 {wi}번째 창 "
f"(L{ws}-L{we}). 이 창 안의 줄만 조각으로 만들라." if len(windows) > 1 else ""),
hints="\n".join(parser_hints(lines, ws, we)) or "-",
text_symbols="\n".join(ts_lines) or "-", code=code, kinds=", ".join(CHUNK_KINDS),
)
out.append({"window": (ws, we), "prompt": prompt})
return out
def finish_unit(con: sqlite3.Connection, u: sqlite3.Row, calls: list[dict], lines: list[str],
text_symbols: dict[str, str]) -> dict:
"""창별 LLM 응답 → 검증·저장 (DB 쓰기 — 메인 스레드 전용).
calls 원소: {window, prompt, raw?, error?, pending?}
한 창이라도 pending 이면 아무것도 저장하지 않는다 (부분 저장 방지).
"""
if any(c.get("pending") for c in calls):
return {"status": "pending", "chunks": 0, "dropped": 0}
errors = [c["error"] for c in calls if c.get("error")]
if errors:
raise errors[0]
known = _known_symbols(con, u["program"], u["unit_id"])
all_chunks: list[ResolvedChunk] = []
dropped_all: list[str] = []
unit_purpose = ""
for c in calls:
ws, we = c["window"]
ext = UnitExtraction.model_validate(c["raw"])
unit_purpose = unit_purpose or ext.unit_purpose_ko.strip()
chunks, dropped = resolve_chunks(ext.chunks, lines, ws, we, u["include"], known)
all_chunks.extend(chunks)
dropped_all.extend(dropped)
all_chunks.sort(key=lambda c: (c.line_start, c.line_end))
for i, c in enumerate(all_chunks, 1):
c.seq = i
_store_chunks(con, u, all_chunks, text_symbols)
covered = covered_lines(all_chunks)
thin = {
"purpose_ko": unit_purpose or clean_comment(u["header_comment"]),
"chunk_count": len(all_chunks), "covered_lines": covered,
"coverage": round(covered / u["loc"], 3) if u["loc"] else 0.0,
"dropped": dropped_all[:10],
}
_mark_unit_done(con, u, thin, len(all_chunks))
con.commit()
return {"status": "done", "chunks": len(all_chunks), "dropped": len(dropped_all)}
def _mark_unit_done(con: sqlite3.Connection, u: sqlite3.Row, thin: dict, n_chunks: int) -> None:
con.execute(
"UPDATE unit SET summary_json=?, summary_status='done', prompt_version=?, chunk_count=?, "
"summary_error=NULL WHERE unit_id=?",
(_j(thin), PROMPT_VERSION, n_chunks, u["unit_id"]),
)
text = " ".join(filter(None, [u["name"], u["signature"] or "", thin["purpose_ko"]]))
con.execute("UPDATE unit_fts SET purpose=?, bigrams=? WHERE unit_id=?",
(thin["purpose_ko"], bigrams(text), u["unit_id"]))
def clone_unit_chunks(con: sqlite3.Connection, rep: sqlite3.Row, target: sqlite3.Row) -> int:
"""code_hash 가 같은 unit 으로 조각을 복제한다 (수정사항 6번).
_BAK / _COPY 관행 때문에 같은 코드가 여러 프로그램에 그대로 들어 있다. 대표 unit 1건만
LLM 에 보내고 나머지는 여기서 만든다 (실측 샘플: 중복 그룹 108개, 호출 179회 절감).
코드는 같아도 include 안에서의 **줄 위치는 다르다** — line_start 차이만큼 평행이동한다.
"""
shift = target["line_start"] - rep["line_start"]
src = con.execute("SELECT * FROM logic_chunk WHERE unit_id=? ORDER BY seq", (rep["unit_id"],)).fetchall()
for r in con.execute("SELECT chunk_id FROM logic_chunk WHERE unit_id=?", (target["unit_id"],)).fetchall():
con.execute("DELETE FROM chunk_fts WHERE chunk_id=?", (r["chunk_id"],))
con.execute("DELETE FROM logic_chunk WHERE unit_id=?", (target["unit_id"],))
for r in src:
chunk_id = f"{target['unit_id']}#C{r['seq']}"
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(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(chunk_id, target["program"], target["include"], target["unit_id"], r["seq"],
r["line_start"] + shift, r["line_end"] + shift, r["code_hash"],
r["kind"], r["purpose_ko"], r["purpose_en"], r["keywords_ko"], r["keywords_en"],
r["sap_objects"], r["tables_read"], r["tables_write"], r["calls"],
r["confidence"], PROMPT_VERSION, _now()),
)
f = con.execute("SELECT purpose, keywords, objects, bigrams FROM chunk_fts WHERE chunk_id=?",
(r["chunk_id"],)).fetchone()
if f:
con.execute(
"INSERT INTO chunk_fts(chunk_id, program, purpose, keywords, objects, bigrams) "
"VALUES(?,?,?,?,?,?)",
(chunk_id, target["program"], f["purpose"], f["keywords"], f["objects"], f["bigrams"]),
)
thin = dict(loads(rep["summary_json"]) or {})
thin["cloned_from"] = rep["unit_id"]
_mark_unit_done(con, target, thin, len(src))
return len(src)
# ------------------------------------------------------------------ 배치 진입점
def _model_label(fake: bool, backend: str | None) -> str:
"""llm_usage_log·대시보드에 남길 '무엇이 요약을 만들었나' 라벨.
환경변수의 LLM_MODEL 을 그대로 쓰면 안 된다 — file 백엔드로 사람·에이전트가 채운 결과를
쓰지도 않은 모델 이름으로 기록하게 된다(실측: file 백엔드 실행이 'z-ai/glm-5.2:free' 로 남았다).
"""
name = backend or ("fake" if fake else "api")
if name == "fake":
return "fake"
if name == "file":
return "file(사람·에이전트 응답)"
return settings.llm_model
def _llm_call(llm, system: str, prompt: str) -> dict:
"""스레드에서 도는 부분 — DB 를 건드리지 않는다. 예외는 값으로 돌려준다."""
try:
return {"raw": llm.complete_json(system, prompt)}
except PendingResponse as e:
return {"pending": True, "job_id": e.job_id}
except Exception as e: # noqa: BLE001 — 호출 실패는 unit 단위로 기록하고 계속
return {"error": e}
def _run_calls_parallel(llm, calls: list[dict], concurrency: int) -> None:
"""calls 각 원소의 'prompt' 를 호출하고 결과를 그 자리에 채운다 (in-place)."""
if not calls:
return
if concurrency <= 1 or len(calls) == 1:
for c in calls:
c.update(_llm_call(llm, SYSTEM_PROMPT, c["prompt"]))
return
with ThreadPoolExecutor(max_workers=min(concurrency, len(calls))) as ex:
futures = {ex.submit(_llm_call, llm, SYSTEM_PROMPT, c["prompt"]): c for c in calls}
for fut in as_completed(futures):
futures[fut].update(fut.result())
def _dedupe_plan(con: sqlite3.Connection, pending: list[sqlite3.Row]) -> tuple[list[sqlite3.Row], dict]:
"""(LLM 에 보낼 대표 unit 목록, {대표 unit_id: [복제 대상 unit...]}) (수정사항 6번)
이미 done 이고 조각이 있는 unit 과 code_hash 가 같으면 LLM 호출 없이 바로 복제한다.
**배치 전체를 한 번에 넘겨야 한다.** 프로그램별로 나눠 호출하면 공용 인클루드
(ZFICOM/ZFIALV 처럼 17개 프로그램에 그대로 복사된 코드)가 서로 다른 호출에 흩어져
중복이 잡히지 않는다 — 실측에서 절감 0회가 나왔다. 프로그램 안의 중복은 드물고
절감분은 거의 전부 프로그램을 가로지르는 중복이다.
"""
reps: list[sqlite3.Row] = []
followers: dict[str, list[sqlite3.Row]] = {}
rep_by_hash: dict[str, sqlite3.Row] = {}
for u in pending:
h = u["code_hash"]
if not h:
reps.append(u)
continue
if h in rep_by_hash:
followers.setdefault(rep_by_hash[h]["unit_id"], []).append(u)
continue
# 이번 배치 밖에서 이미 추출된 동일 코드 unit 이 있으면 그걸 대표로 쓴다 (호출 0회)
done = con.execute(
"SELECT * FROM unit WHERE code_hash=? AND unit_id!=? AND summary_status='done' "
"AND prompt_version=? AND chunk_count>0 LIMIT 1",
(h, u["unit_id"], PROMPT_VERSION),
).fetchone()
if done:
followers.setdefault(done["unit_id"], []).append(u)
rep_by_hash[h] = done
continue
rep_by_hash[h] = u
reps.append(u)
return reps, followers
def summarize_units(program: str | None, limit: int | None, fake: bool = False, dry_run: bool = False,
trigger: str = "manual", backend: str | None = None,
concurrency: int | None = None, dedupe: bool = True) -> dict:
"""프로그램(들)의 요약 + unit 조각 추출을 실행한다. 이름은 API 호환을 위해 유지."""
con = connect()
llm = create_llm(fake=fake, backend=backend)
conc = max(1, concurrency or settings.llm_concurrency)
stats = {"programs": 0, "program_summaries": 0, "done": 0, "skipped": 0, "failed": 0,
"cloned": 0, "awaiting_response": 0, "chunks": 0, "dropped": 0, "llm_calls_saved": 0,
"elapsed_s": 0}
t0 = time.time()
run_id: int | None = None
try:
sql = ("SELECT p.*, COALESCE(k.text_ko,'') AS pkg_text FROM program p "
"LEFT JOIN package k ON k.devclass=p.devclass WHERE p.has_source=1")
params: list = []
if program:
sql += " AND p.name=?"
params.append(program.upper())
programs = con.execute(sql + " ORDER BY p.name", params).fetchall()
# 대상 unit 수집 (leaf 부터 — topo 역순)
pending: list[sqlite3.Row] = []
for p in programs:
rows = con.execute(
"SELECT u.*, t.ord FROM unit u LEFT JOIN topo t ON t.unit_id=u.unit_id "
"WHERE u.program=? AND u.unit_type IN ('FORM','METHOD','FUNCTION','MODULE','EVENT') "
"ORDER BY COALESCE(t.ord, 9999) DESC", (p["name"],)).fetchall()
for u in rows:
if u["summary_status"] == "done" and u["prompt_version"] == PROMPT_VERSION:
stats["skipped"] += 1
else:
pending.append(u)
if limit:
pending = pending[:limit]
# 주의: "pending" = 이번 실행의 대상 unit 수(기존 의미, API/대시보드가 사용).
# file 백엔드의 '응답 대기' 는 별 키인 "awaiting_response" 다.
stats["pending"] = len(pending)
# 중복 제거는 **배치 전체**를 대상으로 한 번만 세운다 (프로그램별로 나누면 공용
# 인클루드의 교차 중복이 잡히지 않는다). LLM 은 대표 unit 만 보고, 나머지는 복제한다.
reps, followers = _dedupe_plan(con, pending) if dedupe else (pending, {})
stats["llm_calls_saved"] = sum(len(v) for v in followers.values())
pending_by_prog: dict[str, list[sqlite3.Row]] = {}
for u in reps:
pending_by_prog.setdefault(u["program"], []).append(u)
needs_summary = {p["name"] for p in programs if not (
p["summary_status"] == "done" and (loads(p["summary_json"]) or {}).get("prompt_version") == PROMPT_VERSION)}
if (pending or needs_summary) and not dry_run:
cur = con.execute(
"INSERT INTO llm_usage_log(ts, program, model, trigger_by, status, total, "
"calls, prompt_tokens, completion_tokens, cost_usd, done, failed, skipped, elapsed_s, chunks) "
"VALUES(datetime('now','localtime'),?,?,?,'running',?,0,0,0,0.0,0,0,?,0,0)",
(program or "*", _model_label(fake, backend), trigger, len(pending), stats["skipped"]),
)
run_id = cur.lastrowid
con.commit()
for p in programs:
units = pending_by_prog.get(p["name"], [])
if not units and p["name"] not in needs_summary:
continue
stats["programs"] += 1
lines_by_inc = _include_lines(con, p["name"])
summary, st = ensure_program_summary(con, llm, p, lines_by_inc, dry_run)
if st == "done":
stats["program_summaries"] += 1
elif st == "pending":
# 프로그램 요약은 unit 프롬프트의 **문맥**이다 (2단계 설계). 요약이 아직 없는데
# unit 프롬프트를 내놓으면 "(요약 없음)" 으로 만들어진 프롬프트를 받게 되고,
# 요약이 채워진 다음 패스에서 프롬프트 내용이 달라져 전부 다시 답해야 한다.
# 그래서 요약이 대기 중이면 이 프로그램의 unit 은 이번 패스에서 건너뛴다.
stats["awaiting_response"] += 1
stats["blocked_units"] = stats.get("blocked_units", 0) + len(units)
continue
text_symbols = _text_symbols(p)
if dry_run:
continue
# 1) 프롬프트 조립 (DB 읽기, 메인 스레드) — units 는 이미 대표 unit 만 들어 있다
jobs: list[tuple[sqlite3.Row, list[dict]]] = []
for u in units:
prompts = build_unit_prompts(con, u, summary, p["title_ko"] or "",
lines_by_inc.get(u["include"], []), text_symbols)
jobs.append((u, [dict(x) for x in prompts]))
# 2) LLM 호출 (병렬, DB 접근 없음) — 수정사항 10번
_run_calls_parallel(llm, [c for _, calls in jobs for c in calls], conc)
# 3) 검증·저장 (DB 쓰기, 메인 스레드)
for u, calls in jobs:
lines = lines_by_inc.get(u["include"], [])
try:
r = finish_unit(con, u, calls, lines, text_symbols)
except Exception as e: # noqa: BLE001
err = f"{type(e).__name__}: {e}"[:500]
con.execute("UPDATE unit SET summary_status='failed', summary_error=? WHERE unit_id=?",
(err, u["unit_id"]))
con.commit()
stats["failed"] += 1
print(f"[FAIL] {u['unit_id']}: {err}")
continue
if r["status"] == "pending":
stats["awaiting_response"] += 1
continue
stats["done"] += 1
stats["chunks"] += r["chunks"]
stats["dropped"] += r["dropped"]
con.commit()
if run_id is not None:
_update_run(con, run_id, stats, llm, t0, status="running")
# ---- 복제 패스: code_hash 가 같은 나머지 unit 에 조각을 복제한다 (LLM 호출 없음) ----
# 대표와 복제 대상이 서로 다른 프로그램일 수 있어 프로그램 루프가 끝난 뒤에 돈다.
touched_programs = {p["name"] for p in programs if pending_by_prog.get(p["name"])}
if not dry_run:
for rep_id, targets in followers.items():
rep_row = con.execute("SELECT * FROM unit WHERE unit_id=?", (rep_id,)).fetchone()
if not rep_row or rep_row["summary_status"] != "done":
continue # 대표가 아직 안 끝났다(응답 대기·실패) — 다음 실행에서 복제된다
for tgt in targets:
stats["chunks"] += clone_unit_chunks(con, rep_row, tgt)
stats["cloned"] += 1
touched_programs.add(tgt["program"])
con.commit()
# 요약·조각이 생긴 프로그램의 색인을 다시 만든다 (수정사항 1번) — 단건 경로
for name in sorted(touched_programs):
refresh_program_fts(con, name)
con.commit()
finally:
if run_id is not None:
stats["elapsed_s"] = round(time.time() - t0, 1)
_update_run(con, run_id, stats, llm, t0, status="done")
con.close()
if hasattr(llm, "usage"):
stats["llm_usage"] = llm.usage
stats["elapsed_s"] = round(time.time() - t0, 1)
return stats
def _update_run(con, run_id: int, stats: dict, llm, t0: float, status: str) -> None:
u = getattr(llm, "usage", None) or {}
con.execute(
"UPDATE llm_usage_log SET status=?, calls=?, prompt_tokens=?, completion_tokens=?, "
"cost_usd=?, done=?, failed=?, skipped=?, elapsed_s=?, chunks=? WHERE id=?",
(status, u.get("calls", 0), u.get("prompt_tokens", 0), u.get("completion_tokens", 0),
u.get("cost_usd", 0.0), stats["done"], stats["failed"], stats["skipped"],
round(time.time() - t0, 1), stats["chunks"], run_id),
)
con.commit()
def main() -> None:
ap = argparse.ArgumentParser(
description="Stage 3 — 프로그램 요약 + 로직 조각 추출",
epilog="LLM 키가 없으면: --llm file 로 프롬프트를 파일로 내놓고 "
"python -m summarize.jobs 로 응답을 채운 뒤 같은 명령을 다시 실행한다.",
)
ap.add_argument("--program", default=None)
ap.add_argument("--limit", type=int, default=None, help="처리할 unit 수 상한")
ap.add_argument("--llm", choices=["api", "file", "fake"], default=None,
help="api=환경변수 키로 호출 / file=프롬프트 파일 큐(키 불필요) / fake=더미")
ap.add_argument("--fake", action="store_true", help="--llm fake 의 하위호환 별칭")
ap.add_argument("--concurrency", type=int, default=None,
help=f"동시 LLM 호출 수 (기본 LLM_CONCURRENCY={settings.llm_concurrency})")
ap.add_argument("--no-dedupe", action="store_true",
help="code_hash 가 같은 unit 도 각각 LLM 에 보낸다 (기본은 대표 1건만)")
ap.add_argument("--dry-run", action="store_true")
args = ap.parse_args()
stats = summarize_units(
args.program, args.limit, fake=args.fake, dry_run=args.dry_run,
backend=args.llm, concurrency=args.concurrency, dedupe=not args.no_dedupe,
)
print(json.dumps(stats, ensure_ascii=False))
if stats.get("pending"):
print(f"\n응답 대기 {stats['awaiting_response']}건 — 프롬프트: {settings.data_llm_jobs}\n"
f" python -m summarize.jobs list --pending\n"
f" python -m summarize.jobs show <job_id>\n"
f" python -m summarize.jobs answer <job_id> --file <응답.json>\n"
f" → 응답을 채운 뒤 같은 명령을 다시 실행하면 적재된다.")
if stats.get("blocked_units"):
print(f"\nunit {stats['blocked_units']}건은 프로그램 요약을 문맥으로 쓰므로 대기 중이다 — "
f"먼저 program_summary 작업에 답하고 다시 실행하면 unit 프롬프트가 나온다.")
if __name__ == "__main__":
main()
+78
View File
@@ -0,0 +1,78 @@
"""Stage 3 — LLM 출력 스키마 (docs/logic-chunk-design.md). pydantic 검증.
- ProgramSummary : 프로그램 1 요약 (unit 추출의 문맥으로도 쓰임)
- UnitExtraction : unit 하나에서 LLM 골라낸 로직 조각 목록 + unit 요약
- LogicChunk : 로직 조각 하나 인덱스의 1 단위
"""
from __future__ import annotations
from pydantic import BaseModel, Field
CHUNK_KINDS = (
"sql_select", # DB 조회 (SELECT / OPEN CURSOR)
"db_write", # DB 갱신 (INSERT / UPDATE / MODIFY / DELETE / COMMIT)
"fm_call", # BAPI · 펑션모듈 · RFC · 메서드 호출로 업무 처리
"aggregation", # 내부테이블 집계 · 가공 · 병합 (LOOP / COLLECT / SORT …)
"validation", # 입력 검증 · 권한 체크 · 존재 확인
"calculation", # 금액 · 수량 · 환율 · 날짜 계산
"output", # ALV · 리스트 · 화면 · 파일 · 메일 출력
"interface", # 외부 시스템 송수신 (파일 · IDoc · HTTP · RFC destination)
"control_flow", # 처리 흐름 분기 · 하위 로직 호출 순서
"other",
)
class LogicChunk(BaseModel):
"""LLM 이 반환하는 조각. line_* 는 include 기준 줄 번호(코드에 붙여 준 번호 그대로)."""
line_start: int
line_end: int
first_line: str = "" # line_start 줄의 코드 원문 — 줄 번호 검증용 앵커
kind: str = "other"
purpose_ko: str
purpose_en: str = ""
keywords_ko: list[str] = Field(default_factory=list)
keywords_en: list[str] = Field(default_factory=list)
sap_objects: list[str] = Field(default_factory=list) # 테이블 · FM · BAPI · 클래스 · T-Code
confidence: float = 0.5
class UnitExtraction(BaseModel):
unit_purpose_ko: str = "" # unit 한 줄 요약 — 얇은 색인용 (조각이 없어도 채운다)
chunks: list[LogicChunk] = Field(default_factory=list)
unclear: list[str] = Field(default_factory=list)
class SelectionParam(BaseModel):
name: str
desc_ko: str = ""
class KeyInternalTable(BaseModel):
name: str
filled_by: list[str] = Field(default_factory=list)
consumed_by: list[str] = Field(default_factory=list)
desc_ko: str = ""
class ProgramSummary(BaseModel):
program: str
title_ko: str = ""
business_purpose_ko: str = ""
business_purpose_en: str = ""
main_flow: list[str] = Field(default_factory=list)
selection_screen: list[SelectionParam] = Field(default_factory=list)
key_internal_tables: list[KeyInternalTable] = Field(default_factory=list)
tables_read: list[str] = Field(default_factory=list) # 파서 값으로 덮어씀
tables_write: list[str] = Field(default_factory=list) # 파서 값으로 덮어씀
external_calls: list[str] = Field(default_factory=list) # 파서 값으로 덮어씀
output_type: list[str] = Field(default_factory=list)
business_tags: list[str] = Field(default_factory=list)
proposed_tags: list[str] = Field(default_factory=list)
sap_module: str = ""
keywords_ko: list[str] = Field(default_factory=list)
keywords_en: list[str] = Field(default_factory=list)
related_tcodes: list[str] = Field(default_factory=list)
notes: list[str] = Field(default_factory=list)
confidence: float = 0.5
unclear: list[str] = Field(default_factory=list)
prompt_version: int = 0
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)
+17
View File
@@ -0,0 +1,17 @@
---
type: concept
title: "계정과목"
description: "계정과목 — 도메인 개념 (질의 확장용)"
tags: ["계정", "RACCT", "SAKNR", "SKA1", "SKAT"]
generated: { by: "abap-indexing/wiki_out", at: "2026-09-20T15:36:23+00:00" }
---
## 관련 용어
이 목록은 검색 질의 확장에도 쓰인다 — `/search/logic?q=` 에 아래 어느 말로 물어도 걸린다.
- 계정
- RACCT
- SAKNR
- SKA1
- SKAT
+16
View File
@@ -0,0 +1,16 @@
---
type: concept
title: "공급업체"
description: "공급업체 — 도메인 개념 (질의 확장용)"
tags: ["벤더", "LIFNR", "LFA1", "XK03"]
generated: { by: "abap-indexing/wiki_out", at: "2026-09-20T15:36:23+00:00" }
---
## 관련 용어
이 목록은 검색 질의 확장에도 쓰인다 — `/search/logic?q=` 에 아래 어느 말로 물어도 걸린다.
- 벤더
- LIFNR
- LFA1
- XK03
+18
View File
@@ -0,0 +1,18 @@
---
type: concept
title: "구매오더"
description: "구매오더 — 도메인 개념 (질의 확장용)"
tags: ["PO", "Purchase Order", "EBELN", "EKKO", "EKPO", "ME23N"]
generated: { by: "abap-indexing/wiki_out", at: "2026-09-20T15:36:23+00:00" }
---
## 관련 용어
이 목록은 검색 질의 확장에도 쓰인다 — `/search/logic?q=` 에 아래 어느 말로 물어도 걸린다.
- PO
- Purchase Order
- EBELN
- EKKO
- EKPO
- ME23N
+17
View File
@@ -0,0 +1,17 @@
---
type: concept
title: "구매요청"
description: "구매요청 — 도메인 개념 (질의 확장용)"
tags: ["PR", "Purchase Requisition", "BANFN", "EBAN", "ME53N"]
generated: { by: "abap-indexing/wiki_out", at: "2026-09-20T15:36:23+00:00" }
---
## 관련 용어
이 목록은 검색 질의 확장에도 쓰인다 — `/search/logic?q=` 에 아래 어느 말로 물어도 걸린다.
- PR
- Purchase Requisition
- BANFN
- EBAN
- ME53N
+14
View File
@@ -0,0 +1,14 @@
---
type: concept
title: "기능영역"
description: "기능영역 — 도메인 개념 (질의 확장용)"
tags: ["FAREA", "RFAREA"]
generated: { by: "abap-indexing/wiki_out", at: "2026-09-20T15:36:23+00:00" }
---
## 관련 용어
이 목록은 검색 질의 확장에도 쓰인다 — `/search/logic?q=` 에 아래 어느 말로 물어도 걸린다.
- FAREA
- RFAREA
+18
View File
@@ -0,0 +1,18 @@
---
type: concept
title: "반제"
description: "반제 — 도메인 개념 (질의 확장용)"
tags: ["클리어링", "미결정리", "BSIS", "BSAS", "F-03", "F.13"]
generated: { by: "abap-indexing/wiki_out", at: "2026-09-20T15:36:23+00:00" }
---
## 관련 용어
이 목록은 검색 질의 확장에도 쓰인다 — `/search/logic?q=` 에 아래 어느 말로 물어도 걸린다.
- 클리어링
- 미결정리
- BSIS
- BSAS
- F-03
- F.13
+14
View File
@@ -0,0 +1,14 @@
---
type: concept
title: "사업영역"
description: "사업영역 — 도메인 개념 (질의 확장용)"
tags: ["BUSA", "GSBER"]
generated: { by: "abap-indexing/wiki_out", at: "2026-09-20T15:36:23+00:00" }
---
## 관련 용어
이 목록은 검색 질의 확장에도 쓰인다 — `/search/logic?q=` 에 아래 어느 말로 물어도 걸린다.
- BUSA
- GSBER
+15
View File
@@ -0,0 +1,15 @@
---
type: concept
title: "손익"
description: "손익 — 도메인 개념 (질의 확장용)"
tags: ["P/L", "손익계산서", "GVTYP"]
generated: { by: "abap-indexing/wiki_out", at: "2026-09-20T15:36:23+00:00" }
---
## 관련 용어
이 목록은 검색 질의 확장에도 쓰인다 — `/search/logic?q=` 에 아래 어느 말로 물어도 걸린다.
- P/L
- 손익계산서
- GVTYP
+17
View File
@@ -0,0 +1,17 @@
---
type: concept
title: "송장"
description: "송장 — 도메인 개념 (질의 확장용)"
tags: ["인보이스", "Invoice", "RBKP", "RSEG", "MIRO"]
generated: { by: "abap-indexing/wiki_out", at: "2026-09-20T15:36:23+00:00" }
---
## 관련 용어
이 목록은 검색 질의 확장에도 쓰인다 — `/search/logic?q=` 에 아래 어느 말로 물어도 걸린다.
- 인보이스
- Invoice
- RBKP
- RSEG
- MIRO
+15
View File
@@ -0,0 +1,15 @@
---
type: concept
title: "이동유형"
description: "이동유형 — 도메인 개념 (질의 확장용)"
tags: ["BWART", "이동타입", "movement type"]
generated: { by: "abap-indexing/wiki_out", at: "2026-09-20T15:36:23+00:00" }
---
## 관련 용어
이 목록은 검색 질의 확장에도 쓰인다 — `/search/logic?q=` 에 아래 어느 말로 물어도 걸린다.
- BWART
- 이동타입
- movement type
+19
View File
@@ -0,0 +1,19 @@
---
type: concept
title: "입고"
description: "입고 — 도메인 개념 (질의 확장용)"
tags: ["GR", "Goods Receipt", "101", "MSEG", "MKPF", "MB51", "MIGO"]
generated: { by: "abap-indexing/wiki_out", at: "2026-09-20T15:36:23+00:00" }
---
## 관련 용어
이 목록은 검색 질의 확장에도 쓰인다 — `/search/logic?q=` 에 아래 어느 말로 물어도 걸린다.
- GR
- Goods Receipt
- 101
- MSEG
- MKPF
- MB51
- MIGO
+18
View File
@@ -0,0 +1,18 @@
---
type: concept
title: "자재"
description: "자재 — 도메인 개념 (질의 확장용)"
tags: ["품목", "자재마스터", "MATNR", "MARA", "MAKT", "MM03"]
generated: { by: "abap-indexing/wiki_out", at: "2026-09-20T15:36:23+00:00" }
---
## 관련 용어
이 목록은 검색 질의 확장에도 쓰인다 — `/search/logic?q=` 에 아래 어느 말로 물어도 걸린다.
- 품목
- 자재마스터
- MATNR
- MARA
- MAKT
- MM03
+17
View File
@@ -0,0 +1,17 @@
---
type: concept
title: "자재문서"
description: "자재문서 — 도메인 개념 (질의 확장용)"
tags: ["물자문서", "MBLNR", "MKPF", "MSEG", "MB51"]
generated: { by: "abap-indexing/wiki_out", at: "2026-09-20T15:36:23+00:00" }
---
## 관련 용어
이 목록은 검색 질의 확장에도 쓰인다 — `/search/logic?q=` 에 아래 어느 말로 물어도 걸린다.
- 물자문서
- MBLNR
- MKPF
- MSEG
- MB51
+16
View File
@@ -0,0 +1,16 @@
---
type: concept
title: "잔액"
description: "잔액 — 도메인 개념 (질의 확장용)"
tags: ["기말잔액", "이월", "HSL", "BALANCE"]
generated: { by: "abap-indexing/wiki_out", at: "2026-09-20T15:36:23+00:00" }
---
## 관련 용어
이 목록은 검색 질의 확장에도 쓰인다 — `/search/logic?q=` 에 아래 어느 말로 물어도 걸린다.
- 기말잔액
- 이월
- HSL
- BALANCE
+17
View File
@@ -0,0 +1,17 @@
---
type: concept
title: "재고"
description: "재고 — 도메인 개념 (질의 확장용)"
tags: ["재고수량", "LABST", "MARD", "MBEW", "MMBE"]
generated: { by: "abap-indexing/wiki_out", at: "2026-09-20T15:36:23+00:00" }
---
## 관련 용어
이 목록은 검색 질의 확장에도 쓰인다 — `/search/logic?q=` 에 아래 어느 말로 물어도 걸린다.
- 재고수량
- LABST
- MARD
- MBEW
- MMBE
+15
View File
@@ -0,0 +1,15 @@
---
type: concept
title: "저장위치"
description: "저장위치 — 도메인 개념 (질의 확장용)"
tags: ["LGORT", "창고", "T001L"]
generated: { by: "abap-indexing/wiki_out", at: "2026-09-20T15:36:23+00:00" }
---
## 관련 용어
이 목록은 검색 질의 확장에도 쓰인다 — `/search/logic?q=` 에 아래 어느 말로 물어도 걸린다.
- LGORT
- 창고
- T001L
+17
View File
@@ -0,0 +1,17 @@
---
type: concept
title: "전표"
description: "전표 — 도메인 개념 (질의 확장용)"
tags: ["회계전표", "BKPF", "BSEG", "ACDOCA", "FB03"]
generated: { by: "abap-indexing/wiki_out", at: "2026-09-20T15:36:23+00:00" }
---
## 관련 용어
이 목록은 검색 질의 확장에도 쓰인다 — `/search/logic?q=` 에 아래 어느 말로 물어도 걸린다.
- 회계전표
- BKPF
- BSEG
- ACDOCA
- FB03
+20
View File
@@ -0,0 +1,20 @@
---
type: concept
title: "제조원가"
description: "제조원가 — 도메인 개념 (질의 확장용)"
tags: ["원가계산", "CO-PC", "오더결산", "COEP", "COSS", "CKMLHD", "KKS1", "KO88"]
generated: { by: "abap-indexing/wiki_out", at: "2026-09-20T15:36:23+00:00" }
---
## 관련 용어
이 목록은 검색 질의 확장에도 쓰인다 — `/search/logic?q=` 에 아래 어느 말로 물어도 걸린다.
- 원가계산
- CO-PC
- 오더결산
- COEP
- COSS
- CKMLHD
- KKS1
- KO88
+20
View File
@@ -0,0 +1,20 @@
---
type: concept
title: "총계정원장"
description: "총계정원장 — 도메인 개념 (질의 확장용)"
tags: ["G/L", "GL", "원장", "ACDOCA", "ACDOCT", "SKAT", "FAGLL03", "FS10N"]
generated: { by: "abap-indexing/wiki_out", at: "2026-09-20T15:36:23+00:00" }
---
## 관련 용어
이 목록은 검색 질의 확장에도 쓰인다 — `/search/logic?q=` 에 아래 어느 말로 물어도 걸린다.
- G/L
- GL
- 원장
- ACDOCA
- ACDOCT
- SKAT
- FAGLL03
- FS10N
+18
View File
@@ -0,0 +1,18 @@
---
type: concept
title: "출고"
description: "출고 — 도메인 개념 (질의 확장용)"
tags: ["GI", "Goods Issue", "201", "261", "MSEG", "MB1A"]
generated: { by: "abap-indexing/wiki_out", at: "2026-09-20T15:36:23+00:00" }
---
## 관련 용어
이 목록은 검색 질의 확장에도 쓰인다 — `/search/logic?q=` 에 아래 어느 말로 물어도 걸린다.
- GI
- Goods Issue
- 201
- 261
- MSEG
- MB1A
+15
View File
@@ -0,0 +1,15 @@
---
type: concept
title: "코스트센터"
description: "코스트센터 — 도메인 개념 (질의 확장용)"
tags: ["CCTR", "CSKS", "KSB1"]
generated: { by: "abap-indexing/wiki_out", at: "2026-09-20T15:36:23+00:00" }
---
## 관련 용어
이 목록은 검색 질의 확장에도 쓰인다 — `/search/logic?q=` 에 아래 어느 말로 물어도 걸린다.
- CCTR
- CSKS
- KSB1
+15
View File
@@ -0,0 +1,15 @@
---
type: concept
title: "플랜트"
description: "플랜트 — 도메인 개념 (질의 확장용)"
tags: ["WERKS", "사업장", "T001W"]
generated: { by: "abap-indexing/wiki_out", at: "2026-09-20T15:36:23+00:00" }
---
## 관련 용어
이 목록은 검색 질의 확장에도 쓰인다 — `/search/logic?q=` 에 아래 어느 말로 물어도 걸린다.
- WERKS
- 사업장
- T001W
+15
View File
@@ -0,0 +1,15 @@
---
type: concept
title: "회계단위"
description: "회계단위 — 도메인 개념 (질의 확장용)"
tags: ["회사코드", "BUKRS", "T001"]
generated: { by: "abap-indexing/wiki_out", at: "2026-09-20T15:36:23+00:00" }
---
## 관련 용어
이 목록은 검색 질의 확장에도 쓰인다 — `/search/logic?q=` 에 아래 어느 말로 물어도 걸린다.
- 회사코드
- BUKRS
- T001
@@ -0,0 +1,109 @@
---
type: abap-function
title: "ALSM_EXCEL_TO_INTERNAL_TABLE"
description: "ALSM_EXCEL_TO_INTERNAL_TABLE — 호출 프로그램 30개"
resource: "abap://function/ALSM_EXCEL_TO_INTERNAL_TABLE"
tags: []
generated: { by: "abap-indexing/wiki_out", at: "2026-09-20T15:36:24+00:00" }
status: stable
x-caller-programs: 30
x-call-sites: 58
---
> 이 펑션모듈 자체의 소스는 수집 대상이 아니다 (ASSUMPTIONS.md §4) — 호출처만 제공한다.
## 호출 프로그램 (30개)
- [ZCOC0010](/programs/ZCOC0010.md)
- [ZCOC0020](/programs/ZCOC0020.md)
- [ZCOC0030](/programs/ZCOC0030.md)
- [ZCOC0040](/programs/ZCOC0040.md)
- [ZCOC0050](/programs/ZCOC0050.md)
- [ZCOC0060](/programs/ZCOC0060.md)
- [ZCOC0070](/programs/ZCOC0070.md)
- [ZCOC0080](/programs/ZCOC0080.md)
- [ZCOC0090](/programs/ZCOC0090.md)
- [ZCOC0100](/programs/ZCOC0100.md)
- [ZCOC0110](/programs/ZCOC0110.md)
- [ZCOC0120](/programs/ZCOC0120.md)
- [ZCOC0130](/programs/ZCOC0130.md)
- [ZCOC1010](/programs/ZCOC1010.md)
- [ZCOC2010](/programs/ZCOC2010.md)
- [ZCOC2020](/programs/ZCOC2020.md)
- [ZCOC2030](/programs/ZCOC2030.md)
- [ZCOC2040](/programs/ZCOC2040.md)
- [ZCOC9010](/programs/ZCOC9010.md)
- [ZCOC9020](/programs/ZCOC9020.md)
- [ZCOC9030](/programs/ZCOC9030.md)
- [ZCOC9040](/programs/ZCOC9040.md)
- [ZCOR0010](/programs/ZCOR0010.md)
- [ZCOR0020](/programs/ZCOR0020.md)
- [ZCOR0030](/programs/ZCOR0030.md)
- [ZCOR0040](/programs/ZCOR0040.md)
- [ZCOR1010](/programs/ZCOR1010.md)
- [ZCOR2010](/programs/ZCOR2010.md)
- [ZFIR0020](/programs/ZFIR0020.md)
- [ZFIR0040](/programs/ZFIR0040.md)
## 호출 지점
| 프로그램 | unit | 종류 | 줄 |
|---|---|---|---|
| [ZCOC0010](/programs/ZCOC0010.md) | UPLOAD_DATA | call_function | 610 |
| [ZCOC0010](/programs/ZCOC0010.md) | UPLOAD_XLS_STRUCT | call_function | 2203 |
| [ZCOC0020](/programs/ZCOC0020.md) | UPLOAD_DATA | call_function | 610 |
| [ZCOC0020](/programs/ZCOC0020.md) | UPLOAD_XLS_STRUCT | call_function | 2203 |
| [ZCOC0030](/programs/ZCOC0030.md) | UPLOAD_DATA | call_function | 610 |
| [ZCOC0030](/programs/ZCOC0030.md) | UPLOAD_XLS_STRUCT | call_function | 2203 |
| [ZCOC0040](/programs/ZCOC0040.md) | UPLOAD_DATA | call_function | 610 |
| [ZCOC0040](/programs/ZCOC0040.md) | UPLOAD_XLS_STRUCT | call_function | 2203 |
| [ZCOC0050](/programs/ZCOC0050.md) | UPLOAD_DATA | call_function | 610 |
| [ZCOC0050](/programs/ZCOC0050.md) | UPLOAD_XLS_STRUCT | call_function | 2203 |
| [ZCOC0060](/programs/ZCOC0060.md) | UPLOAD_DATA | call_function | 610 |
| [ZCOC0060](/programs/ZCOC0060.md) | UPLOAD_XLS_STRUCT | call_function | 2203 |
| [ZCOC0070](/programs/ZCOC0070.md) | UPLOAD_DATA | call_function | 610 |
| [ZCOC0070](/programs/ZCOC0070.md) | UPLOAD_XLS_STRUCT | call_function | 2203 |
| [ZCOC0080](/programs/ZCOC0080.md) | UPLOAD_DATA | call_function | 610 |
| [ZCOC0080](/programs/ZCOC0080.md) | UPLOAD_XLS_STRUCT | call_function | 2203 |
| [ZCOC0090](/programs/ZCOC0090.md) | UPLOAD_DATA | call_function | 610 |
| [ZCOC0090](/programs/ZCOC0090.md) | UPLOAD_XLS_STRUCT | call_function | 2203 |
| [ZCOC0100](/programs/ZCOC0100.md) | UPLOAD_DATA | call_function | 610 |
| [ZCOC0100](/programs/ZCOC0100.md) | UPLOAD_XLS_STRUCT | call_function | 2203 |
| [ZCOC0110](/programs/ZCOC0110.md) | UPLOAD_DATA | call_function | 610 |
| [ZCOC0110](/programs/ZCOC0110.md) | UPLOAD_XLS_STRUCT | call_function | 2203 |
| [ZCOC0120](/programs/ZCOC0120.md) | UPLOAD_DATA | call_function | 610 |
| [ZCOC0120](/programs/ZCOC0120.md) | UPLOAD_XLS_STRUCT | call_function | 2203 |
| [ZCOC0130](/programs/ZCOC0130.md) | UPLOAD_DATA | call_function | 610 |
| [ZCOC0130](/programs/ZCOC0130.md) | UPLOAD_XLS_STRUCT | call_function | 2203 |
| [ZCOC1010](/programs/ZCOC1010.md) | UPLOAD_DATA | call_function | 610 |
| [ZCOC1010](/programs/ZCOC1010.md) | UPLOAD_XLS_STRUCT | call_function | 2203 |
| [ZCOC2010](/programs/ZCOC2010.md) | UPLOAD_DATA | call_function | 610 |
| [ZCOC2010](/programs/ZCOC2010.md) | UPLOAD_XLS_STRUCT | call_function | 2203 |
| [ZCOC2020](/programs/ZCOC2020.md) | UPLOAD_DATA | call_function | 610 |
| [ZCOC2020](/programs/ZCOC2020.md) | UPLOAD_XLS_STRUCT | call_function | 2203 |
| [ZCOC2030](/programs/ZCOC2030.md) | UPLOAD_DATA | call_function | 610 |
| [ZCOC2030](/programs/ZCOC2030.md) | UPLOAD_XLS_STRUCT | call_function | 2203 |
| [ZCOC2040](/programs/ZCOC2040.md) | UPLOAD_DATA | call_function | 610 |
| [ZCOC2040](/programs/ZCOC2040.md) | UPLOAD_XLS_STRUCT | call_function | 2203 |
| [ZCOC9010](/programs/ZCOC9010.md) | UPLOAD_DATA | call_function | 610 |
| [ZCOC9010](/programs/ZCOC9010.md) | UPLOAD_XLS_STRUCT | call_function | 2203 |
| [ZCOC9020](/programs/ZCOC9020.md) | UPLOAD_DATA | call_function | 610 |
| [ZCOC9020](/programs/ZCOC9020.md) | UPLOAD_XLS_STRUCT | call_function | 2203 |
| [ZCOC9030](/programs/ZCOC9030.md) | UPLOAD_DATA | call_function | 610 |
| [ZCOC9030](/programs/ZCOC9030.md) | UPLOAD_XLS_STRUCT | call_function | 2203 |
| [ZCOC9040](/programs/ZCOC9040.md) | UPLOAD_DATA | call_function | 610 |
| [ZCOC9040](/programs/ZCOC9040.md) | UPLOAD_XLS_STRUCT | call_function | 2203 |
| [ZCOR0010](/programs/ZCOR0010.md) | UPLOAD_DATA | call_function | 610 |
| [ZCOR0010](/programs/ZCOR0010.md) | UPLOAD_XLS_STRUCT | call_function | 2203 |
| [ZCOR0020](/programs/ZCOR0020.md) | UPLOAD_DATA | call_function | 610 |
| [ZCOR0020](/programs/ZCOR0020.md) | UPLOAD_XLS_STRUCT | call_function | 2203 |
| [ZCOR0030](/programs/ZCOR0030.md) | UPLOAD_DATA | call_function | 610 |
| [ZCOR0030](/programs/ZCOR0030.md) | UPLOAD_XLS_STRUCT | call_function | 2203 |
| [ZCOR0040](/programs/ZCOR0040.md) | UPLOAD_DATA | call_function | 610 |
| [ZCOR0040](/programs/ZCOR0040.md) | UPLOAD_XLS_STRUCT | call_function | 2203 |
| [ZCOR1010](/programs/ZCOR1010.md) | UPLOAD_DATA | call_function | 610 |
| [ZCOR1010](/programs/ZCOR1010.md) | UPLOAD_XLS_STRUCT | call_function | 2203 |
| [ZCOR2010](/programs/ZCOR2010.md) | UPLOAD_DATA | call_function | 610 |
| [ZCOR2010](/programs/ZCOR2010.md) | UPLOAD_XLS_STRUCT | call_function | 2203 |
| [ZFIR0020](/programs/ZFIR0020.md) | UPLOAD_DATA | call_function | 597 |
| [ZFIR0040](/programs/ZFIR0040.md) | UPLOAD_DATA | call_function | 597 |
+55
View File
@@ -0,0 +1,55 @@
---
type: abap-function
title: "BAL_DSP_LOG_DISPLAY"
description: "BAL_DSP_LOG_DISPLAY — 호출 프로그램 17개"
resource: "abap://function/BAL_DSP_LOG_DISPLAY"
tags: []
generated: { by: "abap-indexing/wiki_out", at: "2026-09-20T15:36:24+00:00" }
status: stable
x-caller-programs: 17
x-call-sites: 17
---
> 이 펑션모듈 자체의 소스는 수집 대상이 아니다 (ASSUMPTIONS.md §4) — 호출처만 제공한다.
## 호출 프로그램 (17개)
- [ZFIC0010](/programs/ZFIC0010.md)
- [ZFIC0020](/programs/ZFIC0020.md)
- [ZFIR0010](/programs/ZFIR0010.md)
- [ZFIR0030](/programs/ZFIR0030.md)
- [ZFIR0031](/programs/ZFIR0031.md)
- [ZFIR0032](/programs/ZFIR0032.md)
- [ZFIR0050](/programs/ZFIR0050.md)
- [ZFIR0060](/programs/ZFIR0060.md)
- [ZFIR0061](/programs/ZFIR0061.md)
- [ZFIR0070](/programs/ZFIR0070.md)
- [ZFIR0071](/programs/ZFIR0071.md)
- [ZFIR0072](/programs/ZFIR0072.md)
- [ZFIR0080](/programs/ZFIR0080.md)
- [ZFIR0090](/programs/ZFIR0090.md)
- [ZFIR0120](/programs/ZFIR0120.md)
- [ZFIR0130](/programs/ZFIR0130.md)
- [ZFIR0170](/programs/ZFIR0170.md)
## 호출 지점
| 프로그램 | unit | 종류 | 줄 |
|---|---|---|---|
| [ZFIC0010](/programs/ZFIC0010.md) | LOG_ALV_DISPLAY | call_function | 852 |
| [ZFIC0020](/programs/ZFIC0020.md) | LOG_ALV_DISPLAY | call_function | 852 |
| [ZFIR0010](/programs/ZFIR0010.md) | LOG_ALV_DISPLAY | call_function | 852 |
| [ZFIR0030](/programs/ZFIR0030.md) | LOG_ALV_DISPLAY | call_function | 852 |
| [ZFIR0031](/programs/ZFIR0031.md) | LOG_ALV_DISPLAY | call_function | 852 |
| [ZFIR0032](/programs/ZFIR0032.md) | LOG_ALV_DISPLAY | call_function | 852 |
| [ZFIR0050](/programs/ZFIR0050.md) | LOG_ALV_DISPLAY | call_function | 852 |
| [ZFIR0060](/programs/ZFIR0060.md) | LOG_ALV_DISPLAY | call_function | 852 |
| [ZFIR0061](/programs/ZFIR0061.md) | LOG_ALV_DISPLAY | call_function | 852 |
| [ZFIR0070](/programs/ZFIR0070.md) | LOG_ALV_DISPLAY | call_function | 852 |
| [ZFIR0071](/programs/ZFIR0071.md) | LOG_ALV_DISPLAY | call_function | 852 |
| [ZFIR0072](/programs/ZFIR0072.md) | LOG_ALV_DISPLAY | call_function | 852 |
| [ZFIR0080](/programs/ZFIR0080.md) | LOG_ALV_DISPLAY | call_function | 852 |
| [ZFIR0090](/programs/ZFIR0090.md) | LOG_ALV_DISPLAY | call_function | 852 |
| [ZFIR0120](/programs/ZFIR0120.md) | LOG_ALV_DISPLAY | call_function | 852 |
| [ZFIR0130](/programs/ZFIR0130.md) | LOG_ALV_DISPLAY | call_function | 852 |
| [ZFIR0170](/programs/ZFIR0170.md) | LOG_ALV_DISPLAY | call_function | 852 |
+55
View File
@@ -0,0 +1,55 @@
---
type: abap-function
title: "BAL_LOG_CREATE"
description: "BAL_LOG_CREATE — 호출 프로그램 17개"
resource: "abap://function/BAL_LOG_CREATE"
tags: []
generated: { by: "abap-indexing/wiki_out", at: "2026-09-20T15:36:24+00:00" }
status: stable
x-caller-programs: 17
x-call-sites: 17
---
> 이 펑션모듈 자체의 소스는 수집 대상이 아니다 (ASSUMPTIONS.md §4) — 호출처만 제공한다.
## 호출 프로그램 (17개)
- [ZFIC0010](/programs/ZFIC0010.md)
- [ZFIC0020](/programs/ZFIC0020.md)
- [ZFIR0010](/programs/ZFIR0010.md)
- [ZFIR0030](/programs/ZFIR0030.md)
- [ZFIR0031](/programs/ZFIR0031.md)
- [ZFIR0032](/programs/ZFIR0032.md)
- [ZFIR0050](/programs/ZFIR0050.md)
- [ZFIR0060](/programs/ZFIR0060.md)
- [ZFIR0061](/programs/ZFIR0061.md)
- [ZFIR0070](/programs/ZFIR0070.md)
- [ZFIR0071](/programs/ZFIR0071.md)
- [ZFIR0072](/programs/ZFIR0072.md)
- [ZFIR0080](/programs/ZFIR0080.md)
- [ZFIR0090](/programs/ZFIR0090.md)
- [ZFIR0120](/programs/ZFIR0120.md)
- [ZFIR0130](/programs/ZFIR0130.md)
- [ZFIR0170](/programs/ZFIR0170.md)
## 호출 지점
| 프로그램 | unit | 종류 | 줄 |
|---|---|---|---|
| [ZFIC0010](/programs/ZFIC0010.md) | LOG_CREATE | call_function | 905 |
| [ZFIC0020](/programs/ZFIC0020.md) | LOG_CREATE | call_function | 905 |
| [ZFIR0010](/programs/ZFIR0010.md) | LOG_CREATE | call_function | 905 |
| [ZFIR0030](/programs/ZFIR0030.md) | LOG_CREATE | call_function | 905 |
| [ZFIR0031](/programs/ZFIR0031.md) | LOG_CREATE | call_function | 905 |
| [ZFIR0032](/programs/ZFIR0032.md) | LOG_CREATE | call_function | 905 |
| [ZFIR0050](/programs/ZFIR0050.md) | LOG_CREATE | call_function | 905 |
| [ZFIR0060](/programs/ZFIR0060.md) | LOG_CREATE | call_function | 905 |
| [ZFIR0061](/programs/ZFIR0061.md) | LOG_CREATE | call_function | 905 |
| [ZFIR0070](/programs/ZFIR0070.md) | LOG_CREATE | call_function | 905 |
| [ZFIR0071](/programs/ZFIR0071.md) | LOG_CREATE | call_function | 905 |
| [ZFIR0072](/programs/ZFIR0072.md) | LOG_CREATE | call_function | 905 |
| [ZFIR0080](/programs/ZFIR0080.md) | LOG_CREATE | call_function | 905 |
| [ZFIR0090](/programs/ZFIR0090.md) | LOG_CREATE | call_function | 905 |
| [ZFIR0120](/programs/ZFIR0120.md) | LOG_CREATE | call_function | 905 |
| [ZFIR0130](/programs/ZFIR0130.md) | LOG_CREATE | call_function | 905 |
| [ZFIR0170](/programs/ZFIR0170.md) | LOG_CREATE | call_function | 905 |
+55
View File
@@ -0,0 +1,55 @@
---
type: abap-function
title: "BAL_LOG_MSG_ADD"
description: "BAL_LOG_MSG_ADD — 호출 프로그램 17개"
resource: "abap://function/BAL_LOG_MSG_ADD"
tags: []
generated: { by: "abap-indexing/wiki_out", at: "2026-09-20T15:36:24+00:00" }
status: stable
x-caller-programs: 17
x-call-sites: 17
---
> 이 펑션모듈 자체의 소스는 수집 대상이 아니다 (ASSUMPTIONS.md §4) — 호출처만 제공한다.
## 호출 프로그램 (17개)
- [ZFIC0010](/programs/ZFIC0010.md)
- [ZFIC0020](/programs/ZFIC0020.md)
- [ZFIR0010](/programs/ZFIR0010.md)
- [ZFIR0030](/programs/ZFIR0030.md)
- [ZFIR0031](/programs/ZFIR0031.md)
- [ZFIR0032](/programs/ZFIR0032.md)
- [ZFIR0050](/programs/ZFIR0050.md)
- [ZFIR0060](/programs/ZFIR0060.md)
- [ZFIR0061](/programs/ZFIR0061.md)
- [ZFIR0070](/programs/ZFIR0070.md)
- [ZFIR0071](/programs/ZFIR0071.md)
- [ZFIR0072](/programs/ZFIR0072.md)
- [ZFIR0080](/programs/ZFIR0080.md)
- [ZFIR0090](/programs/ZFIR0090.md)
- [ZFIR0120](/programs/ZFIR0120.md)
- [ZFIR0130](/programs/ZFIR0130.md)
- [ZFIR0170](/programs/ZFIR0170.md)
## 호출 지점
| 프로그램 | unit | 종류 | 줄 |
|---|---|---|---|
| [ZFIC0010](/programs/ZFIC0010.md) | LOG_CREATE | call_function | 924 |
| [ZFIC0020](/programs/ZFIC0020.md) | LOG_CREATE | call_function | 924 |
| [ZFIR0010](/programs/ZFIR0010.md) | LOG_CREATE | call_function | 924 |
| [ZFIR0030](/programs/ZFIR0030.md) | LOG_CREATE | call_function | 924 |
| [ZFIR0031](/programs/ZFIR0031.md) | LOG_CREATE | call_function | 924 |
| [ZFIR0032](/programs/ZFIR0032.md) | LOG_CREATE | call_function | 924 |
| [ZFIR0050](/programs/ZFIR0050.md) | LOG_CREATE | call_function | 924 |
| [ZFIR0060](/programs/ZFIR0060.md) | LOG_CREATE | call_function | 924 |
| [ZFIR0061](/programs/ZFIR0061.md) | LOG_CREATE | call_function | 924 |
| [ZFIR0070](/programs/ZFIR0070.md) | LOG_CREATE | call_function | 924 |
| [ZFIR0071](/programs/ZFIR0071.md) | LOG_CREATE | call_function | 924 |
| [ZFIR0072](/programs/ZFIR0072.md) | LOG_CREATE | call_function | 924 |
| [ZFIR0080](/programs/ZFIR0080.md) | LOG_CREATE | call_function | 924 |
| [ZFIR0090](/programs/ZFIR0090.md) | LOG_CREATE | call_function | 924 |
| [ZFIR0120](/programs/ZFIR0120.md) | LOG_CREATE | call_function | 924 |
| [ZFIR0130](/programs/ZFIR0130.md) | LOG_CREATE | call_function | 924 |
| [ZFIR0170](/programs/ZFIR0170.md) | LOG_CREATE | call_function | 924 |
@@ -0,0 +1,35 @@
---
type: abap-function
title: "BAPI_COSTACTPLN_POSTPRIMCOST"
description: "BAPI_COSTACTPLN_POSTPRIMCOST — 호출 프로그램 7개"
resource: "abap://function/BAPI_COSTACTPLN_POSTPRIMCOST"
tags: []
generated: { by: "abap-indexing/wiki_out", at: "2026-09-20T15:36:24+00:00" }
status: stable
x-caller-programs: 7
x-call-sites: 7
---
> 이 펑션모듈 자체의 소스는 수집 대상이 아니다 (ASSUMPTIONS.md §4) — 호출처만 제공한다.
## 호출 프로그램 (7개)
- [ZCOC0010](/programs/ZCOC0010.md)
- [ZCOC0020](/programs/ZCOC0020.md)
- [ZCOC0030](/programs/ZCOC0030.md)
- [ZCOC0050](/programs/ZCOC0050.md)
- [ZCOC0060](/programs/ZCOC0060.md)
- [ZCOC0070](/programs/ZCOC0070.md)
- [ZCOC0080](/programs/ZCOC0080.md)
## 호출 지점
| 프로그램 | unit | 종류 | 줄 |
|---|---|---|---|
| [ZCOC0010](/programs/ZCOC0010.md) | MAKE_BAPI_DATA | call_function | 595 |
| [ZCOC0020](/programs/ZCOC0020.md) | MAKE_BAPI_DATA | call_function | 595 |
| [ZCOC0030](/programs/ZCOC0030.md) | BAPI_CARRY_FWD_POST | call_function | 348 |
| [ZCOC0050](/programs/ZCOC0050.md) | BAPI_POST_SENDER | call_function | 577 |
| [ZCOC0060](/programs/ZCOC0060.md) | BAPI_POST | call_function | 607 |
| [ZCOC0070](/programs/ZCOC0070.md) | BAPI_POST | call_function | 632 |
| [ZCOC0080](/programs/ZCOC0080.md) | BAPI_POST_SENDER | call_function | 591 |
@@ -0,0 +1,23 @@
---
type: abap-function
title: "BAPI_COSTCENTER_CHANGEMULTIPLE"
description: "BAPI_COSTCENTER_CHANGEMULTIPLE — 호출 프로그램 1개"
resource: "abap://function/BAPI_COSTCENTER_CHANGEMULTIPLE"
tags: []
generated: { by: "abap-indexing/wiki_out", at: "2026-09-20T15:36:24+00:00" }
status: stable
x-caller-programs: 1
x-call-sites: 1
---
> 이 펑션모듈 자체의 소스는 수집 대상이 아니다 (ASSUMPTIONS.md §4) — 호출처만 제공한다.
## 호출 프로그램 (1개)
- [ZCOC9020](/programs/ZCOC9020.md)
## 호출 지점
| 프로그램 | unit | 종류 | 줄 |
|---|---|---|---|
| [ZCOC9020](/programs/ZCOC9020.md) | RUN_BAPI | call_function | 531 |
@@ -0,0 +1,23 @@
---
type: abap-function
title: "BAPI_COSTCENTER_CREATEMULTIPLE"
description: "BAPI_COSTCENTER_CREATEMULTIPLE — 호출 프로그램 1개"
resource: "abap://function/BAPI_COSTCENTER_CREATEMULTIPLE"
tags: []
generated: { by: "abap-indexing/wiki_out", at: "2026-09-20T15:36:24+00:00" }
status: stable
x-caller-programs: 1
x-call-sites: 1
---
> 이 펑션모듈 자체의 소스는 수집 대상이 아니다 (ASSUMPTIONS.md §4) — 호출처만 제공한다.
## 호출 프로그램 (1개)
- [ZCOC9010](/programs/ZCOC9010.md)
## 호출 지점
| 프로그램 | unit | 종류 | 줄 |
|---|---|---|---|
| [ZCOC9010](/programs/ZCOC9010.md) | RUN_BAPI | call_function | 502 |
@@ -0,0 +1,23 @@
---
type: abap-function
title: "BAPI_COSTELEMENTGRP_GETDETAIL"
description: "BAPI_COSTELEMENTGRP_GETDETAIL — 호출 프로그램 1개"
resource: "abap://function/BAPI_COSTELEMENTGRP_GETDETAIL"
tags: []
generated: { by: "abap-indexing/wiki_out", at: "2026-09-20T15:36:24+00:00" }
status: stable
x-caller-programs: 1
x-call-sites: 1
---
> 이 펑션모듈 자체의 소스는 수집 대상이 아니다 (ASSUMPTIONS.md §4) — 호출처만 제공한다.
## 호출 프로그램 (1개)
- [ZCOR0010](/programs/ZCOR0010.md)
## 호출 지점
| 프로그램 | unit | 종류 | 줄 |
|---|---|---|---|
| [ZCOR0010](/programs/ZCOR0010.md) | SELECT_DATA | call_function | 116 |
@@ -0,0 +1,111 @@
---
type: abap-function
title: "BAPI_CURRENCY_CONV_TO_INTERNAL"
description: "BAPI_CURRENCY_CONV_TO_INTERNAL — 호출 프로그램 30개"
resource: "abap://function/BAPI_CURRENCY_CONV_TO_INTERNAL"
tags: []
generated: { by: "abap-indexing/wiki_out", at: "2026-09-20T15:36:24+00:00" }
status: stable
x-caller-programs: 30
x-call-sites: 60
---
> 이 펑션모듈 자체의 소스는 수집 대상이 아니다 (ASSUMPTIONS.md §4) — 호출처만 제공한다.
## 호출 프로그램 (30개)
- [ZCOC0010](/programs/ZCOC0010.md)
- [ZCOC0020](/programs/ZCOC0020.md)
- [ZCOC0030](/programs/ZCOC0030.md)
- [ZCOC0040](/programs/ZCOC0040.md)
- [ZCOC0050](/programs/ZCOC0050.md)
- [ZCOC0060](/programs/ZCOC0060.md)
- [ZCOC0070](/programs/ZCOC0070.md)
- [ZCOC0080](/programs/ZCOC0080.md)
- [ZCOC0090](/programs/ZCOC0090.md)
- [ZCOC0100](/programs/ZCOC0100.md)
- [ZCOC0110](/programs/ZCOC0110.md)
- [ZCOC0120](/programs/ZCOC0120.md)
- [ZCOC0130](/programs/ZCOC0130.md)
- [ZCOC1010](/programs/ZCOC1010.md)
- [ZCOC2010](/programs/ZCOC2010.md)
- [ZCOC2020](/programs/ZCOC2020.md)
- [ZCOC2030](/programs/ZCOC2030.md)
- [ZCOC2040](/programs/ZCOC2040.md)
- [ZCOC9010](/programs/ZCOC9010.md)
- [ZCOC9020](/programs/ZCOC9020.md)
- [ZCOC9030](/programs/ZCOC9030.md)
- [ZCOC9040](/programs/ZCOC9040.md)
- [ZCOR0010](/programs/ZCOR0010.md)
- [ZCOR0020](/programs/ZCOR0020.md)
- [ZCOR0030](/programs/ZCOR0030.md)
- [ZCOR0040](/programs/ZCOR0040.md)
- [ZCOR1010](/programs/ZCOR1010.md)
- [ZCOR2010](/programs/ZCOR2010.md)
- [ZFIR0020](/programs/ZFIR0020.md)
- [ZFIR0040](/programs/ZFIR0040.md)
## 호출 지점
| 프로그램 | unit | 종류 | 줄 |
|---|---|---|---|
| [ZCOC0010](/programs/ZCOC0010.md) | CONVERT_CHAR_TO_AMT | call_function | 949 |
| [ZCOC0010](/programs/ZCOC0010.md) | AMT_CONV_TO_INTERNAL | call_function | 1151 |
| [ZCOC0020](/programs/ZCOC0020.md) | CONVERT_CHAR_TO_AMT | call_function | 949 |
| [ZCOC0020](/programs/ZCOC0020.md) | AMT_CONV_TO_INTERNAL | call_function | 1151 |
| [ZCOC0030](/programs/ZCOC0030.md) | CONVERT_CHAR_TO_AMT | call_function | 949 |
| [ZCOC0030](/programs/ZCOC0030.md) | AMT_CONV_TO_INTERNAL | call_function | 1151 |
| [ZCOC0040](/programs/ZCOC0040.md) | CONVERT_CHAR_TO_AMT | call_function | 949 |
| [ZCOC0040](/programs/ZCOC0040.md) | AMT_CONV_TO_INTERNAL | call_function | 1151 |
| [ZCOC0050](/programs/ZCOC0050.md) | CONVERT_CHAR_TO_AMT | call_function | 949 |
| [ZCOC0050](/programs/ZCOC0050.md) | AMT_CONV_TO_INTERNAL | call_function | 1151 |
| [ZCOC0060](/programs/ZCOC0060.md) | CONVERT_CHAR_TO_AMT | call_function | 949 |
| [ZCOC0060](/programs/ZCOC0060.md) | AMT_CONV_TO_INTERNAL | call_function | 1151 |
| [ZCOC0070](/programs/ZCOC0070.md) | CONVERT_CHAR_TO_AMT | call_function | 949 |
| [ZCOC0070](/programs/ZCOC0070.md) | AMT_CONV_TO_INTERNAL | call_function | 1151 |
| [ZCOC0080](/programs/ZCOC0080.md) | CONVERT_CHAR_TO_AMT | call_function | 949 |
| [ZCOC0080](/programs/ZCOC0080.md) | AMT_CONV_TO_INTERNAL | call_function | 1151 |
| [ZCOC0090](/programs/ZCOC0090.md) | CONVERT_CHAR_TO_AMT | call_function | 949 |
| [ZCOC0090](/programs/ZCOC0090.md) | AMT_CONV_TO_INTERNAL | call_function | 1151 |
| [ZCOC0100](/programs/ZCOC0100.md) | CONVERT_CHAR_TO_AMT | call_function | 949 |
| [ZCOC0100](/programs/ZCOC0100.md) | AMT_CONV_TO_INTERNAL | call_function | 1151 |
| [ZCOC0110](/programs/ZCOC0110.md) | CONVERT_CHAR_TO_AMT | call_function | 949 |
| [ZCOC0110](/programs/ZCOC0110.md) | AMT_CONV_TO_INTERNAL | call_function | 1151 |
| [ZCOC0120](/programs/ZCOC0120.md) | CONVERT_CHAR_TO_AMT | call_function | 949 |
| [ZCOC0120](/programs/ZCOC0120.md) | AMT_CONV_TO_INTERNAL | call_function | 1151 |
| [ZCOC0130](/programs/ZCOC0130.md) | CONVERT_CHAR_TO_AMT | call_function | 949 |
| [ZCOC0130](/programs/ZCOC0130.md) | AMT_CONV_TO_INTERNAL | call_function | 1151 |
| [ZCOC1010](/programs/ZCOC1010.md) | CONVERT_CHAR_TO_AMT | call_function | 949 |
| [ZCOC1010](/programs/ZCOC1010.md) | AMT_CONV_TO_INTERNAL | call_function | 1151 |
| [ZCOC2010](/programs/ZCOC2010.md) | CONVERT_CHAR_TO_AMT | call_function | 949 |
| [ZCOC2010](/programs/ZCOC2010.md) | AMT_CONV_TO_INTERNAL | call_function | 1151 |
| [ZCOC2020](/programs/ZCOC2020.md) | CONVERT_CHAR_TO_AMT | call_function | 949 |
| [ZCOC2020](/programs/ZCOC2020.md) | AMT_CONV_TO_INTERNAL | call_function | 1151 |
| [ZCOC2030](/programs/ZCOC2030.md) | CONVERT_CHAR_TO_AMT | call_function | 949 |
| [ZCOC2030](/programs/ZCOC2030.md) | AMT_CONV_TO_INTERNAL | call_function | 1151 |
| [ZCOC2040](/programs/ZCOC2040.md) | CONVERT_CHAR_TO_AMT | call_function | 949 |
| [ZCOC2040](/programs/ZCOC2040.md) | AMT_CONV_TO_INTERNAL | call_function | 1151 |
| [ZCOC9010](/programs/ZCOC9010.md) | CONVERT_CHAR_TO_AMT | call_function | 949 |
| [ZCOC9010](/programs/ZCOC9010.md) | AMT_CONV_TO_INTERNAL | call_function | 1151 |
| [ZCOC9020](/programs/ZCOC9020.md) | CONVERT_CHAR_TO_AMT | call_function | 949 |
| [ZCOC9020](/programs/ZCOC9020.md) | AMT_CONV_TO_INTERNAL | call_function | 1151 |
| [ZCOC9030](/programs/ZCOC9030.md) | CONVERT_CHAR_TO_AMT | call_function | 949 |
| [ZCOC9030](/programs/ZCOC9030.md) | AMT_CONV_TO_INTERNAL | call_function | 1151 |
| [ZCOC9040](/programs/ZCOC9040.md) | CONVERT_CHAR_TO_AMT | call_function | 949 |
| [ZCOC9040](/programs/ZCOC9040.md) | AMT_CONV_TO_INTERNAL | call_function | 1151 |
| [ZCOR0010](/programs/ZCOR0010.md) | CONVERT_CHAR_TO_AMT | call_function | 949 |
| [ZCOR0010](/programs/ZCOR0010.md) | AMT_CONV_TO_INTERNAL | call_function | 1151 |
| [ZCOR0020](/programs/ZCOR0020.md) | CONVERT_CHAR_TO_AMT | call_function | 949 |
| [ZCOR0020](/programs/ZCOR0020.md) | AMT_CONV_TO_INTERNAL | call_function | 1151 |
| [ZCOR0030](/programs/ZCOR0030.md) | CONVERT_CHAR_TO_AMT | call_function | 949 |
| [ZCOR0030](/programs/ZCOR0030.md) | AMT_CONV_TO_INTERNAL | call_function | 1151 |
| [ZCOR0040](/programs/ZCOR0040.md) | CONVERT_CHAR_TO_AMT | call_function | 949 |
| [ZCOR0040](/programs/ZCOR0040.md) | AMT_CONV_TO_INTERNAL | call_function | 1151 |
| [ZCOR1010](/programs/ZCOR1010.md) | CONVERT_CHAR_TO_AMT | call_function | 949 |
| [ZCOR1010](/programs/ZCOR1010.md) | AMT_CONV_TO_INTERNAL | call_function | 1151 |
| [ZCOR2010](/programs/ZCOR2010.md) | CONVERT_CHAR_TO_AMT | call_function | 949 |
| [ZCOR2010](/programs/ZCOR2010.md) | AMT_CONV_TO_INTERNAL | call_function | 1151 |
| [ZFIR0020](/programs/ZFIR0020.md) | CONVERT_CHAR_TO_AMT | call_function | 905 |
| [ZFIR0020](/programs/ZFIR0020.md) | AMT_CONV_TO_INTERNAL | call_function | 1108 |
| [ZFIR0040](/programs/ZFIR0040.md) | CONVERT_CHAR_TO_AMT | call_function | 905 |
| [ZFIR0040](/programs/ZFIR0040.md) | AMT_CONV_TO_INTERNAL | call_function | 1108 |
+23
View File
@@ -0,0 +1,23 @@
---
type: abap-function
title: "BAPI_FIXEDASSET_CREATE"
description: "BAPI_FIXEDASSET_CREATE — 호출 프로그램 1개"
resource: "abap://function/BAPI_FIXEDASSET_CREATE"
tags: []
generated: { by: "abap-indexing/wiki_out", at: "2026-09-20T15:36:24+00:00" }
status: stable
x-caller-programs: 1
x-call-sites: 1
---
> 이 펑션모듈 자체의 소스는 수집 대상이 아니다 (ASSUMPTIONS.md §4) — 호출처만 제공한다.
## 호출 프로그램 (1개)
- [ZCOC0120](/programs/ZCOC0120.md)
## 호출 지점
| 프로그램 | unit | 종류 | 줄 |
|---|---|---|---|
| [ZCOC0120](/programs/ZCOC0120.md) | CREATE_FIXED_ASSET | call_function | 1024 |
@@ -0,0 +1,23 @@
---
type: abap-function
title: "BAPI_PROFITCENTER_CREATE"
description: "BAPI_PROFITCENTER_CREATE — 호출 프로그램 1개"
resource: "abap://function/BAPI_PROFITCENTER_CREATE"
tags: []
generated: { by: "abap-indexing/wiki_out", at: "2026-09-20T15:36:24+00:00" }
status: stable
x-caller-programs: 1
x-call-sites: 1
---
> 이 펑션모듈 자체의 소스는 수집 대상이 아니다 (ASSUMPTIONS.md §4) — 호출처만 제공한다.
## 호출 프로그램 (1개)
- [ZCOC9030](/programs/ZCOC9030.md)
## 호출 지점
| 프로그램 | unit | 종류 | 줄 |
|---|---|---|---|
| [ZCOC9030](/programs/ZCOC9030.md) | RUN_BAPI | call_function | 390 |

Some files were not shown because too many files have changed in this diff Show More