Files
ABAP-Indexing/query/tools.py
T

615 lines
26 KiB
Python

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