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:
co-authored by
Claude Fable 5.1
parent
89fc8fab45
commit
fa172d1413
@@ -56,3 +56,7 @@ SAP_PASSWORD_ICF=
|
||||
SAP_CLIENT_ICF=100
|
||||
SAP_VERIFY_SSL_ICF=false
|
||||
SAP_API_BASE_ICF=
|
||||
|
||||
# ── ABAP 소스 인덱스(ABAP_INDEXING, 별도 서버 :8100) — OpenCode 툴 abap_index_* 가 씀 ──
|
||||
# 비우면 기본 127.0.0.1:8100. 서버가 없어도 앱은 돌아감(툴이 "없음"으로 답하고 생성 코드로).
|
||||
ABAP_INDEX_URL=http://127.0.0.1:8100
|
||||
|
||||
@@ -125,3 +125,18 @@ curl -s -X POST http://127.0.0.1:8888/mcp -H "Authorization: Bearer $MCP_API_KEY
|
||||
|
||||
- 로컬 개발에선 `MCP_API_KEY` 를 비워두면 `sap-icf` 가 `enabled:false` 로 렌더돼 OpenCode 가 MCP 를 안 찾음.
|
||||
- MCP 가 zai 모드로 뜨는지는 `logs/mcp.log` 에 `mode=zai` 찍히는 걸로. 고객사 SAP 은 zdict 가 아니라 ZAI_ICF 라 `SAP_API_BASE_ICF` 를 -13 값과 같게.
|
||||
|
||||
## ABAP 소스 인덱스 붙이기 (출처 있는 코드)
|
||||
|
||||
`ABAP_INDEXING` 레포의 질의 서버(:8100)를 OpenCode 커스텀 툴로 붙임 — `opencode/.opencode/tools/abap_index.ts` (툴 3개: `abap_index_search`·`abap_index_chunk`·`abap_index_source`). 에이전트는 코드 질문에 인덱스를 먼저 찾고, 코드 위에 `[출처: 프로그램 / unit L줄]` 을 적음. 없으면 `[출처: 없음 — 생성 코드]`.
|
||||
|
||||
```bash
|
||||
# 인덱스 서버 (ABAP_INDEXING 레포, data/index.db 가 있어야 함)
|
||||
cd ../ABAP_INDEXING && .venv/Scripts/python -m query.api # 127.0.0.1:8100
|
||||
# CodeAssist 쪽: .env 의 ABAP_INDEX_URL (기본 그대로면 됨) → OpenCode 재시작. 툴 목록 확인:
|
||||
curl -s http://127.0.0.1:4096/config 2>/dev/null | grep -o abap_index_[a-z]* | sort -u
|
||||
```
|
||||
|
||||
- 인덱스 서버가 없으면 툴이 "인덱스 조회 실패"를 돌려주고 에이전트는 생성 코드로 답함. 앱은 안 죽음.
|
||||
- `opencode/.opencode/package.json`(`@opencode-ai/plugin` 의존)은 그 폴더 `.gitignore` 에 걸려 git 에 없음. 고객사엔 `.opencode/` 폴더째(node_modules 포함, 약 수 MB) 같이 옮겨야 툴이 로드됨. 없으면 OpenCode 가 툴 파일을 import 못 해 조용히 빠짐 — `/experimental/tool/ids` 로 확인.
|
||||
- 고객사(-12): ABAP_INDEXING 을 -12 에 같이 올리고(`data/index.db` 포함, 약 14MB) 8100 으로 띄움. 오프라인 wheels 는 fastapi·uvicorn 이 이미 있어 재활용 가능.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -12,6 +12,17 @@
|
||||
- 이미지(화면 캡처)가 오면 그 안의 코드·에러·화면을 읽고 답한다. 이미지에 대해 묘사하지 말고 바로 문제 해결로.
|
||||
- 모르는 SAP 오브젝트는 지어내지 않는다. 도구(sap-icf MCP)가 연결돼 있으면 조회하고, 없으면 모른다고 하고 확인 방법(T-code, 테이블)을 알려준다.
|
||||
|
||||
## 출처 — 실제 소스 먼저, 생성은 마지막
|
||||
|
||||
- "~하는 코드 있어?", "~로직 어디 있어?", 특정 테이블·BAPI·펑션을 쓰는 코드를 물으면 **코드를 짓기 전에 `abap_index_search` 를 먼저 부른다.** 결과가 있으면 `abap_index_chunk` 로 원문·정의부를 가져와 그걸 답으로 쓴다.
|
||||
- 코드블럭 **바로 위 줄에 출처를 반드시 적는다.** 형식 셋 중 하나:
|
||||
- `[출처: ZFIR10070 / FORM get_data L120-165]` — 인덱스(고객사 실제 소스)에서 온 것
|
||||
- `[출처: SAP 조회 — 테이블 T000]` — sap-icf 도구 조회 결과
|
||||
- `[출처: 없음 — 생성 코드]` — 인덱스·SAP 에 없어서 직접 만든 것. **이 줄을 빼먹지 않는다.**
|
||||
- 인덱스 조각은 로직만이라 정의부(선언)가 따로 온다. 사용자가 붙여넣을 걸 생각해서 필요한 선언만 골라 코드 위에 같이 준다. 안 쓰는 선언은 버린다.
|
||||
- 인덱스 서버가 죽어 있거나(툴이 "[인덱스 조회 실패]" 를 돌려주면) 결과가 없으면 그냥 생성 코드로 답하고 출처를 `없음 — 생성 코드` 로 적는다. 서버 상태를 사용자에게 길게 설명하지 않는다.
|
||||
- 여러 조각이 나오면 가장 맞는 하나를 골라 답하고, 다른 후보는 한 줄로만 "다른 곳: ZFIR20010 FORM …" 식으로 언급한다.
|
||||
|
||||
## 하지 않는 것
|
||||
|
||||
- 파일을 만들거나 수정하지 않는다. 이 workspace 는 도구 실행용이 아니라 대화용이다.
|
||||
|
||||
@@ -11,3 +11,4 @@
|
||||
| 13:39 | 관리자 대시보드 — 백엔드 /admin/stats 집계(총 토큰·비용·평균 응답, 사용자별·일별, superuser 만) + 앱 /admin 페이지(카드 4·일별 막대·사용자 표·기간 프리셋). 헤더에 관리자만 아이콘 |
|
||||
| 14:04 | 고객사 -12 를 PostgreSQL 로 전환 — -13 과 같은 외부 PG 서버(8851), 스키마 codeassist 분리. 내부 PG 가 SSL 미지원이라 sslmode 를 env 화(prefer). SAP MCP 도 -12 에 별도 기동, 도구 6개 확인 |
|
||||
| 14:13 | 답변 완료 알림 — 창 안 보고 있으면 Windows 토스트 + 트레이 아이콘 파란 점, 창 포커스 받으면 원복. tauri-plugin-notification, 프론트 onDone 에서 chat.done 한 줄 |
|
||||
| 14:37 | ABAP 소스 인덱스(ABAP_INDEXING :8100)를 OpenCode 커스텀 툴 3개로 연결(abap_index_search/chunk/source). AGENTS.md 에 출처 규칙 — 인덱스/SAP조회/생성 코드 구분해 코드 위에 표기. 서버 없을 때 '출처: 없음 — 생성 코드' 로 답하는 것 실측 |
|
||||
|
||||
Reference in New Issue
Block a user