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