# Yagra: Declarative LangGraph Builder

Yagra は、YAML 定義から LangGraph の `StateGraph` を構築・実行する Python ライブラリです。
フロー制御（分岐・ループ）とノード設定（prompt/model など）をコードから分離し、
`workflow.yaml` の差し替えで挙動を切り替えられます。

## 公開運用ポリシー

- パッケージ配布: Public（PyPI）
- ソースリポジトリ: Private（GitHub）
- 利用者向け導線は PyPI を正とし、リポジトリ参照は必須としません。

## 主な特徴

- Schema-Driven: Pydantic で YAML 構造を検証
- Registry Pattern: `handler` 文字列と Python callable を疎結合に接続
- Typed State: `state_schema` に TypedDict/Pydantic などの状態スキーマを指定可能
- Zero-Boilerplate: `Yagra.from_workflow(...)` で構築コードを最小化

## インストール（利用者向け）

- Python 3.12+

```bash
pip install yagra
```

## 開発セットアップ（メンテナー向け）

この手順は private リポジトリへのアクセス権があるメンテナー向けです。

```bash
git clone https://github.com/shogo-hs/Yagra.git
cd Yagra
uv sync --dev
```

## クイックスタート（条件分岐あり）

### 1. State とノード関数を定義

```python
from typing import TypedDict

from yagra import Yagra


class AgentState(TypedDict, total=False):
    query: str
    intent: str
    answer: str
    __next__: str


def classify_intent(state: AgentState, params: dict) -> dict:
    _ = params
    intent = "faq" if "料金" in state.get("query", "") else "general"
    return {"intent": intent, "__next__": intent}


def answer_faq(state: AgentState, params: dict) -> dict:
    prompt = params.get("prompt", {})
    return {"answer": f"FAQ: {prompt.get('system', '')}"}


def answer_general(state: AgentState, params: dict) -> dict:
    model = params.get("model", {})
    return {"answer": f"GENERAL via {model.get('name', 'unknown')}"}


def finish(state: AgentState, params: dict) -> dict:
    _ = params
    return {"answer": state.get("answer", "")}
```

### 2. Workflow YAML を定義

`workflows/support.yaml`

```yaml
version: "1.0"
start_at: "classifier"
end_at:
  - "finish"

nodes:
  - id: "classifier"
    handler: "classify_intent"
  - id: "faq_bot"
    handler: "answer_faq"
    params:
      prompt:
        system: "pricing response"
  - id: "general_bot"
    handler: "answer_general"
    params:
      model:
        provider: "openai"
        name: "gpt-4.1-mini"
  - id: "finish"
    handler: "finish"

edges:
  - source: "classifier"
    target: "faq_bot"
    condition: "faq"
  - source: "classifier"
    target: "general_bot"
    condition: "general"
  - source: "faq_bot"
    target: "finish"
  - source: "general_bot"
    target: "finish"
```

### 3. Registry と実行

`registry` は `dict[str, callable]` を直接渡せます（`InMemoryNodeRegistry` は内部で自動利用）。

```python
registry = {
    "classify_intent": classify_intent,
    "answer_faq": answer_faq,
    "answer_general": answer_general,
    "finish": finish,
}

app = Yagra.from_workflow(
    workflow_path="workflows/support.yaml",
    registry=registry,
    state_schema=AgentState,
)

result = app.invoke({"query": "料金を教えて"})
print(result["answer"])
```

## API

### `Yagra.from_workflow(...)`

```python
Yagra.from_workflow(
    workflow_path: str | PathLike,
    registry: NodeRegistryPort | Mapping[str, NodeHandler],
    bundle_root: str | PathLike | None = None,
    state_schema: Any = dict,
) -> Yagra
```

- `workflow_path`: 入口となる workflow YAML
- `registry`: `NodeRegistryPort` 実装、または `dict[str, callable]`
- `bundle_root`: 分割参照解決の基準ディレクトリ（省略時は workflow の親）
- `state_schema`: LangGraph の状態スキーマ（既定 `dict`）

### `Yagra.invoke(...)`

```python
Yagra.invoke(state: Mapping[str, Any]) -> dict[str, Any]
```

### `yagra visualize`（Read Only 可視化）

