feat(opencode): ABAP 소스 인덱스 툴 + 답변 출처 표기 규칙

- .opencode/tools/abap_index.ts: ABAP_INDEXING 질의 서버(:8100) 를 툴 3개로 —
  abap_index_search(로직 조각 검색) / abap_index_chunk(원문+정의부) / abap_index_source(전체 소스·요약)
- AGENTS.md: 코드 질문은 인덱스 먼저, 코드블럭 위에 [출처: 프로그램/unit L줄] · [출처: SAP 조회] · [출처: 없음 — 생성 코드]
- 서버 없으면 툴이 실패 문자열을 주고 에이전트는 생성 코드로. 실측: 인덱스 미연결 상태에서 '[출처: 없음 — 생성 코드]' 표기 확인
- .env ABAP_INDEX_URL, README 절차. .opencode/package.json 은 gitignore 라 고객사엔 폴더째 옮겨야 함

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
lee-hyeon-cheol
2026-09-21 14:37:26 +09:00
co-authored by Claude Fable 5.1
parent 89fc8fab45
commit fa172d1413
5 changed files with 146 additions and 0 deletions
@@ -0,0 +1,115 @@
// ABAP 인덱스 서버(ABAP_INDEXING, :8100) 를 OpenCode 툴로.
// 고객사 실제 소스에서 로직 조각을 찾아 "출처 있는 코드"로 답하게 하는 게 목적.
// 파일명이 툴 이름 접두 → abap_index_search / abap_index_chunk / abap_index_source.
// 서버 주소는 ABAP_INDEX_URL (기본 127.0.0.1:8100). 서버 없으면 툴이 에러 문자열을 돌려주고 에이전트는 "인덱스 없음"으로 답함.
import { tool } from "@opencode-ai/plugin"
// Bun 런타임엔 process 가 있지만 이 폴더엔 node 타입이 없어 globalThis 로 꺼냄.
const ENV = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env ?? {}
const BASE = (ENV.ABAP_INDEX_URL || "http://127.0.0.1:8100").replace(/\/$/, "")
async function get(path: string, params: Record<string, string | number | undefined> = {}) {
const url = new URL(BASE + path)
for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== "") url.searchParams.set(k, String(v))
const res = await fetch(url, { signal: AbortSignal.timeout(15_000) })
if (!res.ok) {
const body = await res.text().catch(() => "")
throw new Error(`index ${res.status}: ${body.slice(0, 300)}`)
}
return res.json()
}
function fail(e: unknown): string {
const msg = e instanceof Error ? e.message : String(e)
return `[인덱스 조회 실패] ${msg}\n인덱스 서버(ABAP_INDEX_URL)가 안 떠 있거나 항목이 없음. 이 경우 답변에 "인덱스에 없음 — 생성 코드" 라고 표시할 것.`
}
type Chunk = {
chunk_id: string; program: string; unit: string; unit_type: string
line_start: number; line_end: number; kind: string
purpose_ko: string; tables_read: string[]; tables_write: string[]; calls: string[]
score?: number; matched_by?: string
}
export const search = tool({
description:
"고객사 ABAP 소스 인덱스에서 로직 조각을 검색한다. 사용자가 '~하는 코드 있어?', '~로직 어디 있어?', 특정 테이블·BAPI 쓰는 코드를 물으면 코드를 지어내기 전에 먼저 이걸 부른다. 결과의 chunk_id 로 abap_index_chunk 를 불러 원문·정의부를 가져온다.",
args: {
query: tool.schema.string().describe("자연어 질의 또는 키워드. 한국어·SAP 객체명(테이블·BAPI·T-code) 섞어도 됨"),
program: tool.schema.string().optional().describe("특정 프로그램으로 한정할 때 (예: ZFIR10070)"),
top_k: tool.schema.number().optional().describe("최대 결과 수, 기본 8"),
},
async execute(args) {
try {
const data = await get("/search/logic", { q: args.query, program: args.program, top_k: args.top_k ?? 8 })
const programs: { program: string; title_ko: string; purpose: string; chunks: Chunk[] }[] = data.programs ?? []
if (programs.length === 0) return `검색 결과 없음 (query="${args.query}"). 답변에 "인덱스에 없음 — 생성 코드" 라고 표시할 것.`
const lines: string[] = [`${data.total ?? programs.length}건. 프로그램별:`]
for (const p of programs) {
lines.push(`\n## ${p.program}${p.title_ko || ""}${p.purpose ? `\n${p.purpose}` : ""}`)
for (const c of p.chunks) {
const tables = [...c.tables_read.map((t) => `R:${t}`), ...c.tables_write.map((t) => `W:${t}`)].join(" ")
lines.push(
`- chunk_id=${c.chunk_id} | ${c.unit_type} ${c.unit} L${c.line_start}-${c.line_end} | ${c.kind}` +
`${c.matched_by === "동의어 확장" ? " (동의어)" : ""}\n ${c.purpose_ko}${tables ? `\n tables: ${tables}` : ""}${c.calls.length ? `\n calls: ${c.calls.join(", ")}` : ""}`,
)
}
}
return lines.join("\n")
} catch (e) {
return fail(e)
}
},
})
export const chunk = tool({
description:
"로직 조각 하나의 코드 원문 + 붙여넣기에 필요한 선언(정의부)을 가져온다. abap_index_search 결과의 chunk_id 를 넣는다. 답변할 때 코드 위에 출처(프로그램/unit/줄)를 반드시 적는다.",
args: {
chunk_id: tool.schema.string().describe("abap_index_search 가 준 chunk_id 그대로"),
decls: tool.schema.boolean().optional().describe("정의부(선언) 포함 여부, 기본 true"),
},
async execute(args) {
try {
const d = await get(`/chunks/${encodeURIComponent(args.chunk_id)}`, { decls: args.decls === false ? "false" : undefined })
const src = `[출처: ${d.program} / ${d.unit_type ?? ""} ${d.unit ?? ""} L${d.line_start}-${d.line_end}]`
const parts = [src, d.purpose_ko ? `목적: ${d.purpose_ko}` : "", "```abap", d.code ?? "", "```"]
if (d.declaration_code) {
parts.push(
`\n정의부(선언, ${d.declaration_count ?? "?"}${d.declaration_truncated ? ", 일부 생략" : ""}) — 로직만 붙이면 컴파일 안 되니 필요한 것만 골라 TOP 에:`,
"```abap",
d.declaration_code,
"```",
)
if (d.declaration_unresolved?.length) parts.push(`미해결 참조: ${d.declaration_unresolved.join(", ")}`)
}
return parts.filter((x) => x !== "").join("\n")
} catch (e) {
return fail(e)
}
},
})
export const source = tool({
description:
"프로그램 전체 소스(정규화본)를 가져온다. 길 수 있으니 조각(abap_index_chunk)으로 안 될 때만. 프로그램 요약이 필요하면 summary=true.",
args: {
program: tool.schema.string().describe("프로그램명 (예: ZFIR10070)"),
summary: tool.schema.boolean().optional().describe("true 면 전체 소스 대신 LLM 요약 + 구조 요약"),
},
async execute(args) {
try {
const name = args.program.toUpperCase()
if (args.summary) {
const d = await get(`/programs/${encodeURIComponent(name)}/summary`)
return `[출처: ${name} 요약]\n` + JSON.stringify(d, null, 1).slice(0, 12_000)
}
const d = await get(`/programs/${encodeURIComponent(name)}/source`)
const code: string = d.source ?? d.code ?? JSON.stringify(d)
const cut = code.length > 40_000
return `[출처: ${name} 전체 소스${cut ? ", 40k자에서 잘림" : ""}]\n\`\`\`abap\n${code.slice(0, 40_000)}\n\`\`\``
} catch (e) {
return fail(e)
}
},
})