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