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 @@
-
+
@@ -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)