Workflow YAML を Read Only の可視化 HTML に変換します。

```bash
yagra visualize \
  --workflow examples/workflows/loop-split.yaml \
  --output /tmp/yagra-view.html \
  --title "Yagra Workflow Viewer"
```

オプション:

- `--workflow`: 可視化対象 workflow ファイル（必須）
- `--bundle-root`: 分割参照解決の基準ディレクトリ（任意）
- `--output`: 生成 HTML の出力先（既定: `workflow-visualization.html`）
- `--title`: ページタイトル（任意）

検証エラーがある場合は HTML は生成せず、エラー一覧を標準エラーへ出力します。

### `yagra studio`（フォーム + DnD 編集 + 保存）

Workflow の `prompt` / `model` / `condition` 編集に加え、DnD でノード追加・接続変更を行い、diff 確認・保存・rollback を実行するローカル WebUI/API を起動します。

```bash
# ランチャー起動（UI で workflow を選択/新規作成）
yagra studio --port 8787

# 従来どおり直接指定で起動
yagra studio \
  --workflow examples/workflows/loop-split.yaml \
  --bundle-root examples \
  --port 8787
```

起動後はブラウザで `http://127.0.0.1:8787/` を開きます。

主な編集フロー:

1. 必要に応じて `Add Node` でノードを追加し、Node/Edge フォームで値を編集して `Apply ...`
   - `Add Node` は選択中ノードの近傍（未選択時は自動レイアウト位置）へ自動配置されます。
2. Node Properties の `prompt yaml` で参照先 YAML を選択すると、`system prompt` / `user prompt` が自動で読み込まれます。
3. `prompt yaml` 未選択のまま `Apply Node Edit` した場合、workflow YAML と同階層の `prompts/<node-id>.yaml`（重複時は連番）が自動作成され、`prompt_ref` が自動設定されます。
   - `prompt key` を指定した場合は `path#key` 形式になり、生成 YAML は `{ key: { system, user } }` 形式になります。
4. Graph Canvas 上でノードをドラッグして位置調整し、右側ポート（output）から左側ポート（input）へドラッグしてエッジ追加（戻りループは下側 output → 上側 input で接続）
5. 再接続時はエッジ端点をドラッグして接続先を変更（ドラッグ起点が新 source、ドロップ先が新 target）
6. Node Properties で `node id`（リネーム）, `prompt_ref`, `Model Settings`（`provider` / `name` / `temperature` など）を設定
   - `node id` を変更して `Apply Node Edit` すると、関連する `edges[].source/target` と `start_at/end_at` も自動で同期されます。
7. `Preview Diff` で変更差分と validation を確認
8. `Save` で workflow を保存（backup 作成）
9. 必要なら `Rollback` で `backup_id` から復元

補足:
- Studio 画面は操作性優先のため `Workflow/UI State` の生JSONは常時表示しません。必要時は `GET /api/workflow` / `GET /api/workflow/form` で確認してください。

オプション:

- `--workflow`: 編集対象 workflow ファイル（任意、未指定時はランチャーで選択/作成）
- `--bundle-root`: 分割参照解決の基準ディレクトリ（任意）
- `--ui-state`: UI サイドカー JSON の保存先（既定: `<workflow>.workflow-ui.json`）
- `--workspace-root`: ランチャーが探索/作成可能なワークスペースルート（既定: `<workflow> の親` またはカレント）
- `--backup-dir`: バックアップ保存先（既定: `.yagra/backups`）
- `--host`: バインドホスト（既定: `127.0.0.1`）
- `--port`: バインドポート（既定: `8787`）

保存時は以下を実施します。

- 保存前 validation（M-05 契約）
- atomic write（中途半端な書き込み防止）
- backup 作成（`<backup-dir>/<workflow-stem>/<backup_id>.*`）
- 失敗時の自動復旧

フォーム向け API:

- `GET /api/workflow/form`
- `POST /api/workflow/form/preview`
  - M-09 の入力契約として、下記フィールドはすべて必須
  - `base_revision`, `ui_state`
  - `node_creates[]` / `node_edits[]`
  - `edge_creates[]` / `edge_rewires[]` / `edge_edits[]`

