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:
+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);
|
||||
Reference in New Issue
Block a user