Initial commit: ABAP indexing pipeline (ingest, parser, summarize, index, query, wiki)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
"""로직 조각 후처리 — LLM 이 준 조각을 코드와 대조해 확정한다 (docs/logic-chunk-design.md).
|
||||
|
||||
원칙: 사실은 파서가, 해석은 LLM 이.
|
||||
- 줄 번호: first_line 앵커로 검증·보정, 실패하면 버림
|
||||
- 테이블 · 호출: 조각 코드 범위를 파서로 다시 돌려 채움 (LLM 값은 쓰지 않음)
|
||||
- 해시: 조각 코드 sha256
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from parser.refs import extract_refs
|
||||
from parser.statements import split_statements
|
||||
|
||||
from .schemas import CHUNK_KINDS, LogicChunk
|
||||
|
||||
MIN_LINES = 1
|
||||
_WS = re.compile(r"\s+")
|
||||
_SQL_HEAD = re.compile(
|
||||
r"^\s*(SELECT|OPEN\s+CURSOR|INSERT|UPDATE|MODIFY|DELETE|COMMIT\s+WORK|ROLLBACK\s+WORK|"
|
||||
r"CALL\s+FUNCTION|CALL\s+TRANSACTION|CALL\s+METHOD|SUBMIT|PERFORM|AUTHORITY-CHECK|"
|
||||
r"LOOP\s+AT|COLLECT|MESSAGE)\b",
|
||||
re.I,
|
||||
)
|
||||
|
||||
|
||||
def _norm(s: str) -> str:
|
||||
return _WS.sub(" ", (s or "").strip()).lower()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResolvedChunk:
|
||||
seq: int
|
||||
line_start: int
|
||||
line_end: int
|
||||
code: str
|
||||
code_hash: str
|
||||
kind: str
|
||||
purpose_ko: str
|
||||
purpose_en: str
|
||||
keywords_ko: list[str]
|
||||
keywords_en: list[str]
|
||||
sap_objects: list[str]
|
||||
tables_read: list[str] = field(default_factory=list)
|
||||
tables_write: list[str] = field(default_factory=list)
|
||||
calls: list[str] = field(default_factory=list)
|
||||
confidence: float = 0.5
|
||||
|
||||
|
||||
def numbered_code(lines: list[str], line_start: int, line_end: int) -> str:
|
||||
"""include 전체 줄 목록에서 [line_start, line_end] 를 ' 12| code' 형식으로."""
|
||||
out = []
|
||||
for no in range(line_start, line_end + 1):
|
||||
if 1 <= no <= len(lines):
|
||||
out.append(f"{no:5d}| {lines[no - 1]}")
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def parser_hints(lines: list[str], line_start: int, line_end: int, limit: int = 40) -> list[str]:
|
||||
"""DB 접근 · 호출 · 검증 문장이 시작되는 줄 — LLM 이 자를 후보 지점 힌트."""
|
||||
hints = []
|
||||
for no in range(line_start, line_end + 1):
|
||||
if 1 <= no <= len(lines):
|
||||
m = _SQL_HEAD.match(lines[no - 1])
|
||||
if m:
|
||||
hints.append(f"L{no} {lines[no - 1].strip()[:80]}")
|
||||
if len(hints) >= limit:
|
||||
break
|
||||
return hints
|
||||
|
||||
|
||||
def _find_anchor(lines: list[str], anchor: str, lo: int, hi: int) -> int | None:
|
||||
"""[lo, hi] 안에서 anchor(정규화) 와 같은 줄 번호. 없으면 접두 일치."""
|
||||
a = _norm(anchor)
|
||||
if not a:
|
||||
return None
|
||||
for no in range(lo, hi + 1):
|
||||
if _norm(lines[no - 1]) == a:
|
||||
return no
|
||||
head = a[:20]
|
||||
if len(head) >= 8:
|
||||
for no in range(lo, hi + 1):
|
||||
if _norm(lines[no - 1]).startswith(head):
|
||||
return no
|
||||
return None
|
||||
|
||||
|
||||
def resolve_chunks(raw: list[LogicChunk], lines: list[str], unit_start: int, unit_end: int,
|
||||
include: str, known_symbols: set[str]) -> tuple[list[ResolvedChunk], list[str]]:
|
||||
"""LLM 조각 → 검증·보정·사실 채움. 반환: (확정 조각(줄 순), 버린 사유 목록)."""
|
||||
dropped: list[str] = []
|
||||
resolved: list[ResolvedChunk] = []
|
||||
seen_ranges: set[tuple[int, int]] = set()
|
||||
|
||||
for c in raw:
|
||||
s, e = int(c.line_start), int(c.line_end)
|
||||
if e < s:
|
||||
s, e = e, s
|
||||
length = e - s
|
||||
|
||||
# 1) 앵커 검증 — line_start 줄이 first_line 과 다르면 unit 안에서 찾아 보정
|
||||
if c.first_line:
|
||||
ok = unit_start <= s <= unit_end and _norm(lines[s - 1]) == _norm(c.first_line) \
|
||||
if 1 <= s <= len(lines) else False
|
||||
if not ok:
|
||||
found = _find_anchor(lines, c.first_line, unit_start, unit_end)
|
||||
if found is None:
|
||||
dropped.append(f"L{s}-L{e}: first_line 을 unit 안에서 찾지 못함 ({c.first_line[:40]!r})")
|
||||
continue
|
||||
s, e = found, found + length
|
||||
|
||||
# 2) 범위 클램프
|
||||
if s < unit_start or s > unit_end:
|
||||
dropped.append(f"L{s}-L{e}: unit 범위(L{unit_start}-L{unit_end}) 밖")
|
||||
continue
|
||||
e = min(e, unit_end)
|
||||
if e - s + 1 < MIN_LINES:
|
||||
dropped.append(f"L{s}-L{e}: 너무 짧음")
|
||||
continue
|
||||
if (s, e) in seen_ranges:
|
||||
dropped.append(f"L{s}-L{e}: 중복 범위")
|
||||
continue
|
||||
seen_ranges.add((s, e))
|
||||
|
||||
# 3) 코드 · 해시 · 파서 사실
|
||||
code = "\n".join(lines[s - 1 : e])
|
||||
stmts, _ = split_statements(code, include)
|
||||
refs = extract_refs(stmts, list(range(len(stmts))), set(), known_symbols)
|
||||
kind = c.kind if c.kind in CHUNK_KINDS else "other"
|
||||
resolved.append(ResolvedChunk(
|
||||
seq=0, line_start=s, line_end=e, code=code,
|
||||
code_hash=hashlib.sha256(code.encode("utf-8")).hexdigest(),
|
||||
kind=kind, purpose_ko=c.purpose_ko.strip(), purpose_en=c.purpose_en.strip(),
|
||||
keywords_ko=list(dict.fromkeys(k.strip() for k in c.keywords_ko if k.strip()))[:15],
|
||||
keywords_en=list(dict.fromkeys(k.strip() for k in c.keywords_en if k.strip()))[:15],
|
||||
sap_objects=list(dict.fromkeys(k.strip().upper() for k in c.sap_objects if k.strip()))[:20],
|
||||
tables_read=refs.tables_read, tables_write=refs.tables_write,
|
||||
calls=[x["target"] for x in refs.calls][:20],
|
||||
confidence=max(0.0, min(1.0, float(c.confidence))),
|
||||
))
|
||||
|
||||
resolved.sort(key=lambda r: (r.line_start, r.line_end))
|
||||
for i, r in enumerate(resolved, 1):
|
||||
r.seq = i
|
||||
return resolved, dropped
|
||||
|
||||
|
||||
def covered_lines(chunks: list[ResolvedChunk]) -> int:
|
||||
covered: set[int] = set()
|
||||
for c in chunks:
|
||||
covered.update(range(c.line_start, c.line_end + 1))
|
||||
return len(covered)
|
||||
@@ -0,0 +1,204 @@
|
||||
"""LLM 작업 큐 CLI — LLM 키 없이 요약/조각 추출을 돌리는 입구.
|
||||
|
||||
`--llm file` 로 러너를 돌리면 프롬프트가 `data/llm_jobs/` 에 파일로 쌓인다.
|
||||
사람이든 코딩 에이전트든 그 프롬프트를 읽고 응답 JSON 을 채워 넣으면, 러너를 다시 돌릴 때
|
||||
그 응답으로 파이프라인이 이어진다.
|
||||
|
||||
# 1) 프롬프트 내놓기 (LLM 호출 0회)
|
||||
python -m summarize.runner --llm file --program ZFIR10070 --limit 3
|
||||
|
||||
# 2) 대기 목록 보기 / 프롬프트 읽기
|
||||
python -m summarize.jobs list
|
||||
python -m summarize.jobs show <job_id>
|
||||
|
||||
# 3) 응답 채우기 (스키마 검증 후 저장)
|
||||
python -m summarize.jobs answer <job_id> --file answer.json
|
||||
python -m summarize.jobs answer <job_id> --stdin < answer.json
|
||||
|
||||
# 4) 같은 명령을 다시 — 이번엔 응답을 읽어 조각을 적재한다
|
||||
python -m summarize.runner --llm file --program ZFIR10070 --limit 3
|
||||
|
||||
`answer` 는 저장 전에 프롬프트의 [작업] 종류에 맞는 pydantic 스키마로 검증한다 —
|
||||
잘못된 JSON 을 큐에 넣어두고 나중에 러너에서 실패하는 걸 막는다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from config.settings import settings
|
||||
|
||||
from .llm_client import JOB_INDEX
|
||||
from .schemas import ProgramSummary, UnitExtraction
|
||||
|
||||
TASK_SCHEMA = {"program_summary": ProgramSummary, "extract_chunks": UnitExtraction}
|
||||
|
||||
|
||||
def jobs_dir(override: str | None = None) -> Path:
|
||||
return Path(override) if override else Path(settings.data_llm_jobs)
|
||||
|
||||
|
||||
def _index_rows(d: Path) -> list[dict]:
|
||||
idx = d / JOB_INDEX
|
||||
if not idx.exists():
|
||||
return []
|
||||
rows = []
|
||||
for line in idx.read_text(encoding="utf-8").splitlines():
|
||||
if line.strip():
|
||||
try:
|
||||
rows.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return rows
|
||||
|
||||
|
||||
def _status(d: Path, row: dict) -> str:
|
||||
return "answered" if (d / f"{row['job_id']}.response.json").exists() else "pending"
|
||||
|
||||
|
||||
def task_of(d: Path, jid: str) -> str:
|
||||
"""프롬프트 파일에서 [작업] 종류를 읽는다."""
|
||||
p = d / f"{jid}.prompt.md"
|
||||
if not p.exists():
|
||||
return ""
|
||||
m = re.search(r"^\[작업\]\s*(\S+)", p.read_text(encoding="utf-8"), re.M)
|
||||
return m.group(1) if m else ""
|
||||
|
||||
|
||||
def cmd_list(args) -> int:
|
||||
d = jobs_dir(args.dir)
|
||||
rows = _index_rows(d)
|
||||
if not rows:
|
||||
print(f"작업이 없습니다: {d}\n먼저 실행: python -m summarize.runner --llm file --program <PROG> --limit 3")
|
||||
return 0
|
||||
shown = 0
|
||||
for r in rows:
|
||||
st = _status(d, r)
|
||||
if args.pending and st != "pending":
|
||||
continue
|
||||
print(f"{r['job_id']} {st:9s} {r.get('task',''):16s} {r.get('program','')} "
|
||||
f"{r.get('unit_id','') or '(프로그램 요약)'}"
|
||||
+ (f" 창{r['window']}" if r.get("window") else ""))
|
||||
shown += 1
|
||||
n_pending = sum(1 for r in rows if _status(d, r) == "pending")
|
||||
print(f"\n총 {len(rows)}건 (표시 {shown}) — 대기 {n_pending}, 응답완료 {len(rows) - n_pending}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_show(args) -> int:
|
||||
d = jobs_dir(args.dir)
|
||||
p = d / f"{args.job_id}.prompt.md"
|
||||
if not p.exists():
|
||||
print(f"프롬프트 없음: {p}", file=sys.stderr)
|
||||
return 1
|
||||
sys.stdout.write(p.read_text(encoding="utf-8"))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_answer(args) -> int:
|
||||
d = jobs_dir(args.dir)
|
||||
prompt = d / f"{args.job_id}.prompt.md"
|
||||
if not prompt.exists():
|
||||
print(f"프롬프트 없음: {prompt}", file=sys.stderr)
|
||||
return 1
|
||||
if args.stdin:
|
||||
raw = sys.stdin.read()
|
||||
else:
|
||||
try:
|
||||
raw = Path(args.file).read_text(encoding="utf-8")
|
||||
except OSError as e:
|
||||
print(f"응답 파일을 읽을 수 없습니다: {e}", file=sys.stderr)
|
||||
return 1
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError as e:
|
||||
s, e2 = raw.find("{"), raw.rfind("}")
|
||||
if s < 0 or e2 <= s:
|
||||
print(f"JSON 파싱 실패: {e}", file=sys.stderr)
|
||||
return 1
|
||||
data = json.loads(raw[s : e2 + 1])
|
||||
|
||||
task = task_of(d, args.job_id)
|
||||
schema = TASK_SCHEMA.get(task)
|
||||
if schema and not args.no_validate:
|
||||
try:
|
||||
schema.model_validate(data)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[스키마 불일치] task={task}\n{e}", file=sys.stderr)
|
||||
print("\n무시하고 저장하려면 --no-validate", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
out = d / f"{args.job_id}.response.json"
|
||||
out.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"저장: {out} (task={task or '?'}, 검증={'skip' if args.no_validate else 'ok'})")
|
||||
n_pending = sum(1 for r in _index_rows(d) if _status(d, r) == "pending")
|
||||
print(f"남은 대기: {n_pending}건 — 전부 채우면 러너를 같은 옵션으로 다시 실행하세요")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_stats(args) -> int:
|
||||
d = jobs_dir(args.dir)
|
||||
rows = _index_rows(d)
|
||||
by_task: dict[str, list[int]] = {}
|
||||
for r in rows:
|
||||
t = r.get("task") or "?"
|
||||
b = by_task.setdefault(t, [0, 0])
|
||||
b[0 if _status(d, r) == "pending" else 1] += 1
|
||||
print(json.dumps({
|
||||
"dir": str(d),
|
||||
"total": len(rows),
|
||||
"pending": sum(b[0] for b in by_task.values()),
|
||||
"answered": sum(b[1] for b in by_task.values()),
|
||||
"by_task": {k: {"pending": v[0], "answered": v[1]} for k, v in sorted(by_task.items())},
|
||||
}, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_clear(args) -> int:
|
||||
d = jobs_dir(args.dir)
|
||||
n = 0
|
||||
for p in list(d.glob("*.prompt.md")) + list(d.glob("*.response.json")) + [d / JOB_INDEX]:
|
||||
if p.exists() and (not args.answered_only or p.name.endswith(".response.json")):
|
||||
p.unlink()
|
||||
n += 1
|
||||
print(f"삭제 {n}개 파일 ({d})")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description="LLM 작업 큐 (키 없이 요약 돌리기)")
|
||||
ap.add_argument("--dir", default=None, help=f"작업 디렉토리 (기본 {settings.data_llm_jobs})")
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
p = sub.add_parser("list", help="작업 목록")
|
||||
p.add_argument("--pending", action="store_true", help="대기 중인 것만")
|
||||
p.set_defaults(func=cmd_list)
|
||||
|
||||
p = sub.add_parser("show", help="프롬프트 출력")
|
||||
p.add_argument("job_id")
|
||||
p.set_defaults(func=cmd_show)
|
||||
|
||||
p = sub.add_parser("answer", help="응답 JSON 저장 (스키마 검증)")
|
||||
p.add_argument("job_id")
|
||||
g = p.add_mutually_exclusive_group(required=True)
|
||||
g.add_argument("--file", help="응답 JSON 파일")
|
||||
g.add_argument("--stdin", action="store_true", help="표준입력에서 읽기")
|
||||
p.add_argument("--no-validate", action="store_true")
|
||||
p.set_defaults(func=cmd_answer)
|
||||
|
||||
p = sub.add_parser("stats", help="큐 통계")
|
||||
p.set_defaults(func=cmd_stats)
|
||||
|
||||
p = sub.add_parser("clear", help="큐 비우기")
|
||||
p.add_argument("--answered-only", action="store_true")
|
||||
p.set_defaults(func=cmd_clear)
|
||||
|
||||
args = ap.parse_args()
|
||||
sys.exit(args.func(args))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,269 @@
|
||||
"""LLM 클라이언트 추상화. 백엔드 3종 — api / file / fake.
|
||||
|
||||
실제 모델 정보(LLM_BASE_URL/LLM_API_KEY/LLM_MODEL)는 환경변수에서만 읽는다 (계획서 §11.5).
|
||||
|
||||
## 백엔드
|
||||
|
||||
- `api` : OpenAI 호환 `/chat/completions`. 429/5xx 는 지수 백오프로 재시도한다.
|
||||
- `file` : **키 없이 돌리는 입구.** 프롬프트를 `data/llm_jobs/` 에 파일로 내놓고, 누군가(사람이든
|
||||
에이전트든)가 응답 JSON 을 채워 넣으면 그걸 읽어 파이프라인을 계속한다.
|
||||
키가 없는 상태에서 몇 건만 실제 품질로 돌려보려면 이 경로를 쓴다 → `summarize/jobs.py`
|
||||
- `fake` : 스키마만 맞는 더미. 파이프라인 배선 검증용(품질 검증 아님).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from config.settings import settings
|
||||
|
||||
|
||||
class LLMClient:
|
||||
def complete_json(self, system: str, user: str) -> dict:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class PendingResponse(Exception):
|
||||
"""file 백엔드에서 아직 응답이 채워지지 않은 프롬프트 — 실패가 아니라 '대기'다.
|
||||
|
||||
runner 는 이걸 failed 로 기록하지 않고 대기 건수만 집계한다.
|
||||
"""
|
||||
|
||||
def __init__(self, job_id: str, prompt_path: Path, response_path: Path) -> None:
|
||||
super().__init__(f"응답 대기: {job_id} → {response_path}")
|
||||
self.job_id = job_id
|
||||
self.prompt_path = prompt_path
|
||||
self.response_path = response_path
|
||||
|
||||
|
||||
def _extract_json(content: str) -> dict:
|
||||
"""모델이 코드펜스/서문을 붙이는 경우까지 감안해 JSON 을 뽑아낸다."""
|
||||
try:
|
||||
return json.loads(content)
|
||||
except json.JSONDecodeError:
|
||||
s, e = content.find("{"), content.rfind("}")
|
||||
if s >= 0 and e > s:
|
||||
return json.loads(content[s : e + 1])
|
||||
raise
|
||||
|
||||
|
||||
class OpenAICompatClient(LLMClient):
|
||||
def __init__(self) -> None:
|
||||
if not settings.llm_base_url or not settings.llm_api_key:
|
||||
raise RuntimeError("LLM_BASE_URL / LLM_API_KEY 환경변수가 필요합니다")
|
||||
self.base = settings.llm_base_url.rstrip("/")
|
||||
self.key = settings.llm_api_key
|
||||
self.model = settings.llm_model
|
||||
# 호출 누적 사용량 — runner가 stats에 실어 보고한다
|
||||
self.usage = {"calls": 0, "prompt_tokens": 0, "completion_tokens": 0, "cost_usd": 0.0,
|
||||
"retries": 0}
|
||||
self._lock = threading.Lock() # 병렬 호출에서 usage 집계가 어긋나지 않게
|
||||
|
||||
def _post_once(self, system: str, user: str) -> dict:
|
||||
body = {
|
||||
"model": self.model,
|
||||
"temperature": 0.1,
|
||||
"messages": [
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user},
|
||||
],
|
||||
"response_format": {"type": "json_object"},
|
||||
}
|
||||
if "openrouter" in self.base:
|
||||
body["usage"] = {"include": True} # OpenRouter 확장 — usage.cost(USD 크레딧) 포함
|
||||
req = urllib.request.Request(
|
||||
f"{self.base}/chat/completions",
|
||||
data=json.dumps(body).encode("utf-8"),
|
||||
headers={"Authorization": f"Bearer {self.key}", "Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=settings.llm_timeout_s) as res:
|
||||
return json.loads(res.read().decode("utf-8"))
|
||||
|
||||
def complete_json(self, system: str, user: str) -> dict:
|
||||
"""429/5xx 재시도 포함. 동시성을 올리면 429 가 늘어나므로 백오프가 필수다.
|
||||
|
||||
(실측: 무료 티어에서 순차 실행만으로도 429 로 unit 6건이 failed 로 굳었다.)
|
||||
"""
|
||||
delay = settings.llm_backoff_base
|
||||
last: Exception | None = None
|
||||
for attempt in range(settings.llm_max_retries + 1):
|
||||
try:
|
||||
data = self._post_once(system, user)
|
||||
break
|
||||
except urllib.error.HTTPError as e:
|
||||
last = e
|
||||
retryable = e.code == 429 or 500 <= e.code < 600
|
||||
if not retryable or attempt >= settings.llm_max_retries:
|
||||
raise
|
||||
wait = delay
|
||||
hdr = (e.headers or {}).get("Retry-After") if hasattr(e, "headers") else None
|
||||
if hdr:
|
||||
try:
|
||||
wait = max(wait, float(hdr))
|
||||
except ValueError:
|
||||
pass
|
||||
time.sleep(min(wait, settings.llm_backoff_max) * (1 + random.random() * 0.25))
|
||||
delay *= 2
|
||||
with self._lock:
|
||||
self.usage["retries"] += 1
|
||||
except (urllib.error.URLError, TimeoutError) as e:
|
||||
last = e
|
||||
if attempt >= settings.llm_max_retries:
|
||||
raise
|
||||
time.sleep(min(delay, settings.llm_backoff_max))
|
||||
delay *= 2
|
||||
with self._lock:
|
||||
self.usage["retries"] += 1
|
||||
else: # pragma: no cover — 위 for 는 break/raise 로만 끝난다
|
||||
raise last or RuntimeError("LLM 호출 실패")
|
||||
|
||||
u = data.get("usage") or {}
|
||||
with self._lock:
|
||||
self.usage["calls"] += 1
|
||||
self.usage["prompt_tokens"] += u.get("prompt_tokens", 0)
|
||||
self.usage["completion_tokens"] += u.get("completion_tokens", 0)
|
||||
self.usage["cost_usd"] += u.get("cost") or 0.0
|
||||
return _extract_json(data["choices"][0]["message"]["content"])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- file 백엔드
|
||||
|
||||
JOB_INDEX = "index.jsonl"
|
||||
|
||||
|
||||
def job_id(system: str, user: str) -> str:
|
||||
"""프롬프트 내용으로 결정되는 id — 같은 프롬프트는 재실행해도 같은 파일을 가리킨다."""
|
||||
return hashlib.sha256((system + "\n\x00\n" + user).encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def _meta_from_prompt(user: str) -> dict:
|
||||
"""프롬프트 본문에서 작업 종류·프로그램·unit 을 긁어 index.jsonl 에 남긴다."""
|
||||
def grab(pattern: str) -> str:
|
||||
m = re.search(pattern, user, re.M)
|
||||
return m.group(1).strip() if m else ""
|
||||
|
||||
return {
|
||||
"task": grab(r"^\[작업\]\s*(\S+)"),
|
||||
"program": grab(r"^\[프로그램\]\s*(\S+)"),
|
||||
# UNIT_PROMPT 는 '[단위] unit_id: ...' 형태라 줄 앞에 [단위] 가 붙는다
|
||||
"unit_id": grab(r"^\[단위\]\s*unit_id:\s*(\S+)"),
|
||||
"unit_type": grab(r"^unit_type:\s*(\w+)"),
|
||||
"window": grab(r"지금은 (\d+)번째 창"),
|
||||
}
|
||||
|
||||
|
||||
class FileQueueLLM(LLMClient):
|
||||
"""프롬프트를 파일로 내놓고 응답 파일을 기다리는 백엔드 (키 불필요).
|
||||
|
||||
complete_json 은
|
||||
- `<id>.response.json` 이 있으면 그 JSON 을 돌려준다 (정상 경로)
|
||||
- 없으면 `<id>.prompt.md` 를 쓰고 PendingResponse 를 던진다
|
||||
|
||||
응답 파일은 사람이 직접 채워도 되고, 코딩 에이전트가 프롬프트를 읽고 채워도 된다.
|
||||
`python -m summarize.jobs` 가 목록·조회·검증 저장을 돕는다.
|
||||
"""
|
||||
|
||||
def __init__(self, jobs_dir: Path | None = None, *, emit: bool = True) -> None:
|
||||
self.dir = Path(jobs_dir or settings.data_llm_jobs)
|
||||
self.emit = emit
|
||||
self.dir.mkdir(parents=True, exist_ok=True)
|
||||
self.usage = {"calls": 0, "prompt_tokens": 0, "completion_tokens": 0, "cost_usd": 0.0,
|
||||
"retries": 0}
|
||||
self.pending: list[str] = []
|
||||
self.answered: list[str] = []
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def prompt_path(self, jid: str) -> Path:
|
||||
return self.dir / f"{jid}.prompt.md"
|
||||
|
||||
def response_path(self, jid: str) -> Path:
|
||||
return self.dir / f"{jid}.response.json"
|
||||
|
||||
def _write_prompt(self, jid: str, system: str, user: str) -> None:
|
||||
meta = _meta_from_prompt(user)
|
||||
header = [
|
||||
"<!-- abap-indexing LLM 작업. 이 파일은 읽기용이다.",
|
||||
f" 응답은 {jid}.response.json 에 JSON 만 써라 (프롬프트 맨 끝 스키마 그대로).",
|
||||
" 저장·검증: python -m summarize.jobs answer " + jid + " --file <응답.json>",
|
||||
f" task={meta['task']} program={meta['program']} unit={meta['unit_id']} "
|
||||
f"window={meta['window'] or '1'} -->",
|
||||
"",
|
||||
"## SYSTEM", "", system, "", "## USER", "", user, "",
|
||||
]
|
||||
self.prompt_path(jid).write_text("\n".join(header), encoding="utf-8")
|
||||
line = json.dumps({"job_id": jid, **meta,
|
||||
"prompt": self.prompt_path(jid).name,
|
||||
"response": self.response_path(jid).name}, ensure_ascii=False)
|
||||
idx = self.dir / JOB_INDEX
|
||||
existing = idx.read_text(encoding="utf-8").splitlines() if idx.exists() else []
|
||||
if not any(f'"job_id": "{jid}"' in ln for ln in existing):
|
||||
with idx.open("a", encoding="utf-8") as f:
|
||||
f.write(line + "\n")
|
||||
|
||||
def complete_json(self, system: str, user: str) -> dict:
|
||||
jid = job_id(system, user)
|
||||
rp = self.response_path(jid)
|
||||
if rp.exists():
|
||||
data = _extract_json(rp.read_text(encoding="utf-8"))
|
||||
with self._lock:
|
||||
self.usage["calls"] += 1
|
||||
self.answered.append(jid)
|
||||
return data
|
||||
with self._lock:
|
||||
if self.emit:
|
||||
self._write_prompt(jid, system, user)
|
||||
self.pending.append(jid)
|
||||
raise PendingResponse(jid, self.prompt_path(jid), rp)
|
||||
|
||||
|
||||
class FakeLLM(LLMClient):
|
||||
"""테스트/드라이런용 — 프롬프트 종류([작업] 표식)에 맞는 최소 JSON 을 돌려준다.
|
||||
|
||||
extract_chunks 는 코드의 첫 실행문 한 줄을 조각 하나로 만든다 (파이프라인 검증용).
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.usage = {"calls": 0, "prompt_tokens": 0, "completion_tokens": 0, "cost_usd": 0.0,
|
||||
"retries": 0}
|
||||
|
||||
def complete_json(self, system: str, user: str) -> dict:
|
||||
self.usage["calls"] += 1
|
||||
task = ""
|
||||
for line in user.splitlines():
|
||||
if line.startswith("[작업]"):
|
||||
task = line.split("]", 1)[1].strip()
|
||||
break
|
||||
if task == "program_summary":
|
||||
return {"program": "", "business_purpose_ko": "(fake) 프로그램 요약", "confidence": 0.0}
|
||||
# extract_chunks — ' 12| code' 형식의 첫 코드 줄을 조각으로
|
||||
m = re.search(r"^\s*(\d+)\| (?!FORM |ENDFORM|METHOD |ENDMETHOD|FUNCTION |ENDFUNCTION|MODULE |ENDMODULE)(\S.*)$",
|
||||
user, re.M)
|
||||
chunks = []
|
||||
if m:
|
||||
no, text = int(m.group(1)), m.group(2)
|
||||
chunks.append({"line_start": no, "line_end": no, "first_line": text, "kind": "other",
|
||||
"purpose_ko": "(fake) 조각", "confidence": 0.0})
|
||||
return {"unit_purpose_ko": "(fake) unit 요약", "chunks": chunks}
|
||||
|
||||
|
||||
BACKENDS = ("api", "file", "fake")
|
||||
|
||||
|
||||
def create_llm(fake: bool = False, backend: str | None = None, **kwargs) -> LLMClient:
|
||||
"""백엔드 선택. `backend` 가 우선이고, 하위호환으로 `fake=True` 도 받는다."""
|
||||
name = backend or ("fake" if fake else "api")
|
||||
if name not in BACKENDS:
|
||||
raise ValueError(f"알 수 없는 LLM 백엔드: {name} (가능: {', '.join(BACKENDS)})")
|
||||
if name == "fake":
|
||||
return FakeLLM()
|
||||
if name == "file":
|
||||
return FileQueueLLM(**kwargs)
|
||||
return OpenAICompatClient()
|
||||
@@ -0,0 +1,668 @@
|
||||
"""Stage 3 실행기 — 프로그램 요약 → unit 별 로직 조각 추출 (docs/logic-chunk-design.md).
|
||||
|
||||
python -m summarize.runner [--program X] [--limit N] [--fake] [--dry-run]
|
||||
|
||||
흐름 (프로그램마다):
|
||||
1. 프로그램 요약(ProgramSummary) — 없거나 stale 이거나 프롬프트 버전이 지났으면 생성
|
||||
2. unit(FORM/METHOD/FUNCTION/MODULE/EVENT) 하나씩 → 프로그램 요약을 문맥으로 붙여
|
||||
LLM 이 로직 조각(LogicChunk) 을 골라낸다. 300줄 넘는 unit 은 파서 sub_chunks 창으로 나눠 호출.
|
||||
3. 조각은 summarize/chunks.py 로 검증(줄 앵커) · 사실 채움(파서) 후 logic_chunk / chunk_fts 에 저장.
|
||||
|
||||
멱등성: unit.summary_status='done' 이고 prompt_version 이 같으면 스킵 (code_hash 가 바뀐 unit 은
|
||||
loader 가 재적재 시 상태를 초기화한다). LLM Key 미확보 상태에서는 --fake 로 파이프라인만 검증.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from config.settings import settings
|
||||
from index.db import bigrams, clean_comment, connect, loads, text_symbol_phrases
|
||||
from index.loader import refresh_program_fts
|
||||
from query.tools import _structure_summary
|
||||
|
||||
from .chunks import ResolvedChunk, covered_lines, numbered_code, parser_hints, resolve_chunks
|
||||
from .llm_client import PendingResponse, create_llm
|
||||
from .schemas import CHUNK_KINDS, ProgramSummary, UnitExtraction
|
||||
|
||||
PROMPT_VERSION = 2
|
||||
ELIGIBLE = ("FORM", "METHOD", "FUNCTION", "MODULE", "EVENT")
|
||||
MAX_WINDOW_LINES = 300 # 이보다 긴 unit 은 창으로 나눠 LLM 에 보여준다
|
||||
FALLBACK_WINDOW = 250 # sub_chunks 가 없을 때의 고정 창 크기
|
||||
MAX_EVENT_CODE_LINES = 150 # 프로그램 요약에 넣는 이벤트 블록 코드 상한
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"당신은 SAP ABAP 시니어 개발자다. 코드를 업무 관점으로 한국어로 설명한다. "
|
||||
"SAP 표준 영어 용어(예: Goods Receipt, G/L Account)와 기술 객체명(테이블·FM·BAPI)은 함께 적는다. "
|
||||
"추측이 필요한 부분은 confidence 를 낮추고 unclear 에 기록한다. 출력은 JSON 만."
|
||||
)
|
||||
|
||||
PROGRAM_PROMPT = """[작업] program_summary
|
||||
다음 ABAP 프로그램을 업무 관점으로 요약하라. ProgramSummary JSON 스키마로만 답하라.
|
||||
|
||||
[프로그램] {program} — {title}
|
||||
[패키지] {devclass} {pkg_text}
|
||||
[선택화면] {selection}
|
||||
[텍스트 심볼] {text_symbols}
|
||||
|
||||
[구조 사실 — 파서 추출]
|
||||
주 흐름(이벤트 → PERFORM 체인):
|
||||
{main_flow}
|
||||
테이블 read: {tables_read}
|
||||
테이블 write: {tables_write}
|
||||
외부 호출: {external_calls}
|
||||
출력 형태: {output_type}
|
||||
|
||||
[unit 목록] (유형 이름 — 헤더 주석)
|
||||
{unit_list}
|
||||
|
||||
[이벤트 블록 코드 — 줄번호| 내용]
|
||||
{event_code}
|
||||
|
||||
[출력 JSON 스키마 — 아래 키만 사용]
|
||||
{{"program": "{program}", "title_ko": "", "business_purpose_ko": "업무 목적 2~3문장", "business_purpose_en": "",
|
||||
"main_flow": ["처리 순서를 업무 언어로"], "selection_screen": [{{"name": "", "desc_ko": ""}}],
|
||||
"key_internal_tables": [{{"name": "", "filled_by": [], "consumed_by": [], "desc_ko": ""}}],
|
||||
"output_type": [], "business_tags": [], "sap_module": "FI/CO/MM/SD 등",
|
||||
"keywords_ko": ["한국어 검색 키워드"], "keywords_en": ["English search keywords"],
|
||||
"related_tcodes": [], "notes": [], "confidence": 0.0, "unclear": []}}
|
||||
"""
|
||||
|
||||
UNIT_PROMPT = """[작업] extract_chunks
|
||||
다음 ABAP 코드 단위에서 **업무적으로 의미 있는 로직 조각**을 골라내고 각각을 설명하라.
|
||||
UnitExtraction JSON 스키마로만 답하라.
|
||||
|
||||
[프로그램] {program} — {title}
|
||||
[프로그램 요약] {program_purpose}
|
||||
[주 흐름] {main_flow}
|
||||
[핵심 내부테이블] {key_tables}
|
||||
|
||||
[단위] unit_id: {unit_id}
|
||||
unit_type: {unit_type}, name: {name}
|
||||
signature: {signature}
|
||||
header_comment: {header_comment}
|
||||
{window_note}
|
||||
[파서 힌트 — DB 접근 · 호출 · 검증이 시작되는 줄]
|
||||
{hints}
|
||||
|
||||
[텍스트 심볼]
|
||||
{text_symbols}
|
||||
|
||||
[코드 — 줄번호| 내용]
|
||||
{code}
|
||||
|
||||
[조각 선정 규칙]
|
||||
- 조각 = 하나의 업무 로직을 이루는 연속된 줄 범위. 예: 특정 테이블 SELECT 와 그 직후 결과 정리(SORT/DELETE/LOOP 집계)
|
||||
까지가 한 로직이면 하나로 묶는다. BAPI/FM 호출은 파라미터 채우기 ~ 호출 ~ 결과 처리까지 한 조각.
|
||||
- 조각으로 만들지 않고 버리는 것: 단순 선언(DATA/TYPES), 변수 초기화(CLEAR/REFRESH/FREE), ALV 필드카탈로그·레이아웃·
|
||||
컬럼 속성 설정, 화면 속성 LOOP AT SCREEN, 단순 이벤트 등록, 로그성 WRITE, 주석만 있는 구간.
|
||||
- 조각은 서로 겹치지 않는다. 보통 3~80줄. FORM/METHOD 전체를 통째로 하나의 조각으로 만들지 않는다
|
||||
(내부에 로직이 하나뿐이면 그 로직 범위만).
|
||||
- 의미 있는 조각이 없으면 chunks 를 빈 배열로 둔다. 그래도 unit_purpose_ko 는 채운다.
|
||||
- line_start / line_end 는 위 코드에 붙은 줄번호 그대로. first_line 에는 line_start 줄의 코드를 그대로 복사한다.
|
||||
- kind 는 다음 중 하나: {kinds}
|
||||
- purpose_ko 는 "무엇을 왜 하는지" 한 문장(한국어). keywords_ko 는 업무 용어(입고, 반제, 잔액 …),
|
||||
keywords_en 은 SAP 표준 영어 용어, sap_objects 는 테이블·FM·BAPI·클래스·T-Code 이름.
|
||||
|
||||
[출력 JSON 스키마]
|
||||
{{"unit_purpose_ko": "이 단위의 역할 한 문장",
|
||||
"chunks": [{{"line_start": 0, "line_end": 0, "first_line": "", "kind": "sql_select",
|
||||
"purpose_ko": "", "purpose_en": "", "keywords_ko": [], "keywords_en": [], "sap_objects": [],
|
||||
"confidence": 0.0}}],
|
||||
"unclear": []}}
|
||||
"""
|
||||
|
||||
_SELSCREEN = re.compile(r"^\s*(PARAMETERS?|SELECT-OPTIONS|SELECTION-SCREEN\s+BEGIN\s+OF\s+BLOCK)\b(.*)$", re.I)
|
||||
_TEXT_SYM = re.compile(r"TEXT-(\w{3})", re.I)
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _j(v) -> str:
|
||||
return json.dumps(v, ensure_ascii=False)
|
||||
|
||||
|
||||
# ------------------------------------------------------------- 프로그램 요약
|
||||
|
||||
def _include_lines(con: sqlite3.Connection, program: str) -> dict[str, list[str]]:
|
||||
return {
|
||||
r["include"]: (r["code"] or "").split("\n")
|
||||
for r in con.execute("SELECT include, code FROM include WHERE program=?", (program,))
|
||||
}
|
||||
|
||||
|
||||
def _text_symbols(p: sqlite3.Row) -> dict[str, str]:
|
||||
return {t["symbol"].upper(): t["text"] for t in (loads(p["text_symbols_json"]) or []) if t.get("symbol")}
|
||||
|
||||
|
||||
def _program_context(con: sqlite3.Connection, p: sqlite3.Row, lines_by_inc: dict[str, list[str]]) -> dict:
|
||||
structure = _structure_summary(con, p["name"])
|
||||
selection = []
|
||||
for inc_lines in lines_by_inc.values():
|
||||
for ln in inc_lines:
|
||||
m = _SELSCREEN.match(ln)
|
||||
if m:
|
||||
selection.append(ln.strip()[:100])
|
||||
units = con.execute(
|
||||
"SELECT * FROM unit WHERE program=? AND unit_type IN ('FORM','METHOD','FUNCTION','MODULE','EVENT') "
|
||||
"ORDER BY include, line_start", (p["name"],)).fetchall()
|
||||
unit_list = [
|
||||
f"{u['unit_type']} {u['name']} ({u['include']} L{u['line_start']}-{u['line_end']})"
|
||||
+ (f" — {clean_comment(u['header_comment'])}" if clean_comment(u["header_comment"]) else "")
|
||||
for u in units[:150]
|
||||
]
|
||||
event_code, budget = [], MAX_EVENT_CODE_LINES
|
||||
for u in units:
|
||||
if u["unit_type"] != "EVENT" or budget <= 0:
|
||||
continue
|
||||
lines = lines_by_inc.get(u["include"], [])
|
||||
end = min(u["line_end"], u["line_start"] + budget - 1)
|
||||
event_code.append(numbered_code(lines, u["line_start"], end))
|
||||
budget -= end - u["line_start"] + 1
|
||||
ts = _text_symbols(p)
|
||||
return {
|
||||
"structure": structure,
|
||||
"selection": "; ".join(selection[:30]) or "-",
|
||||
"text_symbols": "; ".join(f"{k}={v}" for k, v in list(ts.items())[:40]) or "-",
|
||||
"unit_list": "\n".join(unit_list) or "-",
|
||||
"event_code": "\n".join(event_code) or "-",
|
||||
}
|
||||
|
||||
|
||||
def ensure_program_summary(con: sqlite3.Connection, llm, p: sqlite3.Row,
|
||||
lines_by_inc: dict[str, list[str]], dry_run: bool) -> tuple[dict, str]:
|
||||
"""프로그램 요약을 (필요하면) 생성하고 (summary dict, 상태) 를 돌려준다.
|
||||
|
||||
상태: done | skipped | failed | dry | pending(file 백엔드에서 응답 대기)
|
||||
"""
|
||||
existing = loads(p["summary_json"]) or {}
|
||||
if p["summary_status"] == "done" and existing.get("prompt_version") == PROMPT_VERSION:
|
||||
return existing, "skipped"
|
||||
ctx = _program_context(con, p, lines_by_inc)
|
||||
st = ctx["structure"]
|
||||
prompt = PROGRAM_PROMPT.format(
|
||||
program=p["name"], title=p["title_ko"] or "-", devclass=p["devclass"] or "-", pkg_text=p["pkg_text"] or "",
|
||||
selection=ctx["selection"], text_symbols=ctx["text_symbols"],
|
||||
main_flow="\n".join(st["main_flow"]) or "-", tables_read=_j(st["tables_read"][:40]),
|
||||
tables_write=_j(st["tables_write"][:40]), external_calls=_j(st["external_calls"][:40]),
|
||||
output_type=_j(st["output_type"]), unit_list=ctx["unit_list"], event_code=ctx["event_code"],
|
||||
)
|
||||
if dry_run:
|
||||
return existing, "dry"
|
||||
try:
|
||||
raw = llm.complete_json(SYSTEM_PROMPT, prompt)
|
||||
except PendingResponse:
|
||||
# file 백엔드 — 프롬프트만 내놓고 응답을 기다린다. 실패로 기록하지 않는다.
|
||||
return existing, "pending"
|
||||
try:
|
||||
raw["program"] = p["name"]
|
||||
summary = ProgramSummary.model_validate(raw)
|
||||
summary.tables_read = st["tables_read"]
|
||||
summary.tables_write = st["tables_write"]
|
||||
summary.external_calls = st["external_calls"]
|
||||
summary.output_type = summary.output_type or st["output_type"]
|
||||
summary.title_ko = summary.title_ko or p["title_ko"] or ""
|
||||
summary.prompt_version = PROMPT_VERSION
|
||||
con.execute("UPDATE program SET summary_json=?, summary_status='done' WHERE name=?",
|
||||
(summary.model_dump_json(), p["name"]))
|
||||
con.commit()
|
||||
return summary.model_dump(), "done"
|
||||
except Exception as e: # noqa: BLE001
|
||||
con.execute("UPDATE program SET summary_status='failed' WHERE name=?", (p["name"],))
|
||||
con.commit()
|
||||
print(f"[FAIL] program summary {p['name']}: {type(e).__name__}: {e}")
|
||||
return existing, "failed"
|
||||
|
||||
|
||||
# ------------------------------------------------------------- unit 조각 추출
|
||||
|
||||
def _windows(u: sqlite3.Row) -> list[tuple[int, int]]:
|
||||
if u["loc"] <= MAX_WINDOW_LINES:
|
||||
return [(u["line_start"], u["line_end"])]
|
||||
subs = loads(u["sub_chunks_json"]) or []
|
||||
wins = [(s["line_start"], s["line_end"]) for s in subs if s["line_end"] >= s["line_start"]]
|
||||
if not wins:
|
||||
wins = [(a, min(a + FALLBACK_WINDOW - 1, u["line_end"]))
|
||||
for a in range(u["line_start"], u["line_end"] + 1, FALLBACK_WINDOW)]
|
||||
return wins
|
||||
|
||||
|
||||
def _known_symbols(con: sqlite3.Connection, program: str, unit_id: str) -> set[str]:
|
||||
"""조각의 테이블·호출을 파서로 다시 뽑을 때 쓰는 '심볼이라 DB 테이블이 아니다' 집합.
|
||||
|
||||
`TABLES:`/`NODES:` 로 선언된 DDIC 작업영역은 제외한다 — 넣으면
|
||||
`SELECT ... FROM zfit0060` 이 심볼로 오인돼 조각의 tables_read 가 비어버린다
|
||||
(parser/run.py 의 같은 가드와 기준을 맞춘다).
|
||||
"""
|
||||
return {r["name"] for r in con.execute(
|
||||
"SELECT name FROM symbol WHERE program=? AND (scope='global' OR unit_id=?) "
|
||||
"AND kind NOT IN ('tables','nodes')", (program, unit_id))}
|
||||
|
||||
|
||||
def _store_chunks(con: sqlite3.Connection, u: sqlite3.Row, chunks: list[ResolvedChunk],
|
||||
text_symbols: dict[str, str] | None = None) -> None:
|
||||
for r in con.execute("SELECT chunk_id FROM logic_chunk WHERE unit_id=?", (u["unit_id"],)).fetchall():
|
||||
con.execute("DELETE FROM chunk_fts WHERE chunk_id=?", (r["chunk_id"],))
|
||||
con.execute("DELETE FROM logic_chunk WHERE unit_id=?", (u["unit_id"],))
|
||||
now = _now()
|
||||
for c in chunks:
|
||||
chunk_id = f"{u['unit_id']}#C{c.seq}"
|
||||
con.execute(
|
||||
"INSERT INTO logic_chunk(chunk_id, program, include, unit_id, seq, line_start, line_end, code_hash, "
|
||||
"kind, purpose_ko, purpose_en, keywords_ko, keywords_en, sap_objects, tables_read, tables_write, "
|
||||
"calls, confidence, prompt_version, extracted_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(chunk_id, u["program"], u["include"], u["unit_id"], c.seq, c.line_start, c.line_end, c.code_hash,
|
||||
c.kind, c.purpose_ko, c.purpose_en, _j(c.keywords_ko), _j(c.keywords_en), _j(c.sap_objects),
|
||||
_j(c.tables_read), _j(c.tables_write), _j(c.calls), c.confidence, PROMPT_VERSION, now),
|
||||
)
|
||||
# 조각 코드에 쓰인 TEXT-nnn 의 한국어 원문을 색인에 넣는다 (수정사항 3번) —
|
||||
# LLM 설명과 무관하게 화면 문구로도 조각에 도달할 수 있어야 한다.
|
||||
ts_phrases = text_symbol_phrases(c.code, text_symbols or {})
|
||||
keywords = " ".join(c.keywords_ko + c.keywords_en + [c.kind] + ts_phrases)
|
||||
objects = " ".join(c.sap_objects + c.tables_read + c.tables_write + c.calls)
|
||||
con.execute(
|
||||
"INSERT INTO chunk_fts(chunk_id, program, purpose, keywords, objects, bigrams) VALUES(?,?,?,?,?,?)",
|
||||
(chunk_id, u["program"], f"{c.purpose_ko} {c.purpose_en}".strip(), keywords, objects,
|
||||
bigrams(f"{c.purpose_ko} {' '.join(c.keywords_ko)} {' '.join(ts_phrases)}")),
|
||||
)
|
||||
|
||||
|
||||
def build_unit_prompts(con: sqlite3.Connection, u: sqlite3.Row, program_summary: dict,
|
||||
title: str, lines: list[str], text_symbols: dict[str, str]) -> list[dict]:
|
||||
"""unit 의 창별 프롬프트를 만든다 (DB 읽기 — 메인 스레드 전용).
|
||||
|
||||
LLM 호출을 병렬화하려면 '프롬프트 조립(DB) → 호출(병렬) → 저장(DB)' 으로 갈라야 한다.
|
||||
sqlite 커넥션은 스레드 간 공유가 안 되기 때문이다.
|
||||
"""
|
||||
windows = _windows(u)
|
||||
out: list[dict] = []
|
||||
for wi, (ws, we) in enumerate(windows, 1):
|
||||
code = numbered_code(lines, ws, we)
|
||||
used_ts = {m.group(1).upper() for m in _TEXT_SYM.finditer(code)}
|
||||
ts_lines = [f"TEXT-{k} = {text_symbols[k]}" for k in sorted(used_ts) if k in text_symbols]
|
||||
prompt = UNIT_PROMPT.format(
|
||||
program=u["program"], title=title or "-",
|
||||
program_purpose=program_summary.get("business_purpose_ko") or "(요약 없음 — 구조 사실만 참고)",
|
||||
main_flow=" → ".join(program_summary.get("main_flow") or [])[:600] or "-",
|
||||
key_tables=", ".join(f"{t.get('name')}({t.get('desc_ko', '')})"
|
||||
for t in (program_summary.get("key_internal_tables") or [])[:10]) or "-",
|
||||
unit_id=u["unit_id"], unit_type=u["unit_type"], name=u["name"], signature=u["signature"] or "-",
|
||||
header_comment=clean_comment(u["header_comment"]) or "-",
|
||||
window_note=(f"[창] 이 단위는 길어서 {len(windows)}개 창으로 나눠 보여준다. 지금은 {wi}번째 창 "
|
||||
f"(L{ws}-L{we}). 이 창 안의 줄만 조각으로 만들라." if len(windows) > 1 else ""),
|
||||
hints="\n".join(parser_hints(lines, ws, we)) or "-",
|
||||
text_symbols="\n".join(ts_lines) or "-", code=code, kinds=", ".join(CHUNK_KINDS),
|
||||
)
|
||||
out.append({"window": (ws, we), "prompt": prompt})
|
||||
return out
|
||||
|
||||
|
||||
def finish_unit(con: sqlite3.Connection, u: sqlite3.Row, calls: list[dict], lines: list[str],
|
||||
text_symbols: dict[str, str]) -> dict:
|
||||
"""창별 LLM 응답 → 검증·저장 (DB 쓰기 — 메인 스레드 전용).
|
||||
|
||||
calls 원소: {window, prompt, raw?, error?, pending?}
|
||||
한 창이라도 pending 이면 아무것도 저장하지 않는다 (부분 저장 방지).
|
||||
"""
|
||||
if any(c.get("pending") for c in calls):
|
||||
return {"status": "pending", "chunks": 0, "dropped": 0}
|
||||
errors = [c["error"] for c in calls if c.get("error")]
|
||||
if errors:
|
||||
raise errors[0]
|
||||
|
||||
known = _known_symbols(con, u["program"], u["unit_id"])
|
||||
all_chunks: list[ResolvedChunk] = []
|
||||
dropped_all: list[str] = []
|
||||
unit_purpose = ""
|
||||
for c in calls:
|
||||
ws, we = c["window"]
|
||||
ext = UnitExtraction.model_validate(c["raw"])
|
||||
unit_purpose = unit_purpose or ext.unit_purpose_ko.strip()
|
||||
chunks, dropped = resolve_chunks(ext.chunks, lines, ws, we, u["include"], known)
|
||||
all_chunks.extend(chunks)
|
||||
dropped_all.extend(dropped)
|
||||
|
||||
all_chunks.sort(key=lambda c: (c.line_start, c.line_end))
|
||||
for i, c in enumerate(all_chunks, 1):
|
||||
c.seq = i
|
||||
_store_chunks(con, u, all_chunks, text_symbols)
|
||||
covered = covered_lines(all_chunks)
|
||||
thin = {
|
||||
"purpose_ko": unit_purpose or clean_comment(u["header_comment"]),
|
||||
"chunk_count": len(all_chunks), "covered_lines": covered,
|
||||
"coverage": round(covered / u["loc"], 3) if u["loc"] else 0.0,
|
||||
"dropped": dropped_all[:10],
|
||||
}
|
||||
_mark_unit_done(con, u, thin, len(all_chunks))
|
||||
con.commit()
|
||||
return {"status": "done", "chunks": len(all_chunks), "dropped": len(dropped_all)}
|
||||
|
||||
|
||||
def _mark_unit_done(con: sqlite3.Connection, u: sqlite3.Row, thin: dict, n_chunks: int) -> None:
|
||||
con.execute(
|
||||
"UPDATE unit SET summary_json=?, summary_status='done', prompt_version=?, chunk_count=?, "
|
||||
"summary_error=NULL WHERE unit_id=?",
|
||||
(_j(thin), PROMPT_VERSION, n_chunks, u["unit_id"]),
|
||||
)
|
||||
text = " ".join(filter(None, [u["name"], u["signature"] or "", thin["purpose_ko"]]))
|
||||
con.execute("UPDATE unit_fts SET purpose=?, bigrams=? WHERE unit_id=?",
|
||||
(thin["purpose_ko"], bigrams(text), u["unit_id"]))
|
||||
|
||||
|
||||
def clone_unit_chunks(con: sqlite3.Connection, rep: sqlite3.Row, target: sqlite3.Row) -> int:
|
||||
"""code_hash 가 같은 unit 으로 조각을 복제한다 (수정사항 6번).
|
||||
|
||||
_BAK / _COPY 관행 때문에 같은 코드가 여러 프로그램에 그대로 들어 있다. 대표 unit 1건만
|
||||
LLM 에 보내고 나머지는 여기서 만든다 (실측 샘플: 중복 그룹 108개, 호출 179회 절감).
|
||||
|
||||
코드는 같아도 include 안에서의 **줄 위치는 다르다** — line_start 차이만큼 평행이동한다.
|
||||
"""
|
||||
shift = target["line_start"] - rep["line_start"]
|
||||
src = con.execute("SELECT * FROM logic_chunk WHERE unit_id=? ORDER BY seq", (rep["unit_id"],)).fetchall()
|
||||
for r in con.execute("SELECT chunk_id FROM logic_chunk WHERE unit_id=?", (target["unit_id"],)).fetchall():
|
||||
con.execute("DELETE FROM chunk_fts WHERE chunk_id=?", (r["chunk_id"],))
|
||||
con.execute("DELETE FROM logic_chunk WHERE unit_id=?", (target["unit_id"],))
|
||||
|
||||
for r in src:
|
||||
chunk_id = f"{target['unit_id']}#C{r['seq']}"
|
||||
con.execute(
|
||||
"INSERT INTO logic_chunk(chunk_id, program, include, unit_id, seq, line_start, line_end, code_hash, "
|
||||
"kind, purpose_ko, purpose_en, keywords_ko, keywords_en, sap_objects, tables_read, tables_write, "
|
||||
"calls, confidence, prompt_version, extracted_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(chunk_id, target["program"], target["include"], target["unit_id"], r["seq"],
|
||||
r["line_start"] + shift, r["line_end"] + shift, r["code_hash"],
|
||||
r["kind"], r["purpose_ko"], r["purpose_en"], r["keywords_ko"], r["keywords_en"],
|
||||
r["sap_objects"], r["tables_read"], r["tables_write"], r["calls"],
|
||||
r["confidence"], PROMPT_VERSION, _now()),
|
||||
)
|
||||
f = con.execute("SELECT purpose, keywords, objects, bigrams FROM chunk_fts WHERE chunk_id=?",
|
||||
(r["chunk_id"],)).fetchone()
|
||||
if f:
|
||||
con.execute(
|
||||
"INSERT INTO chunk_fts(chunk_id, program, purpose, keywords, objects, bigrams) "
|
||||
"VALUES(?,?,?,?,?,?)",
|
||||
(chunk_id, target["program"], f["purpose"], f["keywords"], f["objects"], f["bigrams"]),
|
||||
)
|
||||
|
||||
thin = dict(loads(rep["summary_json"]) or {})
|
||||
thin["cloned_from"] = rep["unit_id"]
|
||||
_mark_unit_done(con, target, thin, len(src))
|
||||
return len(src)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 배치 진입점
|
||||
|
||||
def _model_label(fake: bool, backend: str | None) -> str:
|
||||
"""llm_usage_log·대시보드에 남길 '무엇이 요약을 만들었나' 라벨.
|
||||
|
||||
환경변수의 LLM_MODEL 을 그대로 쓰면 안 된다 — file 백엔드로 사람·에이전트가 채운 결과를
|
||||
쓰지도 않은 모델 이름으로 기록하게 된다(실측: file 백엔드 실행이 'z-ai/glm-5.2:free' 로 남았다).
|
||||
"""
|
||||
name = backend or ("fake" if fake else "api")
|
||||
if name == "fake":
|
||||
return "fake"
|
||||
if name == "file":
|
||||
return "file(사람·에이전트 응답)"
|
||||
return settings.llm_model
|
||||
|
||||
|
||||
def _llm_call(llm, system: str, prompt: str) -> dict:
|
||||
"""스레드에서 도는 부분 — DB 를 건드리지 않는다. 예외는 값으로 돌려준다."""
|
||||
try:
|
||||
return {"raw": llm.complete_json(system, prompt)}
|
||||
except PendingResponse as e:
|
||||
return {"pending": True, "job_id": e.job_id}
|
||||
except Exception as e: # noqa: BLE001 — 호출 실패는 unit 단위로 기록하고 계속
|
||||
return {"error": e}
|
||||
|
||||
|
||||
def _run_calls_parallel(llm, calls: list[dict], concurrency: int) -> None:
|
||||
"""calls 각 원소의 'prompt' 를 호출하고 결과를 그 자리에 채운다 (in-place)."""
|
||||
if not calls:
|
||||
return
|
||||
if concurrency <= 1 or len(calls) == 1:
|
||||
for c in calls:
|
||||
c.update(_llm_call(llm, SYSTEM_PROMPT, c["prompt"]))
|
||||
return
|
||||
with ThreadPoolExecutor(max_workers=min(concurrency, len(calls))) as ex:
|
||||
futures = {ex.submit(_llm_call, llm, SYSTEM_PROMPT, c["prompt"]): c for c in calls}
|
||||
for fut in as_completed(futures):
|
||||
futures[fut].update(fut.result())
|
||||
|
||||
|
||||
def _dedupe_plan(con: sqlite3.Connection, pending: list[sqlite3.Row]) -> tuple[list[sqlite3.Row], dict]:
|
||||
"""(LLM 에 보낼 대표 unit 목록, {대표 unit_id: [복제 대상 unit...]}) (수정사항 6번)
|
||||
|
||||
이미 done 이고 조각이 있는 unit 과 code_hash 가 같으면 LLM 호출 없이 바로 복제한다.
|
||||
|
||||
**배치 전체를 한 번에 넘겨야 한다.** 프로그램별로 나눠 호출하면 공용 인클루드
|
||||
(ZFICOM/ZFIALV 처럼 17개 프로그램에 그대로 복사된 코드)가 서로 다른 호출에 흩어져
|
||||
중복이 잡히지 않는다 — 실측에서 절감 0회가 나왔다. 프로그램 안의 중복은 드물고
|
||||
절감분은 거의 전부 프로그램을 가로지르는 중복이다.
|
||||
"""
|
||||
reps: list[sqlite3.Row] = []
|
||||
followers: dict[str, list[sqlite3.Row]] = {}
|
||||
rep_by_hash: dict[str, sqlite3.Row] = {}
|
||||
|
||||
for u in pending:
|
||||
h = u["code_hash"]
|
||||
if not h:
|
||||
reps.append(u)
|
||||
continue
|
||||
if h in rep_by_hash:
|
||||
followers.setdefault(rep_by_hash[h]["unit_id"], []).append(u)
|
||||
continue
|
||||
# 이번 배치 밖에서 이미 추출된 동일 코드 unit 이 있으면 그걸 대표로 쓴다 (호출 0회)
|
||||
done = con.execute(
|
||||
"SELECT * FROM unit WHERE code_hash=? AND unit_id!=? AND summary_status='done' "
|
||||
"AND prompt_version=? AND chunk_count>0 LIMIT 1",
|
||||
(h, u["unit_id"], PROMPT_VERSION),
|
||||
).fetchone()
|
||||
if done:
|
||||
followers.setdefault(done["unit_id"], []).append(u)
|
||||
rep_by_hash[h] = done
|
||||
continue
|
||||
rep_by_hash[h] = u
|
||||
reps.append(u)
|
||||
return reps, followers
|
||||
|
||||
|
||||
def summarize_units(program: str | None, limit: int | None, fake: bool = False, dry_run: bool = False,
|
||||
trigger: str = "manual", backend: str | None = None,
|
||||
concurrency: int | None = None, dedupe: bool = True) -> dict:
|
||||
"""프로그램(들)의 요약 + unit 조각 추출을 실행한다. 이름은 API 호환을 위해 유지."""
|
||||
con = connect()
|
||||
llm = create_llm(fake=fake, backend=backend)
|
||||
conc = max(1, concurrency or settings.llm_concurrency)
|
||||
stats = {"programs": 0, "program_summaries": 0, "done": 0, "skipped": 0, "failed": 0,
|
||||
"cloned": 0, "awaiting_response": 0, "chunks": 0, "dropped": 0, "llm_calls_saved": 0,
|
||||
"elapsed_s": 0}
|
||||
t0 = time.time()
|
||||
run_id: int | None = None
|
||||
try:
|
||||
sql = ("SELECT p.*, COALESCE(k.text_ko,'') AS pkg_text FROM program p "
|
||||
"LEFT JOIN package k ON k.devclass=p.devclass WHERE p.has_source=1")
|
||||
params: list = []
|
||||
if program:
|
||||
sql += " AND p.name=?"
|
||||
params.append(program.upper())
|
||||
programs = con.execute(sql + " ORDER BY p.name", params).fetchall()
|
||||
|
||||
# 대상 unit 수집 (leaf 부터 — topo 역순)
|
||||
pending: list[sqlite3.Row] = []
|
||||
for p in programs:
|
||||
rows = con.execute(
|
||||
"SELECT u.*, t.ord FROM unit u LEFT JOIN topo t ON t.unit_id=u.unit_id "
|
||||
"WHERE u.program=? AND u.unit_type IN ('FORM','METHOD','FUNCTION','MODULE','EVENT') "
|
||||
"ORDER BY COALESCE(t.ord, 9999) DESC", (p["name"],)).fetchall()
|
||||
for u in rows:
|
||||
if u["summary_status"] == "done" and u["prompt_version"] == PROMPT_VERSION:
|
||||
stats["skipped"] += 1
|
||||
else:
|
||||
pending.append(u)
|
||||
if limit:
|
||||
pending = pending[:limit]
|
||||
# 주의: "pending" = 이번 실행의 대상 unit 수(기존 의미, API/대시보드가 사용).
|
||||
# file 백엔드의 '응답 대기' 는 별 키인 "awaiting_response" 다.
|
||||
stats["pending"] = len(pending)
|
||||
|
||||
# 중복 제거는 **배치 전체**를 대상으로 한 번만 세운다 (프로그램별로 나누면 공용
|
||||
# 인클루드의 교차 중복이 잡히지 않는다). LLM 은 대표 unit 만 보고, 나머지는 복제한다.
|
||||
reps, followers = _dedupe_plan(con, pending) if dedupe else (pending, {})
|
||||
stats["llm_calls_saved"] = sum(len(v) for v in followers.values())
|
||||
|
||||
pending_by_prog: dict[str, list[sqlite3.Row]] = {}
|
||||
for u in reps:
|
||||
pending_by_prog.setdefault(u["program"], []).append(u)
|
||||
|
||||
needs_summary = {p["name"] for p in programs if not (
|
||||
p["summary_status"] == "done" and (loads(p["summary_json"]) or {}).get("prompt_version") == PROMPT_VERSION)}
|
||||
if (pending or needs_summary) and not dry_run:
|
||||
cur = con.execute(
|
||||
"INSERT INTO llm_usage_log(ts, program, model, trigger_by, status, total, "
|
||||
"calls, prompt_tokens, completion_tokens, cost_usd, done, failed, skipped, elapsed_s, chunks) "
|
||||
"VALUES(datetime('now','localtime'),?,?,?,'running',?,0,0,0,0.0,0,0,?,0,0)",
|
||||
(program or "*", _model_label(fake, backend), trigger, len(pending), stats["skipped"]),
|
||||
)
|
||||
run_id = cur.lastrowid
|
||||
con.commit()
|
||||
|
||||
for p in programs:
|
||||
units = pending_by_prog.get(p["name"], [])
|
||||
if not units and p["name"] not in needs_summary:
|
||||
continue
|
||||
stats["programs"] += 1
|
||||
lines_by_inc = _include_lines(con, p["name"])
|
||||
summary, st = ensure_program_summary(con, llm, p, lines_by_inc, dry_run)
|
||||
if st == "done":
|
||||
stats["program_summaries"] += 1
|
||||
elif st == "pending":
|
||||
# 프로그램 요약은 unit 프롬프트의 **문맥**이다 (2단계 설계). 요약이 아직 없는데
|
||||
# unit 프롬프트를 내놓으면 "(요약 없음)" 으로 만들어진 프롬프트를 받게 되고,
|
||||
# 요약이 채워진 다음 패스에서 프롬프트 내용이 달라져 전부 다시 답해야 한다.
|
||||
# 그래서 요약이 대기 중이면 이 프로그램의 unit 은 이번 패스에서 건너뛴다.
|
||||
stats["awaiting_response"] += 1
|
||||
stats["blocked_units"] = stats.get("blocked_units", 0) + len(units)
|
||||
continue
|
||||
text_symbols = _text_symbols(p)
|
||||
|
||||
if dry_run:
|
||||
continue
|
||||
|
||||
# 1) 프롬프트 조립 (DB 읽기, 메인 스레드) — units 는 이미 대표 unit 만 들어 있다
|
||||
jobs: list[tuple[sqlite3.Row, list[dict]]] = []
|
||||
for u in units:
|
||||
prompts = build_unit_prompts(con, u, summary, p["title_ko"] or "",
|
||||
lines_by_inc.get(u["include"], []), text_symbols)
|
||||
jobs.append((u, [dict(x) for x in prompts]))
|
||||
|
||||
# 2) LLM 호출 (병렬, DB 접근 없음) — 수정사항 10번
|
||||
_run_calls_parallel(llm, [c for _, calls in jobs for c in calls], conc)
|
||||
|
||||
# 3) 검증·저장 (DB 쓰기, 메인 스레드)
|
||||
for u, calls in jobs:
|
||||
lines = lines_by_inc.get(u["include"], [])
|
||||
try:
|
||||
r = finish_unit(con, u, calls, lines, text_symbols)
|
||||
except Exception as e: # noqa: BLE001
|
||||
err = f"{type(e).__name__}: {e}"[:500]
|
||||
con.execute("UPDATE unit SET summary_status='failed', summary_error=? WHERE unit_id=?",
|
||||
(err, u["unit_id"]))
|
||||
con.commit()
|
||||
stats["failed"] += 1
|
||||
print(f"[FAIL] {u['unit_id']}: {err}")
|
||||
continue
|
||||
if r["status"] == "pending":
|
||||
stats["awaiting_response"] += 1
|
||||
continue
|
||||
stats["done"] += 1
|
||||
stats["chunks"] += r["chunks"]
|
||||
stats["dropped"] += r["dropped"]
|
||||
con.commit()
|
||||
|
||||
if run_id is not None:
|
||||
_update_run(con, run_id, stats, llm, t0, status="running")
|
||||
|
||||
# ---- 복제 패스: code_hash 가 같은 나머지 unit 에 조각을 복제한다 (LLM 호출 없음) ----
|
||||
# 대표와 복제 대상이 서로 다른 프로그램일 수 있어 프로그램 루프가 끝난 뒤에 돈다.
|
||||
touched_programs = {p["name"] for p in programs if pending_by_prog.get(p["name"])}
|
||||
if not dry_run:
|
||||
for rep_id, targets in followers.items():
|
||||
rep_row = con.execute("SELECT * FROM unit WHERE unit_id=?", (rep_id,)).fetchone()
|
||||
if not rep_row or rep_row["summary_status"] != "done":
|
||||
continue # 대표가 아직 안 끝났다(응답 대기·실패) — 다음 실행에서 복제된다
|
||||
for tgt in targets:
|
||||
stats["chunks"] += clone_unit_chunks(con, rep_row, tgt)
|
||||
stats["cloned"] += 1
|
||||
touched_programs.add(tgt["program"])
|
||||
con.commit()
|
||||
|
||||
# 요약·조각이 생긴 프로그램의 색인을 다시 만든다 (수정사항 1번) — 단건 경로
|
||||
for name in sorted(touched_programs):
|
||||
refresh_program_fts(con, name)
|
||||
con.commit()
|
||||
finally:
|
||||
if run_id is not None:
|
||||
stats["elapsed_s"] = round(time.time() - t0, 1)
|
||||
_update_run(con, run_id, stats, llm, t0, status="done")
|
||||
con.close()
|
||||
if hasattr(llm, "usage"):
|
||||
stats["llm_usage"] = llm.usage
|
||||
stats["elapsed_s"] = round(time.time() - t0, 1)
|
||||
return stats
|
||||
|
||||
|
||||
def _update_run(con, run_id: int, stats: dict, llm, t0: float, status: str) -> None:
|
||||
u = getattr(llm, "usage", None) or {}
|
||||
con.execute(
|
||||
"UPDATE llm_usage_log SET status=?, calls=?, prompt_tokens=?, completion_tokens=?, "
|
||||
"cost_usd=?, done=?, failed=?, skipped=?, elapsed_s=?, chunks=? WHERE id=?",
|
||||
(status, u.get("calls", 0), u.get("prompt_tokens", 0), u.get("completion_tokens", 0),
|
||||
u.get("cost_usd", 0.0), stats["done"], stats["failed"], stats["skipped"],
|
||||
round(time.time() - t0, 1), stats["chunks"], run_id),
|
||||
)
|
||||
con.commit()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Stage 3 — 프로그램 요약 + 로직 조각 추출",
|
||||
epilog="LLM 키가 없으면: --llm file 로 프롬프트를 파일로 내놓고 "
|
||||
"python -m summarize.jobs 로 응답을 채운 뒤 같은 명령을 다시 실행한다.",
|
||||
)
|
||||
ap.add_argument("--program", default=None)
|
||||
ap.add_argument("--limit", type=int, default=None, help="처리할 unit 수 상한")
|
||||
ap.add_argument("--llm", choices=["api", "file", "fake"], default=None,
|
||||
help="api=환경변수 키로 호출 / file=프롬프트 파일 큐(키 불필요) / fake=더미")
|
||||
ap.add_argument("--fake", action="store_true", help="--llm fake 의 하위호환 별칭")
|
||||
ap.add_argument("--concurrency", type=int, default=None,
|
||||
help=f"동시 LLM 호출 수 (기본 LLM_CONCURRENCY={settings.llm_concurrency})")
|
||||
ap.add_argument("--no-dedupe", action="store_true",
|
||||
help="code_hash 가 같은 unit 도 각각 LLM 에 보낸다 (기본은 대표 1건만)")
|
||||
ap.add_argument("--dry-run", action="store_true")
|
||||
args = ap.parse_args()
|
||||
stats = summarize_units(
|
||||
args.program, args.limit, fake=args.fake, dry_run=args.dry_run,
|
||||
backend=args.llm, concurrency=args.concurrency, dedupe=not args.no_dedupe,
|
||||
)
|
||||
print(json.dumps(stats, ensure_ascii=False))
|
||||
if stats.get("pending"):
|
||||
print(f"\n응답 대기 {stats['awaiting_response']}건 — 프롬프트: {settings.data_llm_jobs}\n"
|
||||
f" python -m summarize.jobs list --pending\n"
|
||||
f" python -m summarize.jobs show <job_id>\n"
|
||||
f" python -m summarize.jobs answer <job_id> --file <응답.json>\n"
|
||||
f" → 응답을 채운 뒤 같은 명령을 다시 실행하면 적재된다.")
|
||||
if stats.get("blocked_units"):
|
||||
print(f"\nunit {stats['blocked_units']}건은 프로그램 요약을 문맥으로 쓰므로 대기 중이다 — "
|
||||
f"먼저 program_summary 작업에 답하고 다시 실행하면 unit 프롬프트가 나온다.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Stage 3 — LLM 출력 스키마 (docs/logic-chunk-design.md). pydantic 검증.
|
||||
|
||||
- ProgramSummary : 프로그램 1건 요약 (unit 추출의 문맥으로도 쓰임)
|
||||
- UnitExtraction : unit 하나에서 LLM 이 골라낸 로직 조각 목록 + unit 한 줄 요약
|
||||
- LogicChunk : 로직 조각 하나 — 인덱스의 1차 단위
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
CHUNK_KINDS = (
|
||||
"sql_select", # DB 조회 (SELECT / OPEN CURSOR)
|
||||
"db_write", # DB 갱신 (INSERT / UPDATE / MODIFY / DELETE / COMMIT)
|
||||
"fm_call", # BAPI · 펑션모듈 · RFC · 메서드 호출로 업무 처리
|
||||
"aggregation", # 내부테이블 집계 · 가공 · 병합 (LOOP / COLLECT / SORT …)
|
||||
"validation", # 입력 검증 · 권한 체크 · 존재 확인
|
||||
"calculation", # 금액 · 수량 · 환율 · 날짜 계산
|
||||
"output", # ALV · 리스트 · 화면 · 파일 · 메일 출력
|
||||
"interface", # 외부 시스템 송수신 (파일 · IDoc · HTTP · RFC destination)
|
||||
"control_flow", # 처리 흐름 분기 · 하위 로직 호출 순서
|
||||
"other",
|
||||
)
|
||||
|
||||
|
||||
class LogicChunk(BaseModel):
|
||||
"""LLM 이 반환하는 조각. line_* 는 include 기준 줄 번호(코드에 붙여 준 번호 그대로)."""
|
||||
line_start: int
|
||||
line_end: int
|
||||
first_line: str = "" # line_start 줄의 코드 원문 — 줄 번호 검증용 앵커
|
||||
kind: str = "other"
|
||||
purpose_ko: str
|
||||
purpose_en: str = ""
|
||||
keywords_ko: list[str] = Field(default_factory=list)
|
||||
keywords_en: list[str] = Field(default_factory=list)
|
||||
sap_objects: list[str] = Field(default_factory=list) # 테이블 · FM · BAPI · 클래스 · T-Code
|
||||
confidence: float = 0.5
|
||||
|
||||
|
||||
class UnitExtraction(BaseModel):
|
||||
unit_purpose_ko: str = "" # unit 한 줄 요약 — 얇은 색인용 (조각이 없어도 채운다)
|
||||
chunks: list[LogicChunk] = Field(default_factory=list)
|
||||
unclear: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SelectionParam(BaseModel):
|
||||
name: str
|
||||
desc_ko: str = ""
|
||||
|
||||
|
||||
class KeyInternalTable(BaseModel):
|
||||
name: str
|
||||
filled_by: list[str] = Field(default_factory=list)
|
||||
consumed_by: list[str] = Field(default_factory=list)
|
||||
desc_ko: str = ""
|
||||
|
||||
|
||||
class ProgramSummary(BaseModel):
|
||||
program: str
|
||||
title_ko: str = ""
|
||||
business_purpose_ko: str = ""
|
||||
business_purpose_en: str = ""
|
||||
main_flow: list[str] = Field(default_factory=list)
|
||||
selection_screen: list[SelectionParam] = Field(default_factory=list)
|
||||
key_internal_tables: list[KeyInternalTable] = Field(default_factory=list)
|
||||
tables_read: list[str] = Field(default_factory=list) # 파서 값으로 덮어씀
|
||||
tables_write: list[str] = Field(default_factory=list) # 파서 값으로 덮어씀
|
||||
external_calls: list[str] = Field(default_factory=list) # 파서 값으로 덮어씀
|
||||
output_type: list[str] = Field(default_factory=list)
|
||||
business_tags: list[str] = Field(default_factory=list)
|
||||
proposed_tags: list[str] = Field(default_factory=list)
|
||||
sap_module: str = ""
|
||||
keywords_ko: list[str] = Field(default_factory=list)
|
||||
keywords_en: list[str] = Field(default_factory=list)
|
||||
related_tcodes: list[str] = Field(default_factory=list)
|
||||
notes: list[str] = Field(default_factory=list)
|
||||
confidence: float = 0.5
|
||||
unclear: list[str] = Field(default_factory=list)
|
||||
prompt_version: int = 0
|
||||
Reference in New Issue
Block a user