Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`여야 한다.

Expand Down
22 changes: 14 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<a href="https://github.com/needsbuilder/ttobak/actions/workflows/ci.yml"><img src="https://github.com/needsbuilder/ttobak/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<img src="https://img.shields.io/badge/code-Apache--2.0-blue" alt="License: Apache-2.0">
<img src="https://img.shields.io/badge/python-3.11%2B-3776AB" alt="Python 3.11+">
<img src="https://img.shields.io/badge/tests-403_passed-2ea44f" alt="tests 403 passed">
<img src="https://img.shields.io/badge/tests-411_passed-2ea44f" alt="tests 411 passed">
<img src="https://img.shields.io/badge/corpus-CC_BY_4.0-lightgrey" alt="Corpus CC BY 4.0">
</p>

Expand Down Expand Up @@ -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개

Expand All @@ -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) — 반드시 읽어 주세요
Expand Down
47 changes: 32 additions & 15 deletions docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<repo>-GGUF:<quant>")`. 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.
21 changes: 19 additions & 2 deletions tests/providers/test_ollama_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <tag>` 로 받을 수 있는 형식이어야 한다.

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:
Expand Down
17 changes: 17 additions & 0 deletions tests/web/test_build_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
74 changes: 73 additions & 1 deletion tests/web/test_provider.py
Original file line number Diff line number Diff line change
@@ -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():
Expand All @@ -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 == ""
3 changes: 2 additions & 1 deletion ttobak/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down
12 changes: 8 additions & 4 deletions ttobak/providers/__init__.py
Original file line number Diff line number Diff line change
@@ -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.
"""

Expand All @@ -18,8 +22,8 @@
__all__ = [
"LLMProvider",
"FakeProvider",
"AnthropicProvider",
"OllamaProvider",
"AnthropicProvider",
"get_provider",
]

Expand All @@ -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:
Expand Down
7 changes: 6 additions & 1 deletion ttobak/providers/anthropic_provider.py
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
Loading
Loading