diff --git a/CLAUDE.md b/CLAUDE.md index dd18dc8..4cbe01b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,7 +50,9 @@ render_html() ttobak/render.py 원문/쉬운본 나란히 HTML + 면책 + 배지 - `max_revise` 소진 후 잔존 REVISE는 HUMAN_REVIEW로 승격 (fail-safe). - `score`/`verify`는 테스트 monkeypatch를 위해 모듈 레벨에서 import되어 있다 — 이 구조를 깨지 말 것. -**LLM 프로바이더 (`ttobak/providers/`):** `LLMProvider` Protocol + 팩토리 `get_provider()`. `fake`(테스트 전용 — 테스트는 절대 라이브 API를 치지 않는다), `anthropic`(데모 기본), `ollama`(로컬, 기본 `kanana-1.5-8b`). 실제 SDK는 생성 시점에 lazy import — optional extras 없이도 패키지가 import돼야 한다. `ttobak/web/provider.py`의 `make_provider()`는 이름 미지정 시 `$TTOBAK_PROVIDER` → `anthropic` 순으로 고르고, API 키 부재 시 FakeProvider로 폴백한다(CI 안전성). +**LLM 프로바이더 (`ttobak/providers/`):** `LLMProvider` Protocol + 팩토리 `get_provider()`. `ollama`(**기본** — 로컬 독립 구동, 기본 모델 `qwen2.5:7b`), `anthropic`(선택적 원격 대안), `fake`(테스트 전용 — 테스트는 절대 라이브 API를 치지 않는다). 실제 SDK는 생성 시점에 lazy import — optional extras 없이도 패키지가 import돼야 한다. `ttobak/web/provider.py`의 `make_provider()`는 이름 미지정 시 `$TTOBAK_PROVIDER` → `ollama` 순으로 고르고, 구성 실패 시 FakeProvider로 폴백하되 **stderr 경고 + 웹 UI 배너로 반드시 알린다**(스텁을 실제 변환으로 오인하면 안 됨). + +**로컬 우선은 대회 요건이자 설계 원칙이다.** 운영규정 제9조 ②-1-다는 "외부 API 호출을 통해서만 작동하는 상용 API 전용 모델을 서비스 형태로 단순 연결하는 출품작"을 제한한다. 기본값을 원격 API로 되돌리지 말 것. 기본 모델 태그는 **Ollama 공식 라이브러리에 실재하는 것**이어야 한다(`kanana-1.5-8b`는 없어서 `ollama pull`이 실패했다 — 2026-07-29 교체). **평가 (`ttobak/eval/`, `tooling/annotate_corpus.py`):** 코퍼스(`corpus/pairs.jsonl`) 주석은 손으로 지어내지 않는다 — 반드시 실제 엔진을 실행해 도출한다(재현 가능성 = 정직성). gold 페어는 fidelity 게이트 `verdict == PASS`여야 한다. diff --git a/README.md b/README.md index b249e64..2bb5c5d 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ CI License: Apache-2.0 Python 3.11+ - tests 403 passed + tests 411 passed Corpus CC BY 4.0

