Skip to content

Commit 26507fd

Browse files
authored
Merge pull request #208 from CausalInferenceLab/develop/v0.3.0
Core: introduce RunContext + define-by-run tracing, contracts, and Sequential/Baseline flow foundation (0.3.0-dev)
2 parents e540875 + 276e9c0 commit 26507fd

20 files changed

+2422
-1
lines changed

docs/BaseComponent_ko.md

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
# BaseComponent
2+
3+
`BaseComponent`**define-by-run(순수 파이썬 제어)** 철학을 유지하면서도, 컴포넌트 실행을 **관측 가능(observable)** 하게 만들기 위한 **선택적(opt-in) 표준 레이어**입니다.
4+
5+
* 파이프라인은 `step(run: RunContext) -> RunContext` 형태의 **그냥 함수/콜러블**만으로도 충분히 동작합니다.
6+
* `BaseComponent`는 그 위에 **추적(hooks), 에러 표준화, 이름/형식 통일**을 얹어주는 역할을 합니다.
7+
8+
즉, **필수는 아니지만**, 라이브러리/팀 단위 개발에서 “운영 가능한 형태”로 만들고 싶을 때 유용합니다.
9+
10+
---
11+
12+
## 왜 필요한가?
13+
14+
### 1) 관측성(Tracing)을 “그래프 엔진 없이” 얻기 위해
15+
16+
Lang2SQL은 LangGraph 같은 그래프 엔진을 강제하지 않습니다. 대신:
17+
18+
* 사용자는 Python `if/for/while`로 제어한다.
19+
* 라이브러리는 관측성은 **hook 이벤트**로 제공한다.
20+
21+
`BaseComponent`는 각 컴포넌트 실행의 `start/end/error`를 이벤트로 남깁니다.
22+
23+
### 2) 에러를 “도메인 친화적으로” 정리하기 위해
24+
25+
현실에서는 `ValueError`, `KeyError`, 외부 라이브러리 예외 등이 섞여서 올라옵니다.
26+
27+
`BaseComponent`는:
28+
29+
* `Lang2SQLError`(ValidationError, IntegrationMissingError 등)는 **그대로 유지**
30+
* 그 외 예외는 `ComponentError`**표준 래핑**(+ 원인 예외를 `cause`로 보존)
31+
32+
→ 사용자/운영자 관점에서 “어디서 터졌는지”가 분명해집니다.
33+
34+
### 3) “컴포넌트 단위 표준”을 만들기 위해
35+
36+
라이브러리 제공 컴포넌트를 모두 BaseComponent 기반으로 만들면:
37+
38+
* 로그/트레이스의 포맷이 통일
39+
* 테스트/디버깅 경험이 일정
40+
* 문서/타입 힌트가 일관
41+
42+
---
43+
44+
## 철학: Define-by-run + Minimal core
45+
46+
Lang2SQL의 기본 철학은 아래 2개입니다.
47+
48+
1. **제어는 파이썬으로**
49+
루프/분기/재시도/서브플로우 호출은 “프레임워크 DSL”이 아니라 Python으로 표현합니다.
50+
51+
2. **상태는 RunContext 하나로**
52+
파이프라인이 커져도, step 간 연결이 깨지지 않도록 `RunContext`를 I/O로 둡니다.
53+
54+
`BaseComponent`는 이 철학을 해치지 않습니다.
55+
컴포넌트의 실행을 감싸서 이벤트만 남길 뿐, 그래프/스키마/실행 모델을 강제하지 않습니다.
56+
57+
---
58+
59+
## BaseComponent가 제공하는 API
60+
61+
### 생성자
62+
63+
```python
64+
BaseComponent(name: str | None = None, hook: TraceHook | None = None)
65+
```
66+
67+
* `name`: 이벤트에 찍힐 컴포넌트 이름 (기본값: 클래스명)
68+
* `hook`: 이벤트 수신자. 기본값은 `NullHook()` (아무것도 하지 않음)
69+
70+
### 구현해야 하는 것: `run()`
71+
72+
```python
73+
class MyComp(BaseComponent):
74+
def run(self, run: RunContext) -> RunContext:
75+
...
76+
return run
77+
```
78+
79+
### 실행: `__call__`
80+
81+
`comp(run)`을 호출하면 내부적으로 아래를 자동 수행합니다.
82+
83+
* `component.run start 이벤트 발행`
84+
* `self.run(...)` 실행
85+
* 성공 시 `end 이벤트` + `duration_ms`
86+
* 실패 시 `error 이벤트`
87+
88+
* 도메인 예외(`Lang2SQLError`)는 그대로 raise
89+
* 그 외 예외는 `ComponentError`로 래핑해서 raise
90+
91+
---
92+
93+
## 권장 규약: RunContext in → RunContext out
94+
95+
Lang2SQL의 기본 step 규약은 단순합니다.
96+
97+
> **RunContext를 받으면 RunContext를 반환한다.**
98+
> (`return run`을 습관처럼)
99+
100+
왜냐하면 “None 반환”은 인간이 보기엔 자연스럽지만, 팀/사용자 관점에서는 실수를 만들기 쉽습니다.
101+
102+
* `return None`은 “의도적”인지 “실수(반환 누락)”인지 구분이 안 됨
103+
* Flow/컴포넌트 조합에서 결과가 조용히 깨지기 쉬움
104+
105+
그래서 Lang2SQL은 **fail-fast** 스타일을 권장합니다.
106+
107+
---
108+
109+
## 언제 BaseComponent를 쓰는가?
110+
111+
### ✅ BaseComponent를 쓰는 게 좋은 경우
112+
113+
* 라이브러리 기본 제공 컴포넌트( retriever/builder/generator/validator )
114+
* 팀/제품 환경에서 **관측성(트레이싱)이 필요한 경우**
115+
* 예외 표준화가 중요한 경우(운영/테스트/디버깅)
116+
117+
### ✅ BaseComponent 없이 함수로 두는 게 좋은 경우
118+
119+
* `policy`, `eval`, metric 계산처럼 **순수 함수 성격**이 강한 로직
120+
* “유저가 빠르게 붙여 넣어 쓰는” 초경량 커스텀 로직
121+
* 실행 단위가 너무 작아 이벤트가 과도해지는 경우
122+
123+
즉, **핵심 파이프라인 축**은 BaseComponent로 잡고,
124+
그 외의 작은 로직은 함수로 두는 혼합형이 가장 자연스럽습니다.
125+
126+
---
127+
128+
## FunctionalComponent: “함수도 트레이싱하고 싶다”
129+
130+
유저에게 “클래스 상속 + run 메서드 작성”이 부담인 경우가 많습니다.
131+
그래서 **함수/콜러블을 그대로 유지하면서**도 트레이싱을 얻고 싶다면 래퍼를 제공합니다.
132+
133+
### 예시: FunctionalComponent
134+
135+
```python
136+
from __future__ import annotations
137+
from typing import Callable, Any, Optional
138+
139+
from .base import BaseComponent
140+
from .context import RunContext
141+
142+
class FunctionalComponent(BaseComponent):
143+
"""
144+
Wrap a callable(run: RunContext) -> RunContext into a BaseComponent,
145+
so it becomes traceable and error-normalized.
146+
"""
147+
148+
def __init__(
149+
self,
150+
fn: Callable[[RunContext], RunContext],
151+
*,
152+
name: str | None = None,
153+
hook=None,
154+
) -> None:
155+
super().__init__(name=name or getattr(fn, "__name__", "FunctionalComponent"), hook=hook)
156+
self._fn = fn
157+
158+
def run(self, run: RunContext) -> RunContext:
159+
return self._fn(run)
160+
```
161+
162+
### 사용 예
163+
164+
```python
165+
def my_retriever(run: RunContext) -> RunContext:
166+
run.schema_selected = ...
167+
return run
168+
169+
retriever = FunctionalComponent(my_retriever, name="MyRetriever", hook=hook)
170+
```
171+
172+
> 이 방식의 장점: 유저는 “함수 스타일” 그대로 유지하면서, 운영/디버깅을 위한 트레이싱을 얻게 됩니다.
173+
174+
---
175+
176+
## 훅(Tracing) 시스템이 뭐고, 왜 필요한가?
177+
178+
### Hook이란?
179+
180+
컴포넌트/플로우 실행 시점에 **이벤트(Event)** 를 받는 인터페이스입니다.
181+
182+
* `start/end/error` 시점 기록
183+
* 소요 시간(duration_ms)
184+
* 입력/출력 요약(input_summary/output_summary)
185+
* 필요하면 `data`에 구조화된 값을 추가
186+
187+
### 어디서 확인하나?
188+
189+
가장 쉬운 건 `MemoryHook`입니다.
190+
191+
```python
192+
from lang2sql.core.hooks import MemoryHook
193+
hook = MemoryHook()
194+
195+
flow = BaselineFlow(steps=[...], hook=hook) # 또는 컴포넌트마다 hook 주입
196+
out = flow.run_query("지난달 매출")
197+
198+
# 이벤트 확인
199+
for e in hook.events:
200+
print(e.phase, e.component, e.duration_ms, e.error)
201+
```
202+
203+
### 운영용 관측성은 어디서 제어하나?
204+
205+
운영에서는 `MemoryHook` 대신 다음이 일반적입니다.
206+
207+
* 로그로 흘리는 Hook (stdout / JSON log)
208+
* APM/Tracing으로 보내는 Hook (OpenTelemetry span 등)
209+
* 필터링 Hook (특정 컴포넌트만 샘플링)
210+
211+
핵심은: **관측성은 hook 구현체에서 제어**하고, 파이프라인/컴포넌트 코드는 최대한 “비즈니스 로직”만 갖도록 분리합니다.
212+
213+
---
214+
215+
## 중첩(서브플로우/래핑)하면 트레이싱이 깨지나?
216+
217+
“깨진다”기보다는 **이벤트가 더 많이 찍힙니다.**
218+
219+
* `flow_b` 안에 `flow_a`를 step으로 넣으면
220+
221+
* `flow_b` 이벤트 2개(시작/끝)
222+
* `flow_a` 이벤트 2개(시작/끝)
223+
* `a1/a2` 컴포넌트 이벤트도 각각 찍힘(컴포넌트가 BaseComponent라면)
224+
225+
이게 싫다면 두 가지 선택지가 있습니다.
226+
227+
1. **상위 레벨(Flow)만 트레이싱하고 내부는 함수로 둔다**
228+
2. **Hook에서 필터링/샘플링한다** (예: component 이름 prefix로 제외)
229+
230+
추가 문법 없이 해결하려면 2번이 가장 현실적입니다.
231+
232+
---
233+
234+
## 베스트 프랙티스
235+
236+
### 1) 구성(config)은 `__init__`에, 요청별 상태는 `RunContext`
237+
238+
```python
239+
class Retriever(BaseComponent):
240+
def __init__(self, catalog, top_k=8, ...):
241+
self.catalog = catalog # 고정 설정
242+
self.top_k = top_k
243+
244+
def run(self, run: RunContext) -> RunContext:
245+
# 요청마다 달라지는 값은 run에서 읽고 run에 쓴다
246+
...
247+
return run
248+
```
249+
250+
### 2) RunContext가 들어오면 무조건 `return run`
251+
252+
* 가독성(계약이 분명)
253+
* 실수 방지(fail-fast)
254+
* flow 합성 시 안정
255+
256+
### 3) “작은 로직(policy/eval)은 그냥 함수”
257+
258+
* BaseComponent로 감싸는 건 선택
259+
* 운영에서 꼭 추적이 필요할 때만 FunctionalComponent로 감싼다
260+
261+
---
262+
263+
## FAQ
264+
265+
### Q. “그냥 함수만 써도 되는데 왜 굳이 BaseComponent?”
266+
267+
A. **운영/디버깅/협업에서** 차이가 큽니다.
268+
문제 났을 때 “어디서, 어떤 입력으로, 얼마나 걸리다, 어떤 에러로” 터졌는지 자동으로 남는 게 핵심 가치입니다.
269+
270+
### Q. “BaseComponent를 유저가 직접 써야 하나?”
271+
272+
A. 필수 아닙니다.
273+
초급 유저는 **SequentialFlow + 프리셋 컴포넌트**만으로 충분히 쓰게 하고,
274+
고급/운영 유저에게 BaseComponent/Hook을 제공하는 구성이 가장 자연스럽습니다.
275+
276+
### Q. “policy는 RunContext를 몰라도 되는데?”
277+
278+
A. 맞습니다. `policy(metrics) -> action` 같은 건 순수 함수로 두는 걸 권장합니다.
279+
필요하면 `FunctionalComponent(policy_fn)`처럼 감싸서 추적만 추가할 수 있습니다.
280+
281+
---

0 commit comments

Comments
 (0)