diff --git a/5_django_backend/.env.example b/5_django_backend/.env.example index c30c7b4..3f84263 100644 --- a/5_django_backend/.env.example +++ b/5_django_backend/.env.example @@ -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 diff --git a/5_django_backend/README.md b/5_django_backend/README.md index 6c3f13e..19b6a2d 100644 --- a/5_django_backend/README.md +++ b/5_django_backend/README.md @@ -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 이 이미 있어 재활용 가능. diff --git a/5_django_backend/opencode/.opencode/tools/abap_index.ts b/5_django_backend/opencode/.opencode/tools/abap_index.ts new file mode 100644 index 0000000..6b45a53 --- /dev/null +++ b/5_django_backend/opencode/.opencode/tools/abap_index.ts @@ -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 } }).process?.env ?? {} +const BASE = (ENV.ABAP_INDEX_URL || "http://127.0.0.1:8100").replace(/\/$/, "") + +async function get(path: string, params: Record = {}) { + 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) + } + }, +}) diff --git a/5_django_backend/opencode/AGENTS.md b/5_django_backend/opencode/AGENTS.md index 657cde4..3d4e861 100644 --- a/5_django_backend/opencode/AGENTS.md +++ b/5_django_backend/opencode/AGENTS.md @@ -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 는 도구 실행용이 아니라 대화용이다. diff --git a/z-my-docs/work-log/2026-09/2026-09-21.md b/z-my-docs/work-log/2026-09/2026-09-21.md index 4850bd8..78cc7fb 100644 --- a/z-my-docs/work-log/2026-09/2026-09-21.md +++ b/z-my-docs/work-log/2026-09/2026-09-21.md @@ -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조회/생성 코드 구분해 코드 위에 표기. 서버 없을 때 '출처: 없음 — 생성 코드' 로 답하는 것 실측 |