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