Initial commit: ABAP indexing pipeline (ingest, parser, summarize, index, query, wiki)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
byeongwook.choi
2026-09-21 13:23:37 +09:00
co-authored by Claude Fable 5.1
commit 11ae3629b2
453 changed files with 259183 additions and 0 deletions
View File
+428
View File
@@ -0,0 +1,428 @@
"""Stage 5 — 질의 API (FastAPI).
opencode-be 의 커스텀 툴(.opencode/tool/_index_client.ts)이 호출하는 HTTP 서비스.
실행: python -m query.api (기본 127.0.0.1:8100)
자연어 로직 질의는 /search/logic (로직 조각) 이 1차 진입점이다 — docs/logic-chunk-design.md
응답 크기 원칙: 툴 응답이 LLM 컨텍스트에 그대로 들어가므로 상한을 두고 자른다.
(책임 경계 — 컨텍스트 초과는 인덱스 쪽 책임: 계획서 리스크 항목)
"""
from __future__ import annotations
import json
import re
import sqlite3
from datetime import datetime, timezone
import threading
from fastapi import BackgroundTasks, Body, FastAPI, HTTPException, Query
from fastapi.responses import HTMLResponse
from config.settings import ROOT, settings
from index.db import connect, db_path
from index.loader import load_parsed, refresh_program_fts
from ingest.normalize import write_program
from parser.run import parse_program
from summarize.runner import PROMPT_VERSION, summarize_units
from . import tools
from .dashboard import render_dashboard
app = FastAPI(title="abap-indexing", version="0.1.0")
MAX_CODE_LINES = 400 # unit 코드 응답 상한 (초과 시 write 지점 위주로 잘라야 하나 v1은 앞부분+안내)
def _wrap(fn, *args, **kwargs):
try:
return fn(*args, **kwargs)
except tools.NotFound as e:
raise HTTPException(status_code=404, detail=str(e)) from e
except sqlite3.OperationalError as e:
raise HTTPException(status_code=503, detail=f"인덱스 DB 오류: {e}") from e
# 프로세스 시작 시각 — 코드를 고쳤는데 반영이 안 될 때 서버가 낡았는지 판단하는 근거.
# (템플릿 html 은 요청마다 다시 읽지만 파이썬 모듈은 sys.modules 에 캐시된다.)
STARTED_AT = datetime.now(timezone.utc).isoformat(timespec="seconds")
@app.get("/health")
def health() -> dict:
con = connect()
try:
programs = con.execute("SELECT COUNT(*) AS c FROM program").fetchone()["c"]
with_source = con.execute("SELECT COUNT(*) AS c FROM program WHERE has_source=1").fetchone()["c"]
units = con.execute("SELECT COUNT(*) AS c FROM unit").fetchone()["c"]
chunks = con.execute("SELECT COUNT(*) AS c FROM logic_chunk").fetchone()["c"]
wiki_programs = (
sum(1 for _ in (settings.wiki_dir / "programs").glob("*.md"))
if (settings.wiki_dir / "programs").exists() else 0
)
return {
"status": "ok",
"started_at": STARTED_AT,
"db": str(db_path()),
"programs": programs,
"programs_with_source": with_source,
"units": units,
"logic_chunks": chunks,
"wiki_programs": wiki_programs,
"wiki_search_backend": settings.wiki_search_backend, # qmd | pg (M3.5 파일럿으로 확정)
}
finally:
con.close()
# 인제스트 후 백그라운드 요약 — 같은 프로그램 중복 실행 방지
_summarizing: set[str] = set()
_summarizing_lock = threading.Lock()
def _summarize_bg(program: str) -> None:
try:
summarize_units(program, limit=None, dry_run=False, trigger="ingest",
backend=settings.summarize_backend)
except Exception as e: # noqa: BLE001
print(f"[summarize:{program}] {type(e).__name__}: {e}")
finally:
with _summarizing_lock:
_summarizing.discard(program)
# 요약 후 위키 재생성 (계획서 v4 §5.7 — 실패해도 요약 결과에는 영향 없음)
try:
from wiki_out.okf_writer import write_program_wiki
write_program_wiki(program)
except Exception as e: # noqa: BLE001
print(f"[wiki:{program}] {type(e).__name__}: {e}")
@app.post("/ingest")
def ingest(payload: dict = Body(), background_tasks: BackgroundTasks = None) -> dict:
"""수집 API/gateway가 넘겨준 프로그램 소스 JSON 한 건을 정규화→파싱→적재까지 처리한다.
body 는 수집 원본과 같은 형태: {MAIN_PROGRAM, DESCRIPTION, INCLUDE_PROGRAM[], TEXT_SYMBOL?}
(gateway 를 거친 응답은 이미 strict JSON 이므로 raw 정규화 단계는 불필요)
"""
if not isinstance(payload, dict) or not payload.get("MAIN_PROGRAM"):
raise HTTPException(status_code=400, detail="MAIN_PROGRAM이 포함된 소스 JSON이 필요합니다")
try:
meta = write_program(payload, settings.data_normalized)
program = meta["program"]
parsed = parse_program(settings.data_normalized / program)
settings.data_parsed.mkdir(parents=True, exist_ok=True)
parse_path = settings.data_parsed / f"{program}.parse.json"
parse_path.write_text(json.dumps(parsed, ensure_ascii=False), encoding="utf-8")
except Exception as e: # noqa: BLE001
raise HTTPException(status_code=422, detail=f"정규화/파싱 실패: {type(e).__name__}: {e}") from e
con = connect()
try:
status = load_parsed(con, parse_path)
# 단건만 갱신 — 전량 재구축을 /ingest 마다 하면 1만 본에서 O(N²) 가 된다
refresh_program_fts(con, program)
con.commit()
# 요약이 안 됐거나(failed 포함) 프롬프트 버전이 지난 unit 수 — runner 의 스킵 조건과 동일 기준
pending = con.execute(
"SELECT COUNT(*) AS c FROM unit WHERE program=? AND unit_type IN "
"('FORM','METHOD','FUNCTION','MODULE','EVENT') "
"AND (summary_status!='done' OR prompt_version!=?)",
(program, PROMPT_VERSION),
).fetchone()["c"]
except Exception as e: # noqa: BLE001
con.rollback()
raise HTTPException(status_code=500, detail=f"인덱스 적재 실패: {type(e).__name__}: {e}") from e
finally:
con.close()
# LLM 요약을 백그라운드로 이어 돌린다 (응답은 즉시 반환).
# 소스가 안 바뀐(skip) 프로그램이라도 요약 미완료 unit 이 있으면 이어서 돌린다(재인덱싱 = 요약 재시도).
# SUMMARIZE_BACKEND=file 이면 키 없이 프롬프트만 큐에 쌓는다 (/summaries/jobs 로 확인).
backend = settings.summarize_backend
summarize = "disabled"
if backend != "off" and (backend == "file" or (settings.llm_base_url and settings.llm_api_key)):
if status == "loaded" or pending:
with _summarizing_lock:
already = program in _summarizing
if not already:
_summarizing.add(program)
if already:
summarize = "already_running"
else:
background_tasks.add_task(_summarize_bg, program)
summarize = "scheduled" if status == "loaded" else "resumed"
else:
summarize = "skip" # 소스 동일 + 요약도 전부 완료
return {
"program": program,
"status": status, # loaded | skip(소스 변경 없음)
"summarize": summarize, # scheduled | resumed(미완료 요약 이어서) | already_running | skip | disabled
"summarize_backend": backend, # api | file(프롬프트 큐) | off
"pending_units": pending, # 요약 대기/실패 unit 수
"includes": len(meta["includes"]),
"units": parsed["stats"]["units"],
"statements": parsed["stats"]["statements"],
"unknown_ratio": parsed["stats"]["unknown_ratio"],
}
@app.get("/search/programs")
def search_programs(q: str = Query(min_length=1), top_k: int = Query(default=10, le=30)) -> dict:
return {"query": q, "results": _wrap(tools.search_programs, q, top_k)}
@app.get("/search/units")
def search_units(q: str = Query(min_length=1), program: str | None = None,
top_k: int = Query(default=10, le=30)) -> dict:
return {"query": q, "results": _wrap(tools.search_units, q, program, top_k)}
@app.get("/search/logic")
def search_logic(q: str = Query(min_length=1), program: str | None = None, kind: str | None = None,
top_k: int = Query(default=10, le=30)) -> dict:
"""로직 조각 검색 — 자연어 로직 질의의 1차 진입점. 프로그램 단위로 묶어 반환.
응답의 `expansion` 은 용어 사전으로 펼친 동의어를 보여준다. 조각마다 붙는 `matched_by` 가
'동의어 확장' 이면 원질의가 아니라 동의어로 걸린 결과다 (점수에 감쇠가 적용됨).
"""
return {"query": q, **_wrap(tools.search_logic, q, top_k, program, kind)}
@app.get("/chunks/{chunk_id}")
def chunk(chunk_id: str, decls: bool = True) -> dict:
"""조각 메타 + 코드 원문 + 정의부. chunk_id 의 '#' 은 URL 인코딩(%23) 해야 한다.
`code` 는 로직만이라 그대로 붙여넣으면 선언이 없어 문법 오류가 난다. 함께 가야 하는 선언은
`declaration_code`(붙여넣기용 한 덩어리)와 `declarations`(건별 목록)로 준다.
필요 없으면 `?decls=false` 로 끈다.
"""
return _wrap(tools.get_chunk, chunk_id, decls)
@app.get("/programs/{name}/declarations")
def program_declarations(name: str, scope: str | None = None) -> dict:
"""프로그램의 정의부 전체 (scope=global|unit 로 거를 수 있다)."""
return _wrap(tools.get_declarations, name, scope)
@app.get("/programs/{name}/chunks")
def program_chunks(name: str, unit_id: str | None = None) -> dict:
return {"program": name.upper(), "chunks": _wrap(tools.list_chunks, name, unit_id)}
@app.get("/programs/{name}/summary")
def program_summary(name: str) -> dict:
return _wrap(tools.get_program_summary, name)
@app.get("/programs/{name}/source")
def program_source(name: str) -> dict:
return _wrap(tools.get_program_source, name)
@app.get("/programs/{name}/units/{unit}/code")
def unit_code(name: str, unit: str) -> dict:
result = _wrap(tools.get_unit_code, name, unit)
lines = result["code"].split("\n")
if len(lines) > MAX_CODE_LINES:
result["code"] = "\n".join(lines[:MAX_CODE_LINES])
result["truncated"] = True
result["total_lines"] = len(lines)
return result
@app.get("/programs/{name}/trace/{symbol}")
def trace(name: str, symbol: str) -> dict:
return _wrap(tools.trace_variable, name, symbol)
@app.get("/programs/{name}/call-graph")
def call_graph(name: str, unit: str | None = None) -> dict:
return _wrap(tools.get_call_graph, name, unit)
@app.get("/programs/{name}/who-calls/{unit}")
def who_calls_route(name: str, unit: str) -> dict:
return _wrap(tools.who_calls, name, unit)
@app.get("/tables/{name}/usage")
def table_usage(name: str) -> dict:
return _wrap(tools.get_table_usage, name)
@app.get("/wiki/{doc_path:path}")
def wiki_doc(doc_path: str) -> dict:
"""위키 문서 원문 반환 (계획서 v4 §7.2).
경로 규약: **wiki/ 루트 상대 경로** (예: programs/ZFIR10070.md).
경로 A(qmd) 채택 시 qmd 검색 결과의 경로가 그대로 들어온다.
"""
from wiki_out.merge import is_human_verified, split_frontmatter
root = settings.wiki_dir.resolve()
if not doc_path.endswith(".md"):
doc_path += ".md"
target = (root / doc_path).resolve()
if not target.is_relative_to(root):
raise HTTPException(status_code=400, detail="잘못된 경로입니다 (wiki/ 루트 상대 경로만 허용)")
if not target.is_file():
raise HTTPException(
status_code=404,
detail=f"위키 문서 '{doc_path}' 이(가) 없습니다. "
f"경로는 wiki/ 루트 상대(예: programs/ZFIR10070.md)여야 합니다.",
)
text = target.read_text(encoding="utf-8")
fm, _body = split_frontmatter(text)
return {
"path": target.relative_to(root).as_posix(),
"human_verified": is_human_verified(fm), # 사람 교정 문서를 더 신뢰 (계획서 §6-3 규칙 0)
"content": text,
}
@app.get("/wiki-viewer", response_class=HTMLResponse)
def wiki_viewer() -> str:
"""위키 뷰어 — 요청 시점의 wiki/ 를 스캔해 렌더 (실시간)."""
from wiki_out.viewer import build_html
return build_html()
@app.get("/dashboard", response_class=HTMLResponse)
def dashboard() -> str:
"""관측소 대시보드 — 요청 시점의 index.db 상태를 담아 렌더 (실시간)."""
with _summarizing_lock:
running = sorted(_summarizing)
return render_dashboard(running)
@app.get("/summaries/status")
def summaries_status() -> dict:
"""요약 진행 현황과 LLM 사용량/비용 로그 (관측소 대시보드·gateway용)."""
con = connect()
try:
per_program = [dict(r) for r in con.execute(
"SELECT u.program, COUNT(*) AS eligible, "
"SUM(CASE WHEN u.summary_status='done' THEN 1 ELSE 0 END) AS done, "
"SUM(CASE WHEN u.summary_status='failed' THEN 1 ELSE 0 END) AS failed, "
"COALESCE(SUM(u.chunk_count),0) AS chunks, "
"MAX(p.summary_status) AS program_summary "
"FROM unit u JOIN program p ON p.name=u.program "
"WHERE u.unit_type IN ('FORM','METHOD','FUNCTION','MODULE','EVENT') "
"GROUP BY u.program ORDER BY u.program")]
totals = con.execute(
"SELECT COUNT(*) AS runs, COALESCE(SUM(calls),0) AS calls, "
"COALESCE(SUM(prompt_tokens),0) AS prompt_tokens, "
"COALESCE(SUM(completion_tokens),0) AS completion_tokens, "
"COALESCE(SUM(cost_usd),0) AS cost_usd FROM llm_usage_log").fetchone()
log = [dict(r) for r in con.execute(
"SELECT * FROM llm_usage_log ORDER BY id DESC LIMIT 50")]
failures = [dict(r) for r in con.execute(
"SELECT program, unit_id, include, unit_type, name, summary_error "
"FROM unit WHERE summary_status='failed' ORDER BY program, include, line_start")]
with _summarizing_lock:
running = sorted(_summarizing)
return {
"model": settings.llm_model,
"llm_enabled": bool(settings.llm_base_url and settings.llm_api_key),
"running": running,
"programs": per_program,
"failures": failures,
"usage_totals": dict(totals),
"usage_log": log,
"job_queue": _job_queue_status(),
}
finally:
con.close()
def _job_queue_status() -> dict:
"""file 백엔드 작업 큐 현황 — LLM 키가 없을 때 진행 상황을 여기로 본다."""
from summarize.jobs import _index_rows, _status, jobs_dir
d = jobs_dir()
rows = _index_rows(d)
pending = [r for r in rows if _status(d, r) == "pending"]
return {
"dir": str(d),
"total": len(rows),
"pending": len(pending),
"answered": len(rows) - len(pending),
"next": [
{"job_id": r["job_id"], "task": r.get("task", ""), "program": r.get("program", ""),
"unit_id": r.get("unit_id", "")}
for r in pending[:20]
],
}
@app.get("/summaries/jobs")
def summaries_jobs() -> dict:
"""LLM 키 없이 돌리는 작업 큐 조회 (프롬프트 대기 목록).
채우는 방법은 `python -m summarize.jobs` 참고. API 로 응답을 받지는 않는다 —
프롬프트 본문이 커서 파일로 주고받는 쪽이 안전하다.
"""
return _job_queue_status()
# ===== LLM 설정 (관측소 대시보드에서 요약 모델 전환) =====
_MODEL_RE = re.compile(r"^[A-Za-z0-9._\-/:]+$")
ENV_PATH = ROOT / ".env" # 테스트에서 monkeypatch
def _persist_env(key: str, value: str) -> None:
"""ENV_PATH 의 key= 줄을 교체(없으면 추가) — 서버 재시작 후에도 유지되게."""
lines = ENV_PATH.read_text(encoding="utf-8").splitlines() if ENV_PATH.exists() else []
for i, ln in enumerate(lines):
if ln.startswith(f"{key}="):
lines[i] = f"{key}={value}"
break
else:
lines.append(f"{key}={value}")
ENV_PATH.write_text("\n".join(lines) + "\n", encoding="utf-8")
@app.get("/settings/llm")
def get_llm_settings() -> dict:
return {
"model": settings.llm_model,
"enabled": bool(settings.llm_base_url and settings.llm_api_key),
}
@app.put("/settings/llm")
def put_llm_settings(payload: dict = Body()) -> dict:
model = str(payload.get("model", "")).strip()
if not model or not _MODEL_RE.match(model):
raise HTTPException(status_code=400, detail="model 형식이 잘못됐습니다 (영숫자 · . - / : 만 허용)")
settings.llm_model = model # 즉시 반영 — 다음 요약 배치부터 이 모델로 호출
_persist_env("LLM_MODEL", model)
return {"model": model}
def main() -> None:
import argparse
import uvicorn
ap = argparse.ArgumentParser(description="Stage 5 — 질의 API / 관측소 / 위키 뷰어")
ap.add_argument("--host", default=settings.index_host)
ap.add_argument("--port", type=int, default=settings.index_port)
ap.add_argument("--reload", action="store_true",
help="소스 변경 시 자동 재시작. 템플릿(html)은 요청마다 다시 읽지만 "
"파이썬 모듈은 프로세스에 캐시되므로, 코드를 고치는 중이면 이 옵션이 필요하다")
args = ap.parse_args()
if args.reload:
uvicorn.run("query.api:app", host=args.host, port=args.port, reload=True)
else:
uvicorn.run(app, host=args.host, port=args.port)
if __name__ == "__main__":
main()
+123
View File
@@ -0,0 +1,123 @@
"""관측소 대시보드 — index.db 현재 상태를 담아 서버가 직접 서빙한다 (GET /dashboard).
정적 아티팩트 스냅샷과 같은 화면이며, 요청 시점마다 DB를 다시 읽어 HTML에 주입한다.
템플릿: query/dashboard.html (플레이스홀더 __DATA_JSON__, __GENERATED_AT__)
"""
from __future__ import annotations
import json
from datetime import datetime
from pathlib import Path
from config.settings import settings
from index.db import connect, loads
TEMPLATE = Path(__file__).with_name("dashboard.html")
ELIGIBLE = "('FORM','METHOD','FUNCTION','MODULE','EVENT')"
def _rows(con, sql: str, params: tuple = ()) -> list[dict]:
return [dict(r) for r in con.execute(sql, params)]
def build_data(con, running: list[str]) -> dict:
counts = {
t: con.execute(f"SELECT COUNT(*) FROM {t}").fetchone()[0]
for t in ("program", "include", "unit", "symbol", "symbol_write",
"symbol_read", "table_ref", "call_edge", "topo")
}
counts["table_read"] = con.execute("SELECT COUNT(*) FROM table_ref WHERE mode='read'").fetchone()[0]
counts["table_write"] = con.execute("SELECT COUNT(*) FROM table_ref WHERE mode='write'").fetchone()[0]
unit_counts = {r["program"]: r["n"] for r in con.execute(
"SELECT program, COUNT(*) n FROM unit GROUP BY program")}
chunk_counts = {r["program"]: r["n"] for r in con.execute(
"SELECT program, COUNT(*) n FROM logic_chunk GROUP BY program")}
counts["logic_chunk"] = sum(chunk_counts.values())
programs = [
{"name": r["name"], "devclass": r["devclass"], "title": r["title_ko"],
"units": unit_counts.get(r["name"], 0), "chunks": chunk_counts.get(r["name"], 0)}
for r in con.execute("SELECT name, devclass, title_ko FROM program ORDER BY name")
]
return {
"counts": counts,
"programs": programs,
"includes": _rows(con, "SELECT program, include, line_count FROM include ORDER BY program, include"),
"units": _rows(con, "SELECT unit_id, program, include, unit_type, name, line_start, line_end, loc FROM unit"),
"edges": _rows(con, "SELECT program, from_unit, to_unit, external_name, call_type, line FROM call_edge"),
"table_refs": _rows(con, "SELECT program, unit_id, table_name, mode FROM table_ref"),
"rw": _rows(con, """
SELECT program, symbol, SUM(w) w, SUM(r) r FROM (
SELECT program, symbol, COUNT(*) w, 0 r FROM symbol_write GROUP BY program, symbol
UNION ALL
SELECT program, symbol, 0 w, COUNT(*) r FROM symbol_read GROUP BY program, symbol
) GROUP BY program, symbol ORDER BY (SUM(w)+SUM(r)) DESC"""),
"summaries": {
r["unit_id"]: {k: (loads(r["summary_json"]) or {}).get(k)
for k in ("purpose_ko", "chunk_count", "coverage")}
for r in con.execute("SELECT unit_id, summary_json FROM unit WHERE summary_status='done'")
},
"chunks": _rows(con, "SELECT chunk_id, program, unit_id, include, line_start, line_end, kind, "
"purpose_ko, confidence FROM logic_chunk ORDER BY program, include, line_start"),
"llm": {
# 설정된 모델과 **실제로 쓰인 백엔드**를 구분해 보여준다 — file 백엔드로 돌린 결과를
# 쓰지도 않은 모델 이름으로 표기하면 관측 결과가 거짓이 된다.
"model": settings.llm_model,
"backend": settings.summarize_backend,
"api_key_set": bool(settings.llm_base_url and settings.llm_api_key),
"last_used_model": (con.execute(
"SELECT model FROM llm_usage_log ORDER BY id DESC LIMIT 1").fetchone() or [None])[0],
"running": running,
"progress": _rows(con, f"""
SELECT program, COUNT(*) eligible,
SUM(CASE WHEN summary_status='done' THEN 1 ELSE 0 END) done,
SUM(CASE WHEN summary_status='failed' THEN 1 ELSE 0 END) failed,
COALESCE(SUM(chunk_count),0) chunks
FROM unit WHERE unit_type IN {ELIGIBLE} GROUP BY program ORDER BY program"""),
"failures": _rows(con, """
SELECT program, unit_id, include, unit_type, name, summary_error
FROM unit WHERE summary_status='failed' ORDER BY program, include, line_start"""),
"totals": dict(con.execute(
"SELECT COUNT(*) runs, COALESCE(SUM(calls),0) calls, "
"COALESCE(SUM(prompt_tokens),0) pt, COALESCE(SUM(completion_tokens),0) ct, "
"COALESCE(SUM(cost_usd),0) cost FROM llm_usage_log").fetchone()),
"log": _rows(con, "SELECT * FROM llm_usage_log ORDER BY id DESC LIMIT 50"),
},
}
def render_dashboard(running: list[str] | None = None) -> str:
con = connect()
try:
data = build_data(con, running or [])
finally:
con.close()
payload = json.dumps(data, ensure_ascii=False).replace("</", "<\\/")
return (TEMPLATE.read_text(encoding="utf-8")
.replace("__DATA_JSON__", payload)
.replace("__GENERATED_AT__", datetime.now().strftime("%Y-%m-%d %H:%M:%S")))
def main() -> None:
"""관측소를 단일 HTML 파일로 내보낸다 — 다른 PC 로 옮겨 브라우저로만 열어보는 용도.
python -m query.dashboard [--out PATH]
데이터가 파일 안에 박히므로 파이썬·서버·index.db 없이 열린다 (뷰어 스냅샷과 같은 방식).
"""
import argparse
ap = argparse.ArgumentParser(description="관측소 대시보드 HTML 스냅샷")
ap.add_argument("--out", default=None)
args = ap.parse_args()
out = Path(args.out) if args.out else settings.data_raw.parent / "dashboard.html"
out.parent.mkdir(parents=True, exist_ok=True)
html = render_dashboard([])
out.write_text(html, encoding="utf-8")
print(f"{out} ({len(html) / 1024:.0f} KB)")
if __name__ == "__main__":
main()
+56
View File
@@ -0,0 +1,56 @@
"""질의 확장 — 도메인 용어 사전으로 동의어를 펼친다 (계획서 §5.6, 수정사항 4번).
"입고"로 물었을 때 GR / MSEG / 101 로 색인된 문서도 찾아야 한다.
## 왜 한 MATCH 식에 동의어를 섞지 않는가
`index.db.fts_or` 는 토큰과 한글 2-gram 을 전부 OR 로 잇는다. 이미 재현율 편향이라
동의어까지 같은 식에 넣으면 정밀도가 더 나빠진다 ("총계정원장" 질의가 "원장" 2-gram 하나로
걸린 문서와 동일 가중치가 된다).
그래서 **2단 검색**을 한다:
1단 — 원질의만으로 검색. 이걸 항상 상위에 둔다.
2단 — 결과가 부족할 때만(top_k 미만) 동의어 식으로 보충하고, 점수에 감쇠 계수를 걸고
matched_by='동의어 확장' 으로 표시해 호출 측이 구분할 수 있게 한다.
감쇠 계수(EXPANDED_WEIGHT)는 원질의 히트가 항상 앞서도록 하는 장치다.
"""
from __future__ import annotations
from config.glossary import synonym_map
from index.db import fts_or, query_tokens
# 동의어 히트에 곱하는 점수 감쇠 — 원질의 히트보다 항상 뒤에 놓이게 한다
EXPANDED_WEIGHT = 0.35
MAX_SYNONYMS = 24
def expansion_terms(q: str) -> list[str]:
"""질의 토큰의 동의어 목록 (원질의 토큰 자체는 제외)."""
syn = synonym_map()
tokens = query_tokens(q)
seen = {t.upper() for t in tokens}
out: list[str] = []
for t in tokens:
for s in syn.get(t.upper(), []):
if s.upper() in seen:
continue
seen.add(s.upper())
out.append(s)
if len(out) >= MAX_SYNONYMS:
return out
return out
def match_exprs(q: str) -> tuple[str, str | None]:
"""(원질의 MATCH 식, 동의어 MATCH 식 | None)"""
terms = expansion_terms(q)
return fts_or(query_tokens(q)), (fts_or(terms) if terms else None)
def explain(q: str) -> dict:
"""확장 결과 설명 — API 응답에 실어 "왜 이게 나왔나"를 보이게 한다."""
terms = expansion_terms(q)
return {"query": q, "tokens": query_tokens(q), "synonyms": terms,
"expanded": bool(terms), "expanded_weight": EXPANDED_WEIGHT}
+614
View File
@@ -0,0 +1,614 @@
"""Stage 5 — 질의 도구 구현. API(query/api.py)와 향후 에이전트가 공용으로 사용한다.
검색의 1차 단위는 로직 조각(search_logic → logic_chunk). search_programs / search_units 는
프로그램·unit 이름·주석·한 줄 요약 기반의 얇은 색인이다 (docs/logic-chunk-design.md).
요약(Stage 3)이 아직 없는 프로그램은 파서 구조 정보로 대체 요약을 만들어 반환한다
(사실은 파서가, 해석은 LLM이 — 구조 정보만으로도 흐름/추적 질문에는 답할 수 있다).
"""
from __future__ import annotations
import json
import sqlite3
from typing import Any
from index import decls as decls_mod
from index.db import bm25_rank, clean_comment, connect, fts_escape, loads
from parser.refs import EXTERNAL_CALL_KINDS
from .expand import EXPANDED_WEIGHT, explain, match_exprs
MAX_TRACE_DEPTH = 5
# 서술 색인(요약·키워드) 히트에 곱하는 계수. 이름·타이틀 일치가 우선이고 요약은 보강 신호다.
# 정답셋(수정사항 7번)이 생기면 EXPANDED_WEIGHT 와 함께 측정해 조정할 값이다.
_DESC_WEIGHT = 0.6
_REASON_RANK = {"이름/타이틀 일치": 0, "키워드 일치": 1, "요약 일치": 2, "동의어 확장": 3}
# clean_comment 는 index.db 로 옮겼다 (loader 도 써야 하는데 index → query 의존은 역방향).
# 기존 import 경로(query.tools.clean_comment)를 쓰는 호출부가 있어 이름은 여기서도 노출한다.
__all__ = ["clean_comment", "search_programs", "search_units", "search_logic", "get_chunk",
"list_chunks", "get_program_summary", "get_program_source", "get_unit_code",
"get_declarations", "trace_variable", "get_call_graph", "get_table_usage",
"who_calls", "NotFound"]
def _fts_rows(con: sqlite3.Connection, sql: str, base_params: list, q: str,
limit: int) -> list[tuple[sqlite3.Row, float]]:
"""2단 검색 실행 — 원질의 → (부족하면) 동의어 확장. 반환: [(row, 가중치)]
sql 은 '... MATCH ?' 자리표시자 하나를 첫 파라미터로 받는 형태여야 한다.
"""
primary, expanded = match_exprs(q)
out: list[tuple[sqlite3.Row, float]] = []
try:
out = [(r, 1.0) for r in con.execute(sql, [primary, *base_params, limit]).fetchall()]
except sqlite3.OperationalError:
out = []
if expanded and len(out) < limit:
seen = {tuple(r) for r, _ in out}
try:
for r in con.execute(sql, [expanded, *base_params, limit]).fetchall():
if tuple(r) not in seen:
out.append((r, EXPANDED_WEIGHT))
except sqlite3.OperationalError:
pass
return out
class NotFound(Exception):
pass
def _program_or_404(con: sqlite3.Connection, name: str) -> sqlite3.Row:
row = con.execute(
"SELECT p.*, COALESCE(k.text_ko,'') AS pkg_text FROM program p "
"LEFT JOIN package k ON k.devclass=p.devclass WHERE p.name=?",
(name.upper(),),
).fetchone()
if not row:
raise NotFound(f"프로그램 '{name}' 이(가) 인덱스에 없습니다")
return row
def search_programs(q: str, top_k: int = 10) -> list[dict]:
con = connect()
try:
results: dict[str, dict] = {}
def add(name: str, score: float, reason: str) -> None:
e = results.setdefault(name, {"name": name, "score": 0.0, "reason": reason})
e["score"] += score
# 더 강한 근거로 덮어쓴다: 이름/타이틀 > 요약·키워드 > 동의어
if _REASON_RANK.get(reason, 9) < _REASON_RANK.get(e["reason"], 9):
e["reason"] = reason
# 1) 이름·타이틀 색인 (원질의 우선, 부족하면 동의어 확장 — 수정사항 4번)
rank = bm25_rank("program_fts")
for row, weight in _fts_rows(
con,
f"SELECT name, {rank} AS rank FROM program_fts WHERE program_fts MATCH ? "
f"ORDER BY {rank} LIMIT ?",
[], q, top_k * 3,
):
add(row["name"], -float(row["rank"] or 0) * weight,
"키워드 일치" if weight >= 1.0 else "동의어 확장")
# 2) 서술 색인 (LLM 요약·조각 키워드·텍스트 심볼 — 수정사항 1·3번).
# 별 테이블이라 요약이 길어도 이름·타이틀 점수를 밀어내지 않고 **더하기만** 한다.
drank = bm25_rank("program_desc_fts")
for row, weight in _fts_rows(
con,
f"SELECT name, {drank} AS rank FROM program_desc_fts WHERE program_desc_fts MATCH ? "
f"ORDER BY {drank} LIMIT ?",
[], q, top_k * 3,
):
add(row["name"], -float(row["rank"] or 0) * weight * _DESC_WEIGHT,
"요약 일치" if weight >= 1.0 else "동의어 확장")
# 3) 이름/타이틀 LIKE 보조
like = f"%{q.strip().upper()}%"
like_ko = f"%{q.strip()}%"
for row in con.execute(
"SELECT name FROM program WHERE name LIKE ? OR title_ko LIKE ? LIMIT ?",
(like, like_ko, top_k * 2),
).fetchall():
add(row["name"], 5.0, "이름/타이틀 일치")
ranked = sorted(results.values(), key=lambda x: -x["score"])[:top_k]
out = []
for r in ranked:
p = con.execute(
"SELECT p.name, p.devclass, p.title_ko, p.changed_on, p.has_source, p.summary_json, "
"COALESCE(k.text_ko,'') AS pkg_text FROM program p "
"LEFT JOIN package k ON k.devclass=p.devclass WHERE p.name=?",
(r["name"],),
).fetchone()
summary = loads(p["summary_json"]) or {}
out.append(
{
"program": p["name"],
"devclass": p["devclass"],
"title_ko": p["title_ko"],
"changed_on": p["changed_on"],
"has_source": bool(p["has_source"]),
"purpose": (summary.get("business_purpose_ko") or "")[:200],
"reason": r["reason"],
}
)
return out
finally:
con.close()
def _chunk_row(r: sqlite3.Row) -> dict:
return {
"chunk_id": r["chunk_id"], "program": r["program"], "include": r["include"],
"unit": r["unit_id"].split("#")[3] if r["unit_id"].count("#") >= 3 else r["unit_id"],
"unit_type": r["unit_id"].split("#")[2] if r["unit_id"].count("#") >= 3 else "",
"line_start": r["line_start"], "line_end": r["line_end"], "kind": r["kind"],
"purpose_ko": r["purpose_ko"], "purpose_en": r["purpose_en"],
"keywords_ko": loads(r["keywords_ko"]) or [], "keywords_en": loads(r["keywords_en"]) or [],
"sap_objects": loads(r["sap_objects"]) or [],
"tables_read": loads(r["tables_read"]) or [], "tables_write": loads(r["tables_write"]) or [],
"calls": loads(r["calls"]) or [], "confidence": r["confidence"],
}
def search_logic(q: str, top_k: int = 10, program: str | None = None,
kind: str | None = None) -> dict:
"""로직 조각(logic_chunk) 검색 — 인덱스의 1차 단위. 결과는 프로그램 단위로 묶어 돌려준다.
반환: {"total": n, "programs": [{program, title_ko, purpose, score, chunks: [...]}]}
"""
con = connect()
try:
crank = bm25_rank("chunk_fts")
sql = (f"SELECT c.*, {crank} AS rank FROM chunk_fts f JOIN logic_chunk c "
f"ON c.chunk_id=f.chunk_id WHERE chunk_fts MATCH ?")
params: list[Any] = []
if program:
sql += " AND c.program=?"
params.append(program.upper())
if kind:
sql += " AND c.kind=?"
params.append(kind)
sql += f" ORDER BY {crank} LIMIT ?"
rows = _fts_rows(con, sql, params, q, top_k * 4)
grouped: dict[str, dict] = {}
for r, weight in rows:
g = grouped.get(r["program"])
if not g:
p = con.execute(
"SELECT name, devclass, title_ko, summary_json FROM program WHERE name=?", (r["program"],)
).fetchone()
summary = (loads(p["summary_json"]) or {}) if p else {}
g = grouped[r["program"]] = {
"program": r["program"], "devclass": p["devclass"] if p else None,
"title_ko": p["title_ko"] if p else "",
"purpose": (summary.get("business_purpose_ko") or "")[:200],
"score": 0.0, "chunks": [],
}
score = -float(r["rank"] or 0) * weight
g["score"] += score
if len(g["chunks"]) < top_k:
g["chunks"].append({
**_chunk_row(r), "score": round(score, 3),
"matched_by": "원질의" if weight >= 1.0 else "동의어 확장",
})
programs = sorted(grouped.values(), key=lambda g: -g["score"])[:top_k]
for g in programs:
g["score"] = round(g["score"], 3)
# total/matched_chunks 는 top_k*4 상한 안에서 매칭된 조각 수다(전체 건수가 아님).
# total 은 opencode-be 가 이미 쓰는 키라 이름을 유지하고, 뜻이 분명한 별칭을 함께 준다.
return {"total": len(rows), "matched_chunks": len(rows), "programs": programs,
"expansion": explain(q)}
finally:
con.close()
def get_chunk(chunk_id: str, with_decls: bool = True) -> dict:
"""조각 메타 + 코드 원문 + **정의부**.
`code` 는 로직만이다. 그대로 붙여넣으면 내부테이블·스트럭처 선언이 없어 문법 오류가 난다.
그래서 `declarations`(선언 목록)와 `declaration_code`(붙여넣기용 한 덩어리)를 함께 준다 —
쓸지 말지, 이름을 바꿀지는 붙여넣는 쪽이 정한다 (index/decls.py).
"""
con = connect()
try:
r = con.execute("SELECT * FROM logic_chunk WHERE chunk_id=?", (chunk_id,)).fetchone()
if not r:
raise NotFound(f"조각 '{chunk_id}' 이(가) 인덱스에 없습니다")
inc = con.execute("SELECT code FROM include WHERE program=? AND include=?",
(r["program"], r["include"])).fetchone()
code = ""
if inc and inc["code"]:
code = "\n".join(inc["code"].split("\n")[r["line_start"] - 1 : r["line_end"]])
u = con.execute("SELECT name, unit_type, line_start, line_end FROM unit WHERE unit_id=?",
(r["unit_id"],)).fetchone()
out = _chunk_row(r)
out["unit_id"] = r["unit_id"]
out["unit_lines"] = f"{u['line_start']}-{u['line_end']}" if u else ""
out["code"] = code
if with_decls:
out.update(_decl_fields(decls_mod.for_chunk(con, r, code)))
return out
finally:
con.close()
def _decl_fields(d: dict) -> dict:
"""index.decls 결과 → API 응답 필드."""
return {
"declarations": [{k: v for k, v in x.items() if k != "depends"} for x in d["declarations"]],
"declaration_code": d["code"],
"declaration_count": d["count"],
"declaration_external_refs": d["external_refs"], # DDIC 등 — 선언을 가져갈 필요가 없다
"declaration_params": d["params"], # FORM 파라미터 — 호출 측에서 들어온다
"declaration_unresolved": d["unresolved"], # 선언을 못 찾은 이름 (수집 누락 가능)
"declaration_truncated": d["truncated"],
}
def list_chunks(program: str, unit_id: str | None = None) -> list[dict]:
con = connect()
try:
sql = "SELECT * FROM logic_chunk WHERE program=?"
params: list[Any] = [program.upper()]
if unit_id:
sql += " AND unit_id=?"
params.append(unit_id)
return [_chunk_row(r) for r in con.execute(sql + " ORDER BY include, line_start", params)]
finally:
con.close()
def search_units(q: str, program: str | None = None, top_k: int = 10) -> list[dict]:
con = connect()
try:
urank = bm25_rank("unit_fts")
sql = "SELECT unit_id, program, name, purpose FROM unit_fts WHERE unit_fts MATCH ?"
params: list[Any] = []
if program:
sql += " AND program=?"
params.append(program.upper())
sql += f" ORDER BY {urank} LIMIT ?"
return [
{**dict(r), "matched_by": "원질의" if w >= 1.0 else "동의어 확장"}
for r, w in _fts_rows(con, sql, params, q, top_k)
][:top_k]
finally:
con.close()
def _structure_summary(con: sqlite3.Connection, program: str) -> dict:
"""LLM 요약이 없을 때 파서 구조 정보로 만드는 대체 요약."""
units = con.execute(
"SELECT u.*, t.ord FROM unit u LEFT JOIN topo t ON t.unit_id=u.unit_id "
"WHERE u.program=? ORDER BY COALESCE(t.ord, 9999), u.include, u.line_start",
(program,),
).fetchall()
tables_read, tables_write, external, sel_params, outputs = set(), set(), set(), [], set()
unit_list = []
for u in units:
refs = loads(u["refs_json"]) or {}
tables_read.update(refs.get("tables_read", []))
tables_write.update(refs.get("tables_write", []))
outputs.update(refs.get("output_signals", []))
sel_params.extend(refs.get("select_params", []))
for c in refs.get("calls", []):
if c["kind"] in EXTERNAL_CALL_KINDS:
external.add(f"{c.get('program', '')}:{c['target']}".lstrip(":"))
if u["unit_type"] in {"FORM", "METHOD", "FUNCTION", "MODULE", "EVENT"}:
summ = loads(u["summary_json"]) or {}
unit_list.append(
{
"unit": u["name"], "unit_type": u["unit_type"], "include": u["include"],
"lines": f"{u['line_start']}-{u['line_end']}", "loc": u["loc"],
"purpose_ko": summ.get("purpose_ko") or clean_comment(u["header_comment"]),
"chunk_count": u["chunk_count"] or 0,
}
)
chunks = [
{"chunk_id": c["chunk_id"], "unit": c["unit_id"].split("#")[3] if c["unit_id"].count("#") >= 3 else "",
"include": c["include"], "lines": f"{c['line_start']}-{c['line_end']}", "kind": c["kind"],
"purpose_ko": c["purpose_ko"]}
for c in con.execute("SELECT * FROM logic_chunk WHERE program=? ORDER BY include, line_start", (program,))
]
# main_flow: 이벤트 unit + 직접 호출 FORM 나열
flow = []
for u in units:
if u["unit_type"] != "EVENT":
continue
refs = loads(u["refs_json"]) or {}
performs = [c["target"] for c in refs.get("calls", []) if c["kind"] == "perform"]
flow.append(f"{u['name']}: " + ("".join(performs[:8]) if performs else "(직접 처리)"))
return {
"units": unit_list,
"logic_chunks": chunks,
"main_flow": flow,
"selection_screen": sel_params[:30],
"tables_read": sorted(tables_read),
"tables_write": sorted(tables_write),
"external_calls": sorted(external)[:30],
"output_type": sorted(outputs),
}
def get_program_summary(name: str) -> dict:
con = connect()
try:
p = _program_or_404(con, name)
base = {
"program": p["name"], "devclass": p["devclass"], "title_ko": p["title_ko"],
"package_text": p["pkg_text"], "changed_on": p["changed_on"],
"has_source": bool(p["has_source"]),
"summary_status": p["summary_status"],
}
llm = loads(p["summary_json"])
if llm:
base["summary"] = llm
if p["has_source"]:
base["structure"] = _structure_summary(con, p["name"])
return base
finally:
con.close()
def get_program_source(name: str) -> dict:
con = connect()
try:
p = _program_or_404(con, name)
rows = con.execute(
"SELECT include, line_count, code FROM include WHERE program=? ORDER BY rowid",
(p["name"],),
).fetchall()
if not rows:
raise NotFound(f"'{name}' 의 소스가 인덱스에 없습니다 (목록만 있는 프로그램)")
return {
"program": p["name"], "title_ko": p["title_ko"],
"includes": [{"include": r["include"], "line_count": r["line_count"], "code": r["code"]} for r in rows],
}
finally:
con.close()
def _find_unit(con: sqlite3.Connection, program: str, unit_name: str) -> sqlite3.Row:
un = unit_name.upper()
row = con.execute(
"SELECT * FROM unit WHERE program=? AND (UPPER(name)=? OR unit_id LIKE ?) "
"ORDER BY CASE unit_type WHEN 'FORM' THEN 0 WHEN 'METHOD' THEN 1 ELSE 2 END LIMIT 1",
(program, un, f"%#{un}"),
).fetchone()
if not row:
cands = con.execute(
"SELECT name FROM unit WHERE program=? AND unit_type IN "
"('FORM','METHOD','FUNCTION','MODULE','EVENT') AND UPPER(name) LIKE ? LIMIT 10",
(program, f"%{un}%"),
).fetchall()
hint = ", ".join(c["name"] for c in cands) or "(유사한 unit 없음)"
raise NotFound(f"unit '{unit_name}' 을 찾을 수 없습니다. 유사: {hint}")
return row
def get_unit_code(program: str, unit_name: str) -> dict:
con = connect()
try:
p = _program_or_404(con, program)
u = _find_unit(con, p["name"], unit_name)
inc = con.execute(
"SELECT code FROM include WHERE program=? AND include=?", (p["name"], u["include"])
).fetchone()
code = ""
if inc and inc["code"]:
lines = inc["code"].split("\n")
code = "\n".join(lines[u["line_start"] - 1 : u["line_end"]])
chunks = [_chunk_row(c) for c in con.execute(
"SELECT * FROM logic_chunk WHERE unit_id=? ORDER BY seq", (u["unit_id"],))]
return {
"unit_id": u["unit_id"], "program": p["name"], "include": u["include"],
"unit_type": u["unit_type"], "name": u["name"],
"line_start": u["line_start"], "line_end": u["line_end"],
"summary": loads(u["summary_json"]),
"chunks": chunks,
"code": code,
**_decl_fields(decls_mod.resolve(con, p["name"], u["unit_id"], code,
self_include=u["include"],
self_range=(u["line_start"], u["line_end"]))),
}
finally:
con.close()
def get_declarations(program: str, scope: str | None = None) -> dict:
"""프로그램의 정의부 전체 — 선언 카탈로그 (TOP 인클루드 전역 + FORM 로컬).
조각 단위로 필요한 만큼만 가져가는 것이 기본(get_chunk)이고, 이건 "이 프로그램의 선언을
통째로 보고 싶다"는 경우다.
"""
con = connect()
try:
p = _program_or_404(con, program)
rows = decls_mod.program_declarations(con, p["name"], scope)
return {
"program": p["name"], "count": len(rows),
"declarations": [{k: v for k, v in r.items() if k != "via"} for r in rows],
}
finally:
con.close()
def trace_variable(program: str, symbol: str) -> dict:
con = connect()
try:
p = _program_or_404(con, program)
sym = symbol.upper().split("-")[0].split("[")[0]
decls = [
dict(r) for r in con.execute(
"SELECT name, scope, unit_id, decl_include, decl_line, type_text, ddic_ref, kind "
"FROM symbol WHERE program=? AND name=?",
(p["name"], sym),
).fetchall()
]
if not decls:
cands = con.execute(
"SELECT DISTINCT name FROM symbol WHERE program=? AND name LIKE ? LIMIT 10",
(p["name"], f"%{sym.strip('<>')}%"),
).fetchall()
hint = ", ".join(c["name"] for c in cands) or "(유사한 심볼 없음)"
raise NotFound(f"심볼 '{symbol}' 선언을 찾을 수 없습니다. 유사: {hint}")
topo_rank = {
r["unit_id"]: r["ord"]
for r in con.execute("SELECT unit_id, ord FROM topo WHERE program=?", (p["name"],)).fetchall()
}
def unit_writes(sym_name: str, depth: int, seen: set[str]) -> list[dict]:
rows = con.execute(
"SELECT * FROM symbol_write WHERE program=? AND symbol=?", (p["name"], sym_name)
).fetchall()
out = []
for w in sorted(rows, key=lambda r: (topo_rank.get(r["unit_id"], 9999), r["line"])):
entry = {
"unit": w["unit_id"].split("#")[-1],
"unit_id": w["unit_id"],
"include": w["include"],
"line": w["line"],
"kind": w["kind"],
"stmt": w["stmt_text"],
"source_symbols": json.loads(w["source_symbols"] or "[]"),
"source_tables": json.loads(w["source_tables"] or "[]"),
}
if w["kind"].startswith("via_perform") and w["callee"] and depth < MAX_TRACE_DEPTH:
callee_key = f"{w['callee']}@{sym_name}"
if callee_key not in seen:
seen.add(callee_key)
entry["callee"] = w["callee"]
entry["callee_writes"] = _callee_writes(con, p["name"], w["callee"], depth + 1, seen, topo_rank)
out.append(entry)
return out
def _callee_writes(con, program, callee, depth, seen, topo_rank) -> list[dict]:
# callee unit 내부의 쓰기 — 파라미터(kind=param) 심볼 우선, 없으면 전체
u = con.execute(
"SELECT unit_id FROM unit WHERE program=? AND UPPER(name)=? AND unit_type='FORM' LIMIT 1",
(program, callee.upper()),
).fetchone()
if not u:
return []
params = {
r["name"] for r in con.execute(
"SELECT name FROM symbol WHERE program=? AND unit_id=? AND kind='param'",
(program, u["unit_id"]),
).fetchall()
}
rows = con.execute(
"SELECT * FROM symbol_write WHERE unit_id=?", (u["unit_id"],)
).fetchall()
selected = [r for r in rows if r["symbol"] in params] or list(rows)[:10]
out = []
for w in sorted(selected, key=lambda r: r["line"]):
e = {
"unit": callee.upper(), "include": w["include"], "line": w["line"],
"symbol": w["symbol"], "kind": w["kind"], "stmt": w["stmt_text"],
"source_tables": json.loads(w["source_tables"] or "[]"),
}
if w["kind"].startswith("via_perform") and w["callee"] and depth < MAX_TRACE_DEPTH:
key = f"{w['callee']}@{w['symbol']}"
if key not in seen:
seen.add(key)
e["callee"] = w["callee"]
e["callee_writes"] = _callee_writes(con, program, w["callee"], depth + 1, seen, topo_rank)
out.append(e)
return out
reads = con.execute(
"SELECT unit_id, include, line FROM symbol_read WHERE program=? AND symbol=? "
"ORDER BY line LIMIT 50",
(p["name"], sym),
).fetchall()
return {
"program": p["name"],
"symbol": sym,
"declarations": decls,
"writes": unit_writes(sym, 0, set()),
"reads": [dict(r) for r in reads],
}
finally:
con.close()
def get_call_graph(program: str, unit: str | None = None) -> dict:
con = connect()
try:
p = _program_or_404(con, program)
sql = "SELECT from_unit, to_unit, external_name, call_type, line FROM call_edge WHERE program=?"
params: list[Any] = [p["name"]]
if unit:
u = _find_unit(con, p["name"], unit)
sql += " AND (from_unit=? OR to_unit=?)"
params += [u["unit_id"], u["unit_id"]]
rows = con.execute(sql + " ORDER BY from_unit, line", params).fetchall()
edges = [
{
"from": r["from_unit"].split("#")[-1],
"to": (r["to_unit"] or "").split("#")[-1] or r["external_name"],
"external": r["to_unit"] is None,
"call_type": r["call_type"],
"line": r["line"],
}
for r in rows
]
return {"program": p["name"], "unit_filter": unit, "edges": edges[:500], "edge_count": len(edges)}
finally:
con.close()
def get_table_usage(table: str) -> dict:
con = connect()
try:
t = table.upper().strip()
rows = con.execute(
"SELECT tr.program, tr.unit_id, tr.mode, u.name AS unit_name, u.unit_type, p.title_ko "
"FROM table_ref tr JOIN unit u ON u.unit_id=tr.unit_id "
"JOIN program p ON p.name=tr.program WHERE tr.table_name=? ORDER BY tr.program",
(t,),
).fetchall()
if not rows:
cands = con.execute(
"SELECT DISTINCT table_name FROM table_ref WHERE table_name LIKE ? LIMIT 10",
(f"%{t}%",),
).fetchall()
return {"table": t, "usages": [], "similar_tables": [c["table_name"] for c in cands]}
return {
"table": t,
"usages": [
{"program": r["program"], "title_ko": r["title_ko"], "unit": r["unit_name"],
"unit_type": r["unit_type"], "mode": r["mode"]}
for r in rows[:200]
],
}
finally:
con.close()
def who_calls(program: str, unit: str) -> dict:
con = connect()
try:
p = _program_or_404(con, program)
u = _find_unit(con, p["name"], unit)
rows = con.execute(
"SELECT from_unit, call_type, line FROM call_edge WHERE to_unit=?", (u["unit_id"],)
).fetchall()
ext = con.execute(
"SELECT program, from_unit, call_type, line FROM call_edge WHERE external_name LIKE ?",
(f"%{p['name']}:{u['name'].upper()}%",),
).fetchall()
return {
"unit_id": u["unit_id"],
"callers": [{"from": r["from_unit"].split("#")[-1], "call_type": r["call_type"], "line": r["line"]} for r in rows]
+ [{"from": f"{r['program']}:{r['from_unit'].split('#')[-1]}", "call_type": r["call_type"], "line": r["line"]} for r in ext],
}
finally:
con.close()