## 仕様上の契約（Implicit Contracts）

### 1. 条件分岐の契約

- `edges[].condition` は分岐ラベルです。
- 分岐元ノードは `{"__next__": "<condition-label>"}` を state update として返します。
- 例: `condition: "faq"` がある場合、`__next__` は `"faq"` を返す必要があります。

### 2. prompt 参照解決の契約

- `prompt_ref` は実行前に解決されます。
- handler が受け取る `params` には解決済み値が入ります。
  - `params["prompt"]`
  - `params["model"]`
- `prompt_ref` と `prompt` を同時指定した場合、`prompt_ref` 解決結果に `prompt` が上書きマージされます（ノード側優先）。
- 実行時に handler へ渡る `params` からは `prompt_ref` は除去されます（`prompt`/`model` を利用）。
- `model_ref` は廃止です。モデル設定は `nodes[].params.model` にインライン定義します。
- `prompt_ref` の参照形式:
  - `<path>`（YAML ルートが prompt mapping の場合）
  - `<path>#<key.path>`（YAML 内キーを指定）

例:

```yaml
nodes:
  - id: planner
    handler: planner_loop_handler
    params:
      prompt_ref: "../prompts/support_prompts.yaml#planner"
      model:
        provider: openai
        name: gpt-4.1-mini
        kwargs:
          temperature: 0.1
```

### 3. `end_at` の挙動

- `end_at` は「終了ノードIDのリスト」です（複数可）。
- Yagra は各ノードを LangGraph の finish point として登録します。
- YAML 上で `END` という特別ノードを直接書く仕様ではありません。

### 4. ノード関数シグネチャ

Yagra は次の順で呼び出しを試みます。

1. `handler(state, params)`
2. `handler(state)`

## YAML 仕様（要点）

トップレベル:

- `version: str`
- `start_at: str`
- `end_at: list[str]`
- `nodes: list[NodeSpec]`
- `edges: list[EdgeSpec]`
- `params: dict[str, Any]`（任意）

`NodeSpec`:

- `id: str`
- `handler: str`
- `params: dict[str, Any]`（任意）

`EdgeSpec`:

- `source: str`
- `target: str`
- `condition: str | null`（任意）

補足:
- `edges` は 0 件でも有効です（例: `start_at` と `end_at` が同一の単一ノード workflow）。

## 同梱サンプル

- `examples/workflows/branch-inline.yaml`
  - 条件分岐の最小例
- `examples/workflows/loop-split.yaml`
  - ループ + 条件分岐 + `prompt_ref` 分割参照 + model インライン定義
- `examples/prompts/support_prompts.yaml`
- `examples/models/openai_models.yaml`

## 開発コマンド

```bash
uv run ruff check .
uv run mypy .
uv run pytest -q
uv run pre-commit run --all-files
```

## ドキュメント（Sphinx）

ローカルで HTML を生成:

```bash
uv run sphinx-build -b html docs/sphinx/source docs/sphinx/_build/html
```

生成先:

- `docs/sphinx/_build/html/index.html`

公開先（GitHub Pages）:

- `https://shogo-hs.github.io/Yagra/`

公開の前提:

- GitHub の `Settings > Pages` で Source を `GitHub Actions` に設定する
- `.github/workflows/docs.yml` が `main` への push または手動実行で成功する

## PyPI 公開

公開ワークフローは `v*` タグ push をトリガーに実行されます。

- workflow: `.github/workflows/publish.yml`
- build: `uv build`
- 検証: `uvx twine check dist/*`
- publish: `pypa/gh-action-pypi-publish`（OIDC）

前提:

- PyPI 側で private repository `shogo-hs/Yagra` を Trusted Publisher として登録しておく
- GitHub Actions の `pypi` environment が利用可能である
- Git タグ（`vX.Y.Z`）と `pyproject.toml` の `version` を一致させる

リリース実行例:

```bash
git tag v0.1.3
git push origin v0.1.3
```

## ライセンスと変更履歴

- ライセンス: `LICENSE`（MIT）
- 変更履歴: `CHANGELOG.md`

## エージェント実行規約

エージェント運用ルールは `AGENTS.md` を参照してください。
