"""Stage 5 — 질의 API (FastAPI). opencode-be 의 커스텀 툴(.opencode/tool/_index_client.ts)이 호출하는 HTTP 서비스. 실행: python -m query.api (기본 127.0.0.1:8100) 자연어 로직 질의는 /search/logic (로직 조각) 이 1차 진입점이다 — docs/logic-chunk-design.md 응답 크기 원칙: 툴 응답이 LLM 컨텍스트에 그대로 들어가므로 상한을 두고 자른다. (책임 경계 — 컨텍스트 초과는 인덱스 쪽 책임: 계획서 리스크 항목) """ from __future__ import annotations import json import re import sqlite3 from datetime import datetime, timezone import threading from fastapi import BackgroundTasks, Body, FastAPI, HTTPException, Query from fastapi.responses import HTMLResponse from config.settings import ROOT, settings from index.db import connect, db_path from index.loader import load_parsed, refresh_program_fts from ingest.normalize import write_program from parser.run import parse_program from summarize.runner import PROMPT_VERSION, summarize_units from . import tools from .dashboard import render_dashboard app = FastAPI(title="abap-indexing", version="0.1.0") MAX_CODE_LINES = 400 # unit 코드 응답 상한 (초과 시 write 지점 위주로 잘라야 하나 v1은 앞부분+안내) def _wrap(fn, *args, **kwargs): try: return fn(*args, **kwargs) except tools.NotFound as e: raise HTTPException(status_code=404, detail=str(e)) from e except sqlite3.OperationalError as e: raise HTTPException(status_code=503, detail=f"인덱스 DB 오류: {e}") from e # 프로세스 시작 시각 — 코드를 고쳤는데 반영이 안 될 때 서버가 낡았는지 판단하는 근거. # (템플릿 html 은 요청마다 다시 읽지만 파이썬 모듈은 sys.modules 에 캐시된다.) STARTED_AT = datetime.now(timezone.utc).isoformat(timespec="seconds") @app.get("/health") def health() -> dict: con = connect() try: programs = con.execute("SELECT COUNT(*) AS c FROM program").fetchone()["c"] with_source = con.execute("SELECT COUNT(*) AS c FROM program WHERE has_source=1").fetchone()["c"] units = con.execute("SELECT COUNT(*) AS c FROM unit").fetchone()["c"] chunks = con.execute("SELECT COUNT(*) AS c FROM logic_chunk").fetchone()["c"] wiki_programs = ( sum(1 for _ in (settings.wiki_dir / "programs").glob("*.md")) if (settings.wiki_dir / "programs").exists() else 0 ) return { "status": "ok", "started_at": STARTED_AT, "db": str(db_path()), "programs": programs, "programs_with_source": with_source, "units": units, "logic_chunks": chunks, "wiki_programs": wiki_programs, "wiki_search_backend": settings.wiki_search_backend, # qmd | pg (M3.5 파일럿으로 확정) } finally: con.close() # 인제스트 후 백그라운드 요약 — 같은 프로그램 중복 실행 방지 _summarizing: set[str] = set() _summarizing_lock = threading.Lock() def _summarize_bg(program: str) -> None: try: summarize_units(program, limit=None, dry_run=False, trigger="ingest", backend=settings.summarize_backend) except Exception as e: # noqa: BLE001 print(f"[summarize:{program}] {type(e).__name__}: {e}") finally: with _summarizing_lock: _summarizing.discard(program) # 요약 후 위키 재생성 (계획서 v4 §5.7 — 실패해도 요약 결과에는 영향 없음) try: from wiki_out.okf_writer import write_program_wiki write_program_wiki(program) except Exception as e: # noqa: BLE001 print(f"[wiki:{program}] {type(e).__name__}: {e}") @app.post("/ingest") def ingest(payload: dict = Body(), background_tasks: BackgroundTasks = None) -> dict: """수집 API/gateway가 넘겨준 프로그램 소스 JSON 한 건을 정규화→파싱→적재까지 처리한다. body 는 수집 원본과 같은 형태: {MAIN_PROGRAM, DESCRIPTION, INCLUDE_PROGRAM[], TEXT_SYMBOL?} (gateway 를 거친 응답은 이미 strict JSON 이므로 raw 정규화 단계는 불필요) """ if not isinstance(payload, dict) or not payload.get("MAIN_PROGRAM"): raise HTTPException(status_code=400, detail="MAIN_PROGRAM이 포함된 소스 JSON이 필요합니다") try: meta = write_program(payload, settings.data_normalized) program = meta["program"] parsed = parse_program(settings.data_normalized / program) settings.data_parsed.mkdir(parents=True, exist_ok=True) parse_path = settings.data_parsed / f"{program}.parse.json" parse_path.write_text(json.dumps(parsed, ensure_ascii=False), encoding="utf-8") except Exception as e: # noqa: BLE001 raise HTTPException(status_code=422, detail=f"정규화/파싱 실패: {type(e).__name__}: {e}") from e con = connect() try: status = load_parsed(con, parse_path) # 단건만 갱신 — 전량 재구축을 /ingest 마다 하면 1만 본에서 O(N²) 가 된다 refresh_program_fts(con, program) con.commit() # 요약이 안 됐거나(failed 포함) 프롬프트 버전이 지난 unit 수 — runner 의 스킵 조건과 동일 기준 pending = con.execute( "SELECT COUNT(*) AS c FROM unit WHERE program=? AND unit_type IN " "('FORM','METHOD','FUNCTION','MODULE','EVENT') " "AND (summary_status!='done' OR prompt_version!=?)", (program, PROMPT_VERSION), ).fetchone()["c"] except Exception as e: # noqa: BLE001 con.rollback() raise HTTPException(status_code=500, detail=f"인덱스 적재 실패: {type(e).__name__}: {e}") from e finally: con.close() # LLM 요약을 백그라운드로 이어 돌린다 (응답은 즉시 반환). # 소스가 안 바뀐(skip) 프로그램이라도 요약 미완료 unit 이 있으면 이어서 돌린다(재인덱싱 = 요약 재시도). # SUMMARIZE_BACKEND=file 이면 키 없이 프롬프트만 큐에 쌓는다 (/summaries/jobs 로 확인). backend = settings.summarize_backend summarize = "disabled" if backend != "off" and (backend == "file" or (settings.llm_base_url and settings.llm_api_key)): if status == "loaded" or pending: with _summarizing_lock: already = program in _summarizing if not already: _summarizing.add(program) if already: summarize = "already_running" else: background_tasks.add_task(_summarize_bg, program) summarize = "scheduled" if status == "loaded" else "resumed" else: summarize = "skip" # 소스 동일 + 요약도 전부 완료 return { "program": program, "status": status, # loaded | skip(소스 변경 없음) "summarize": summarize, # scheduled | resumed(미완료 요약 이어서) | already_running | skip | disabled "summarize_backend": backend, # api | file(프롬프트 큐) | off "pending_units": pending, # 요약 대기/실패 unit 수 "includes": len(meta["includes"]), "units": parsed["stats"]["units"], "statements": parsed["stats"]["statements"], "unknown_ratio": parsed["stats"]["unknown_ratio"], } @app.get("/search/programs") def search_programs(q: str = Query(min_length=1), top_k: int = Query(default=10, le=30)) -> dict: return {"query": q, "results": _wrap(tools.search_programs, q, top_k)} @app.get("/search/units") def search_units(q: str = Query(min_length=1), program: str | None = None, top_k: int = Query(default=10, le=30)) -> dict: return {"query": q, "results": _wrap(tools.search_units, q, program, top_k)} @app.get("/search/logic") def search_logic(q: str = Query(min_length=1), program: str | None = None, kind: str | None = None, top_k: int = Query(default=10, le=30)) -> dict: """로직 조각 검색 — 자연어 로직 질의의 1차 진입점. 프로그램 단위로 묶어 반환. 응답의 `expansion` 은 용어 사전으로 펼친 동의어를 보여준다. 조각마다 붙는 `matched_by` 가 '동의어 확장' 이면 원질의가 아니라 동의어로 걸린 결과다 (점수에 감쇠가 적용됨). """ return {"query": q, **_wrap(tools.search_logic, q, top_k, program, kind)} @app.get("/chunks/{chunk_id}") def chunk(chunk_id: str, decls: bool = True) -> dict: """조각 메타 + 코드 원문 + 정의부. chunk_id 의 '#' 은 URL 인코딩(%23) 해야 한다. `code` 는 로직만이라 그대로 붙여넣으면 선언이 없어 문법 오류가 난다. 함께 가야 하는 선언은 `declaration_code`(붙여넣기용 한 덩어리)와 `declarations`(건별 목록)로 준다. 필요 없으면 `?decls=false` 로 끈다. """ return _wrap(tools.get_chunk, chunk_id, decls) @app.get("/programs/{name}/declarations") def program_declarations(name: str, scope: str | None = None) -> dict: """프로그램의 정의부 전체 (scope=global|unit 로 거를 수 있다).""" return _wrap(tools.get_declarations, name, scope) @app.get("/programs/{name}/chunks") def program_chunks(name: str, unit_id: str | None = None) -> dict: return {"program": name.upper(), "chunks": _wrap(tools.list_chunks, name, unit_id)} @app.get("/programs/{name}/summary") def program_summary(name: str) -> dict: return _wrap(tools.get_program_summary, name) @app.get("/programs/{name}/source") def program_source(name: str) -> dict: return _wrap(tools.get_program_source, name) @app.get("/programs/{name}/units/{unit}/code") def unit_code(name: str, unit: str) -> dict: result = _wrap(tools.get_unit_code, name, unit) lines = result["code"].split("\n") if len(lines) > MAX_CODE_LINES: result["code"] = "\n".join(lines[:MAX_CODE_LINES]) result["truncated"] = True result["total_lines"] = len(lines) return result @app.get("/programs/{name}/trace/{symbol}") def trace(name: str, symbol: str) -> dict: return _wrap(tools.trace_variable, name, symbol) @app.get("/programs/{name}/call-graph") def call_graph(name: str, unit: str | None = None) -> dict: return _wrap(tools.get_call_graph, name, unit) @app.get("/programs/{name}/who-calls/{unit}") def who_calls_route(name: str, unit: str) -> dict: return _wrap(tools.who_calls, name, unit) @app.get("/tables/{name}/usage") def table_usage(name: str) -> dict: return _wrap(tools.get_table_usage, name) @app.get("/wiki/{doc_path:path}") def wiki_doc(doc_path: str) -> dict: """위키 문서 원문 반환 (계획서 v4 §7.2). 경로 규약: **wiki/ 루트 상대 경로** (예: programs/ZFIR10070.md). 경로 A(qmd) 채택 시 qmd 검색 결과의 경로가 그대로 들어온다. """ from wiki_out.merge import is_human_verified, split_frontmatter root = settings.wiki_dir.resolve() if not doc_path.endswith(".md"): doc_path += ".md" target = (root / doc_path).resolve() if not target.is_relative_to(root): raise HTTPException(status_code=400, detail="잘못된 경로입니다 (wiki/ 루트 상대 경로만 허용)") if not target.is_file(): raise HTTPException( status_code=404, detail=f"위키 문서 '{doc_path}' 이(가) 없습니다. " f"경로는 wiki/ 루트 상대(예: programs/ZFIR10070.md)여야 합니다.", ) text = target.read_text(encoding="utf-8") fm, _body = split_frontmatter(text) return { "path": target.relative_to(root).as_posix(), "human_verified": is_human_verified(fm), # 사람 교정 문서를 더 신뢰 (계획서 §6-3 규칙 0) "content": text, } @app.get("/wiki-viewer", response_class=HTMLResponse) def wiki_viewer() -> str: """위키 뷰어 — 요청 시점의 wiki/ 를 스캔해 렌더 (실시간).""" from wiki_out.viewer import build_html return build_html() @app.get("/dashboard", response_class=HTMLResponse) def dashboard() -> str: """관측소 대시보드 — 요청 시점의 index.db 상태를 담아 렌더 (실시간).""" with _summarizing_lock: running = sorted(_summarizing) return render_dashboard(running) @app.get("/summaries/status") def summaries_status() -> dict: """요약 진행 현황과 LLM 사용량/비용 로그 (관측소 대시보드·gateway용).""" con = connect() try: per_program = [dict(r) for r in con.execute( "SELECT u.program, COUNT(*) AS eligible, " "SUM(CASE WHEN u.summary_status='done' THEN 1 ELSE 0 END) AS done, " "SUM(CASE WHEN u.summary_status='failed' THEN 1 ELSE 0 END) AS failed, " "COALESCE(SUM(u.chunk_count),0) AS chunks, " "MAX(p.summary_status) AS program_summary " "FROM unit u JOIN program p ON p.name=u.program " "WHERE u.unit_type IN ('FORM','METHOD','FUNCTION','MODULE','EVENT') " "GROUP BY u.program ORDER BY u.program")] totals = con.execute( "SELECT COUNT(*) AS runs, COALESCE(SUM(calls),0) AS calls, " "COALESCE(SUM(prompt_tokens),0) AS prompt_tokens, " "COALESCE(SUM(completion_tokens),0) AS completion_tokens, " "COALESCE(SUM(cost_usd),0) AS cost_usd FROM llm_usage_log").fetchone() log = [dict(r) for r in con.execute( "SELECT * FROM llm_usage_log ORDER BY id DESC LIMIT 50")] failures = [dict(r) for r in con.execute( "SELECT program, unit_id, include, unit_type, name, summary_error " "FROM unit WHERE summary_status='failed' ORDER BY program, include, line_start")] with _summarizing_lock: running = sorted(_summarizing) return { "model": settings.llm_model, "llm_enabled": bool(settings.llm_base_url and settings.llm_api_key), "running": running, "programs": per_program, "failures": failures, "usage_totals": dict(totals), "usage_log": log, "job_queue": _job_queue_status(), } finally: con.close() def _job_queue_status() -> dict: """file 백엔드 작업 큐 현황 — LLM 키가 없을 때 진행 상황을 여기로 본다.""" from summarize.jobs import _index_rows, _status, jobs_dir d = jobs_dir() rows = _index_rows(d) pending = [r for r in rows if _status(d, r) == "pending"] return { "dir": str(d), "total": len(rows), "pending": len(pending), "answered": len(rows) - len(pending), "next": [ {"job_id": r["job_id"], "task": r.get("task", ""), "program": r.get("program", ""), "unit_id": r.get("unit_id", "")} for r in pending[:20] ], } @app.get("/summaries/jobs") def summaries_jobs() -> dict: """LLM 키 없이 돌리는 작업 큐 조회 (프롬프트 대기 목록). 채우는 방법은 `python -m summarize.jobs` 참고. API 로 응답을 받지는 않는다 — 프롬프트 본문이 커서 파일로 주고받는 쪽이 안전하다. """ return _job_queue_status() # ===== LLM 설정 (관측소 대시보드에서 요약 모델 전환) ===== _MODEL_RE = re.compile(r"^[A-Za-z0-9._\-/:]+$") ENV_PATH = ROOT / ".env" # 테스트에서 monkeypatch def _persist_env(key: str, value: str) -> None: """ENV_PATH 의 key= 줄을 교체(없으면 추가) — 서버 재시작 후에도 유지되게.""" lines = ENV_PATH.read_text(encoding="utf-8").splitlines() if ENV_PATH.exists() else [] for i, ln in enumerate(lines): if ln.startswith(f"{key}="): lines[i] = f"{key}={value}" break else: lines.append(f"{key}={value}") ENV_PATH.write_text("\n".join(lines) + "\n", encoding="utf-8") @app.get("/settings/llm") def get_llm_settings() -> dict: return { "model": settings.llm_model, "enabled": bool(settings.llm_base_url and settings.llm_api_key), } @app.put("/settings/llm") def put_llm_settings(payload: dict = Body()) -> dict: model = str(payload.get("model", "")).strip() if not model or not _MODEL_RE.match(model): raise HTTPException(status_code=400, detail="model 형식이 잘못됐습니다 (영숫자 · . - / : 만 허용)") settings.llm_model = model # 즉시 반영 — 다음 요약 배치부터 이 모델로 호출 _persist_env("LLM_MODEL", model) return {"model": model} def main() -> None: import argparse import uvicorn ap = argparse.ArgumentParser(description="Stage 5 — 질의 API / 관측소 / 위키 뷰어") ap.add_argument("--host", default=settings.index_host) ap.add_argument("--port", type=int, default=settings.index_port) ap.add_argument("--reload", action="store_true", help="소스 변경 시 자동 재시작. 템플릿(html)은 요청마다 다시 읽지만 " "파이썬 모듈은 프로세스에 캐시되므로, 코드를 고치는 중이면 이 옵션이 필요하다") args = ap.parse_args() if args.reload: uvicorn.run("query.api:app", host=args.host, port=args.port, reload=True) else: uvicorn.run(app, host=args.host, port=args.port) if __name__ == "__main__": main()