@@ -36,19 +36,25 @@ | 📏 **측정하는 쉬움** | 열두 가지 규칙(문장 길이·어려운 낱말·피동·부정 밀도 …)으로 K-ER 점수를 매기고, 점수보다 **규칙별 위반 체크리스트**를 핵심 산출물로 냅니다. | | 🧷 **사실이 먼저** | Fidelity 게이트가 금액·날짜·연락처·자격·기관명·개수를 슬롯별로 원문과 대조합니다. 값이 사라지면 자동 재교정, `미만→이하` 같은 의미 반전이나 `강서구청→송파구청` 같은 **기관 바꿔치기는 자동 교정 없이 사람 검수로** 돌려보냅니다. | | 📄 **포맷 네이티브** | 관공서 원본 포맷(PDF·HWPX)을 변환 없이 직접 파싱합니다. | -| 🔓 **전부 공개** | 코드(Apache-2.0)·코퍼스(CC BY 4.0)·평가 하네스까지 공개. 로컬 모델(큐원 2.5 7B, Apache-2.0)로도 동일하게 동작합니다. | +| 🔓 **전부 공개** | 코드(Apache-2.0)·코퍼스(CC BY 4.0)·평가 하네스까지 공개. 기본 구성이 로컬 오픈웨이트 모델(큐원 2.5 7B, Apache-2.0)이라 인터넷도 상용 API도 없이 돌아갑니다. | ## 빠른 시작 ```bash python3 -m venv venv && source venv/bin/activate # 가상환경 생성 (최신 macOS/우분투의 PEP 668 필수) -python -m pip install -e ".[dev]" # 설치 (Python 3.11+) -ttobak web --provider fake # 웹 데모 (fake = API 키 불필요) -python -m pytest -q # 테스트 (403개) +python -m pip install -e ".[dev,ollama]" # 설치 (Python 3.11+) +ollama serve & ollama pull qwen2.5:7b # 로컬 모델 (Apache-2.0, 약 4.7GB) +ttobak web # 웹 데모 — 기본이 로컬 모델입니다 +python -m pytest -q # 테스트 (411개) ``` -Anthropic API 키가 있으면 `--provider anthropic`, 로컬 Ollama가 있으면 -`--provider ollama`(기본 모델 `qwen2.5:7b`, Apache-2.0)로 실행합니다. +**또박은 인터넷 없이, 상용 API 없이 끝까지 동작합니다.** 필요한 모델은 직접 +내려받아 직접 구동하는 오픈웨이트 모델 하나뿐입니다. + +모델을 받기 전에 화면만 먼저 보고 싶으면 `ttobak web --provider fake`(고정 응답 +스텁 — 실제 변환 아님, 화면에 그렇게 표시됩니다). 원격 API를 쓰고 싶으면 +`--provider anthropic`도 있지만, 어느 것도 파이프라인의 필수 구성요소가 아닙니다 +— 자세한 건 [`docs/providers.md`](docs/providers.md). ## 아키텍처 — 공개 함수 6개 @@ -67,7 +73,7 @@ render_html() 원문/쉬운본 나란히 HTML + 면책 + 배지 - 공개 합성 코퍼스 11쌍: K-ER 평균 **71.2 → 80.7 (Δ +9.5)**, 규칙 위반 평균 −2.09건, **전 페어 Fidelity PASS** — 재현: `python -m tooling.annotate_corpus` -- 테스트 **403개** 통과 · 라이선스/보안 감사 clean (`ttobak audit`) +- 테스트 **411개** 통과 · 라이선스/보안 감사 clean (`ttobak audit`) - CI 5중 게이트: 정적분석(ruff) + pytest + 의존성 라이선스 허용목록 + 자산 분리 검사 + 감사 ## 정직성 (Honesty) — 반드시 읽어 주세요 diff --git a/docs/providers.md b/docs/providers.md index 8d0d362..e6a58df 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -12,29 +12,46 @@ Select one with the factory: ```python from ttobak.providers import get_provider -provider = get_provider("anthropic") # demo default (Claude) -provider = get_provider("ollama") # local fallback +provider = get_provider("ollama") # default — local, open-weight +provider = get_provider("anthropic") # optional remote alternative provider = get_provider("fake", responses=[]) # tests only ``` +**Ttobak runs end to end with no network and no commercial API.** The only +model it needs is an open-weight one you download and serve yourself. Every +remote option is an interchangeable alternative behind the same Protocol, never +a required component — nothing in the pipeline breaks if you never configure +one. + ## Providers -| Name | Class | Use | Dependency | -|-------------|---------------------|---------------------------------------|----------------------| -| `fake` | `FakeProvider` | Deterministic tests (never a live API) | none | -| `anthropic` | `AnthropicProvider` | Demo default; model `claude-opus-4-8` | `ttobak[anthropic]` | -| `ollama` | `OllamaProvider` | Local; default `kanana-1.5-8b` | `ttobak[ollama]` | +| Name | Class | Use | Dependency | +|-------------|---------------------|----------------------------------------------|---------------------| +| `ollama` | `OllamaProvider` | **Default**; local, model `qwen2.5:7b` | `ttobak[ollama]` | +| `anthropic` | `AnthropicProvider` | Optional remote; model `claude-opus-4-8` | `ttobak[anthropic]` | +| `fake` | `FakeProvider` | Deterministic tests (never a live API) | none | + +`ttobak web` with no `--provider` reads `$TTOBAK_PROVIDER`, then falls back to +`ollama`. If a provider cannot be constructed (package missing, daemon down, no +API key), the demo stays up on `FakeProvider` — but it says so loudly, on stderr +**and** as a banner in the web UI. A stub response must never be mistaken for a +real conversion. ## Local model decision (Apache-2.0 only, license gate) -- **1st choice (default): Kanana-1.5-8B** (Kakao, Apache-2.0) — strong Korean. - `get_provider("ollama")` uses `model="kanana-1.5-8b"`. -- **2nd choice: Qwen2.5-7B / 14B** (Apache-2.0) — - `get_provider("ollama", model="qwen2.5:7b")`. +- **Default: Qwen2.5-7B** (Alibaba, Apache-2.0) — `ollama pull qwen2.5:7b`. + Chosen as the default because it is in Ollama's official library, so a + first-time run needs no extra setup. `get_provider("ollama")` uses + `model="qwen2.5:7b"`. Also fine: `qwen2.5:14b`. +- **Alternative: Kanana-1.5** (Kakao, Apache-2.0) — strong Korean, but **not** + in Ollama's official library (`ollama.com/library/kanana` → 404, checked + 2026-07-29). It needs a Hugging Face GGUF tag, e.g. + `get_provider("ollama", model="hf.co/-GGUF:")`. It was the + default until 2026-07-29; a bare `kanana-1.5-8b` tag is unpullable, so the + default path failed on `ollama pull`. - **Excluded from the shipped path (NC / gated):** Qwen2.5-3B/72B, Kanana-2-30B, EXAONE. Documented as known NC alternatives only. -Demo runs default to the Anthropic API for quality; the local Ollama path is a -documented, license-clean fallback. Real providers import their SDK lazily at -construction, so the package imports cleanly without the optional extras and -the test suite (FakeProvider only) needs no LLM dependency. +Real providers import their SDK lazily at construction, so the package imports +cleanly without the optional extras and the test suite (FakeProvider only) needs +no LLM dependency. diff --git a/tests/providers/test_ollama_provider.py b/tests/providers/test_ollama_provider.py index 8a92818..d31d584 100644 --- a/tests/providers/test_ollama_provider.py +++ b/tests/providers/test_ollama_provider.py @@ -59,9 +59,26 @@ def test_generate_omits_system_message_when_none(): ] -def test_default_model_is_kanana(): +# (2026-07-29) 기본 모델을 kanana-1.5-8b -> qwen2.5:7b 로 교체. +# kanana 는 Ollama 공식 라이브러리에 없어(ollama.com/library/kanana → 404) +# `ollama pull kanana-1.5-8b` 가 실패한다. 즉 아무것도 지정하지 않고 실행하면 +# 기본 경로가 반드시 깨졌다. qwen2.5:7b 는 공식 라이브러리에 있고 Apache-2.0 +# 이며, 결과보고서 붙임2·시연영상이 신고·사용한 바로 그 모델이다. +def test_default_model_is_qwen25_7b(): provider = OllamaProvider(client=_FakeOllamaClient("ok")) - assert provider.model == "kanana-1.5-8b" + assert provider.model == "qwen2.5:7b" + + +def test_default_model_is_pullable_from_the_official_ollama_library(): + """기본 모델 태그는 `ollama pull ` 로 받을 수 있는 형식이어야 한다. + + Ollama 공식 라이브러리 모델은 `name` 또는 `name:tag` 형식이다. `hf.co/...` + 처럼 외부 저장소를 가리키거나, 라이브러리에 없는 이름을 기본값으로 두면 + 처음 실행하는 사람에게 곧바로 'model not found' 가 뜬다. + """ + model = OllamaProvider(client=_FakeOllamaClient("ok")).model + assert "/" not in model, f"기본 모델이 외부 저장소 경로다: {model}" + assert model.split(":")[0] == "qwen2.5" class _RecordingClientClass: diff --git a/tests/web/test_build_app.py b/tests/web/test_build_app.py index 846a4e3..21063eb 100644 --- a/tests/web/test_build_app.py +++ b/tests/web/test_build_app.py @@ -26,6 +26,23 @@ def test_build_app_contains_disclaimer(): assert "원문이 우선" in blob +# (2026-07-29) 스텁 모드가 화면에서 구분되지 않으면, 고정 응답을 실제 변환 +# 결과로 오인할 수 있다. 검증자는 터미널이 아니라 브라우저를 보므로 stderr +# 경고만으로는 부족하다 — 배너를 UI 에도 띄운다. +def test_build_app_shows_stub_banner_when_provider_is_fake(): + blob = str(webapp.build_app(provider=_fake()).get_config_file()) + assert "데모 스텁 모드" in blob + + +def test_build_app_has_no_stub_banner_with_a_real_provider(): + class _Realish: + def generate(self, prompt, *, system=None, max_tokens=2048): + return "쉬운 글입니다." + + blob = str(webapp.build_app(provider=_Realish()).get_config_file()) + assert "데모 스텁 모드" not in blob + + # Regression (#13, 2026-07-06): the pictogram "broken image" bug's real root # cause was the missing gr.set_static_paths() wiring. Without this test, deleting # that call passes all other web tests green while pictograms 403 again. diff --git a/tests/web/test_provider.py b/tests/web/test_provider.py index 7e5cad0..58428b6 100644 --- a/tests/web/test_provider.py +++ b/tests/web/test_provider.py @@ -1,5 +1,6 @@ +import ttobak.web.provider as provider_mod from ttobak.providers import FakeProvider -from ttobak.web import provider as provider_mod +from ttobak.web import provider as _alias # noqa: F401 — 기존 import 경로 유지 def test_explicit_fake_returns_fakeprovider(): @@ -26,3 +27,74 @@ def test_returned_provider_is_callable(): p = provider_mod.make_provider("fake") out = p.generate("안녕하세요", system=None, max_tokens=64) assert isinstance(out, str) + + +# --------------------------------------------------------------------------- +# 대회 운영규정 제9조 ②-1-다 대응 (2026-07-29) +# +# "외부 API 호출을 통해서만 작동하는 상용 API 전용 모델을 서비스 형태로 단순 +# 연결하는 출품작은 제한한다." 또박은 로컬 Ollama(Qwen2.5-7B / Kanana-1.5-8B, +# 모두 Apache-2.0)로 독립 구동되며, 원격 Anthropic 은 선택적 대안이다. +# 그 사실이 문서 문구가 아니라 **코드의 기본 동작**으로 드러나야 한다. +# --------------------------------------------------------------------------- + + +def test_default_provider_is_local_ollama(): + """기본 프로바이더는 로컬 독립 구동(ollama)이어야 한다 — 원격 API 아님.""" + assert provider_mod.DEFAULT_PROVIDER == "ollama" + + +def test_none_without_env_takes_the_ollama_path(monkeypatch): + """이름·환경변수 모두 없으면 ollama 경로를 탄다(생성자 호출로 확인).""" + monkeypatch.delenv("TTOBAK_PROVIDER", raising=False) + built = [] + + class _StubOllama: + def __init__(self, **kwargs): + built.append(kwargs) + + def generate(self, prompt, *, system=None, max_tokens=2048): + return "stub" + + monkeypatch.setattr(provider_mod, "OllamaProvider", _StubOllama) + p = provider_mod.make_provider(None) + assert built, "make_provider(None) must construct OllamaProvider" + assert isinstance(p, _StubOllama) + + +def test_ollama_construction_failure_falls_back_to_fake(monkeypatch): + """ollama 패키지·데몬이 없어도 데모는 죽지 않는다(CI 안전성).""" + def _boom(**kwargs): + raise ImportError("no ollama package") + + monkeypatch.setattr(provider_mod, "OllamaProvider", _boom) + assert isinstance(provider_mod.make_provider("ollama"), FakeProvider) + + +def test_fallback_to_fake_warns_loudly_on_stderr(monkeypatch, capsys): + """조용한 폴백 금지 — 스텁 응답을 실제 변환으로 오인하면 안 된다. + + (2026-07-29) 기능테스트에서 검증자가 키·데몬 없이 실행했을 때 고정 문장이 + 실제 모델 출력처럼 보이는 것이 가장 큰 위험이었다. + """ + def _boom(**kwargs): + raise RuntimeError("daemon down") + + monkeypatch.setattr(provider_mod, "OllamaProvider", _boom) + provider_mod.make_provider("ollama") + err = capsys.readouterr().err + assert "ollama" in err + assert "고정 응답" in err or "스텁" in err + + +def test_unknown_provider_name_also_warns(monkeypatch, capsys): + """오타난 이름이 조용히 스텁으로 떨어지지 않는다.""" + provider_mod.make_provider("gpt-4o") + err = capsys.readouterr().err + assert "gpt-4o" in err + + +def test_explicit_fake_does_not_warn(capsys): + """의도적으로 fake 를 고른 경우(테스트·CI)는 경고하지 않는다.""" + provider_mod.make_provider("fake") + assert capsys.readouterr().err == "" diff --git a/ttobak/cli.py b/ttobak/cli.py index 3f384a2..6186628 100644 --- a/ttobak/cli.py +++ b/ttobak/cli.py @@ -21,7 +21,8 @@ def _build_parser() -> argparse.ArgumentParser: web.add_argument("--host", default="127.0.0.1", help="바인드 호스트 (기본 127.0.0.1)") web.add_argument("--port", type=int, default=7860, help="포트 (기본 7860)") web.add_argument("--provider", default=None, - help="LLM 프로바이더 이름 (anthropic|fake). 미지정 시 $TTOBAK_PROVIDER 또는 anthropic") + help="LLM 프로바이더 이름 (ollama|anthropic|fake). " + "미지정 시 $TTOBAK_PROVIDER 또는 ollama(로컬 독립 구동)") web.add_argument("--share", action="store_true", help="Gradio 공개 링크 생성") audit_p = sub.add_parser("audit", help="라이선스·보안 게이트 실행 (spec 14.5)") diff --git a/ttobak/providers/__init__.py b/ttobak/providers/__init__.py index 19a35bb..d5de291 100644 --- a/ttobak/providers/__init__.py +++ b/ttobak/providers/__init__.py @@ -1,10 +1,14 @@ """LLM provider abstraction for Ttobak. +Ttobak runs end to end on local open-weight models; every remote option is an +interchangeable alternative, never a required component. Anything satisfying +the ``LLMProvider`` Protocol can be swapped in. + Public API: LLMProvider — the structural Protocol all providers satisfy. FakeProvider — deterministic scripted provider for tests. - AnthropicProvider — Claude provider (demo default). - OllamaProvider — local provider (Kanana-1.5-8B / Qwen2.5). + OllamaProvider — local provider (Qwen2.5-7B by default), the default. + AnthropicProvider — Claude provider (optional remote alternative). get_provider — factory selecting a provider by name. """ @@ -18,8 +22,8 @@ __all__ = [ "LLMProvider", "FakeProvider", - "AnthropicProvider", "OllamaProvider", + "AnthropicProvider", "get_provider", ] @@ -28,7 +32,7 @@ def get_provider(name: str, **kwargs) -> LLMProvider: """Build a provider by name. Args: - name: One of ``"fake"``, ``"anthropic"``, ``"ollama"`` (case-insensitive). + name: One of ``"ollama"``, ``"anthropic"``, ``"fake"`` (case-insensitive). **kwargs: Forwarded to the selected provider's constructor. Returns: diff --git a/ttobak/providers/anthropic_provider.py b/ttobak/providers/anthropic_provider.py index 9983f6d..c065099 100644 --- a/ttobak/providers/anthropic_provider.py +++ b/ttobak/providers/anthropic_provider.py @@ -1,4 +1,9 @@ -"""Anthropic (Claude) provider — the demo default. +"""Anthropic (Claude) provider — an optional remote alternative. + +Ttobak's default is the local :mod:`~ttobak.providers.ollama_provider`; this +module is one interchangeable implementation of the same ``LLMProvider`` +Protocol, never a required part of the pipeline. Nothing here is reachable +unless the caller asks for it by name (``--provider anthropic``). The ``anthropic`` SDK is an optional dependency, imported lazily at construction so importing this module never fails when the SDK is absent. diff --git a/ttobak/providers/ollama_provider.py b/ttobak/providers/ollama_provider.py index e8038e9..f03c9a1 100644 --- a/ttobak/providers/ollama_provider.py +++ b/ttobak/providers/ollama_provider.py @@ -1,7 +1,18 @@ -"""Ollama provider — the local fallback. +"""Ollama provider — the default. Runs entirely on the local machine. -Default local model: Kanana-1.5-8B (Kakao, Apache-2.0, strong Korean). -Documented secondary: Qwen2.5-7B / 14B (Apache-2.0) via ``model="qwen2.5:7b"``. +This is what makes Ttobak independently runnable with no network and no +commercial API: the pipeline's only model dependency is an open-weight model +you download and serve yourself. + +Default local model: Qwen2.5-7B (Alibaba, Apache-2.0) — pulled with +``ollama pull qwen2.5:7b``. Chosen as the default because it is in Ollama's +official library, so a first-time run works with no extra setup. Qwen2.5's +3B and 72B sizes are NC (Qwen Research License) and must not be used; 7B/14B +are Apache-2.0. + +Documented alternative: Kanana-1.5 (Kakao, Apache-2.0, strong Korean). It is +**not** in Ollama's official library, so it needs a Hugging Face GGUF tag +(``model="hf.co/-GGUF:"``) rather than a bare name. The ``ollama`` package is an optional dependency, imported lazily at construction. Tests inject a stand-in ``client`` and never touch a daemon. @@ -14,8 +25,9 @@ class OllamaProvider: """LLMProvider backed by a local Ollama daemon. Args: - model: Ollama model tag. Default ``kanana-1.5-8b``. Documented - alternative: ``qwen2.5:7b`` / ``qwen2.5:14b`` (Apache-2.0). + model: Ollama model tag. Default ``qwen2.5:7b`` (Apache-2.0, in the + official library). Also fine: ``qwen2.5:14b``. Kanana-1.5 needs a + Hugging Face GGUF tag — see the module docstring. host: Optional Ollama host URL (e.g. ``http://localhost:11434``). If ``None``, the client resolves it from the environment. timeout: Request timeout in seconds passed to ``ollama.Client``. @@ -29,7 +41,7 @@ class OllamaProvider: def __init__( self, *, - model: str = "kanana-1.5-8b", + model: str = "qwen2.5:7b", host: str | None = None, timeout: float | int = 120, client: object | None = None, diff --git a/ttobak/web/app.py b/ttobak/web/app.py index 662755e..6e4d3c1 100644 --- a/ttobak/web/app.py +++ b/ttobak/web/app.py @@ -20,6 +20,7 @@ from ttobak.metric.models import KERReport from ttobak.parse import parse from ttobak.pipeline import simplify +from ttobak.providers import FakeProvider from ttobak.providers.base import LLMProvider from ttobak.render import render_html @@ -119,6 +120,12 @@ def _fidelity_badge(verdict: Verdict) -> str: "사실충실성(Fidelity)을 함께 측정합니다." ) +_STUB_BANNER = ( + "데모 스텁 모드 — LLM 프로바이더가 연결되지 않아 고정 문장을 돌려줍니다. " + "실제 변환 결과가 아닙니다. 로컬 모델로 실행하려면 " + "`ollama serve` + `ollama pull qwen2.5:7b` 후 `ttobak web` 을 다시 실행하세요." +) + def build_app(provider: "LLMProvider | None" = None) -> "gr.Blocks": """Gradio 데모를 구성해 Blocks 를 반환한다(launch 는 호출자 책임).""" @@ -148,6 +155,11 @@ def _on_click(text_input: str, file_obj, level_label: str): with gr.Blocks(title="또박 Ttobak", analytics_enabled=False) as demo: gr.Markdown(_INTRO) + # FakeProvider 는 LLM 을 부르지 않고 미리 정해진 문장을 돌려준다. 그 화면을 + # 실제 변환 결과로 오인하면 안 되므로, 스텁일 때는 배너를 띄운다 — + # stderr 경고는 브라우저만 보는 사람에게 닿지 않는다(정직성 원칙). + if isinstance(bound_provider, FakeProvider): + gr.Markdown(f"> 🧪 **{_STUB_BANNER}**") with gr.Row(): with gr.Column(scale=1): with gr.Tab("텍스트 붙여넣기"): diff --git a/ttobak/web/provider.py b/ttobak/web/provider.py index 2741039..1f8af32 100644 --- a/ttobak/web/provider.py +++ b/ttobak/web/provider.py @@ -1,39 +1,76 @@ -"""LLM 프로바이더 선택 — 기본 Anthropic, 키 없으면 FakeProvider로 우아하게 폴백. +"""LLM 프로바이더 선택 — 기본은 로컬 Ollama(독립 구동), 원격 Anthropic 은 선택. -앱 빌더·CLI는 이 팩토리만 호출하고 환경 분기를 직접 하지 않는다(라이선스/CI 안전성). +또박은 로컬 오픈웨이트 모델(Qwen2.5-7B / Kanana-1.5-8B, 모두 Apache-2.0)만으로 +인터넷 없이 완결 동작한다. 원격 상용 API 는 교체 가능한 선택지 중 하나일 뿐이며, +어느 것도 파이프라인의 필수 구성요소가 아니다 — `LLMProvider` Protocol 을 만족하는 +어떤 구현으로도 갈아끼울 수 있다. + +앱 빌더·CLI 는 이 팩토리만 호출하고 환경 분기를 직접 하지 않는다(라이선스/CI 안전성). + +구성에 실패하면 FakeProvider(결정론적 고정 응답)로 폴백하되, **반드시 stderr 에 +경고를 남긴다.** 스텁 응답을 실제 변환 결과로 오인하는 것이 조용한 폴백의 진짜 +위험이기 때문이다(정직성 원칙). """ from __future__ import annotations import os +import sys -from ttobak.providers import AnthropicProvider, FakeProvider +from ttobak.providers import AnthropicProvider, FakeProvider, OllamaProvider from ttobak.providers.base import LLMProvider DEFAULT_PROVIDER_ENV = "TTOBAK_PROVIDER" +#: 이름·환경변수가 모두 없을 때 고르는 프로바이더. 로컬 독립 구동이 기본이다. +DEFAULT_PROVIDER = "ollama" + # Deterministic stub output for the CI/no-key fallback (canonical FakeProvider # raises when its queue empties with no default, so a default is required). _FAKE_DEFAULT = "쉬운 글로 바꾼 결과입니다.\n자세한 내용은 원문을 확인하세요." +_STUB_NOTICE = ( + "[또박] 경고: '{name}' 프로바이더를 구성하지 못해 데모 스텁으로 폴백했습니다" + "{reason}.\n" + "[또박] 지금 나오는 변환 결과는 실제 모델 출력이 아니라 고정 응답입니다.\n" + "[또박] 로컬 실행: `ollama serve` 후 `ollama pull qwen2.5:7b` " + "(pip install 'ttobak[ollama]') → `ttobak web`\n" +) + + +def _fallback_to_stub(name: str, exc: BaseException | None = None) -> FakeProvider: + """FakeProvider 로 폴백하면서 stderr 에 눈에 띄게 알린다.""" + reason = f" ({type(exc).__name__}: {exc})" if exc is not None else "" + print(_STUB_NOTICE.format(name=name, reason=reason), file=sys.stderr, end="") + return FakeProvider(default=_FAKE_DEFAULT) + def make_provider(name: str | None = None) -> LLMProvider: """이름으로 프로바이더를 생성한다. - name=None 이면 $TTOBAK_PROVIDER 를 읽고, 없으면 "anthropic" 을 기본으로 한다. - "anthropic" 구성 실패(예: ANTHROPIC_API_KEY 부재) 시 FakeProvider 로 폴백한다 — - 데모/CI가 라이브 API 없이도 항상 동작해야 하기 때문이다. + name=None 이면 $TTOBAK_PROVIDER 를 읽고, 없으면 :data:`DEFAULT_PROVIDER` + ("ollama" — 로컬 독립 구동)를 기본으로 한다. + + 구성 실패(ollama 패키지·데몬 부재, ANTHROPIC_API_KEY 부재 등) 시에는 + FakeProvider 로 폴백한다 — 데모/CI 가 라이브 API 없이도 항상 떠야 하기 + 때문이다. 폴백은 언제나 stderr 경고를 동반한다. """ if name is None: - name = os.environ.get(DEFAULT_PROVIDER_ENV) or "anthropic" + name = os.environ.get(DEFAULT_PROVIDER_ENV) or DEFAULT_PROVIDER name = name.strip().lower() if name == "fake": return FakeProvider(default=_FAKE_DEFAULT) + if name == "ollama": + try: + return OllamaProvider() + except Exception as exc: # noqa: BLE001 — 어떤 구성 실패든 데모는 떠야 한다 + return _fallback_to_stub("ollama", exc) + if name == "anthropic": try: return AnthropicProvider() - except Exception: - return FakeProvider(default=_FAKE_DEFAULT) + except Exception as exc: # noqa: BLE001 — 동상 + return _fallback_to_stub("anthropic", exc) - return FakeProvider(default=_FAKE_DEFAULT) + return _fallback_to_stub(name)