Files

272 lines
12 KiB
Python

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