1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Jevで要件定義書をレビューするアプリを作った

1
Last updated at Posted at 2026-09-19

はじめに

2026年9月にリリースされた TypeSafe の Jev という「文章を生成しないAIモデル」で要求定義書をレビューするアプリを作成しました。19の観点から要件定義書を一括評価し、不足している項目と改善ガイダンスを表示する Streamlit アプリです。

この記事では、Jev とは何か(なぜ普通のLLMじゃないのか)、APIキーの取得方法、そして作ったアプリの中身と設計を順に説明します。


Jev とは何か

「System One モデル」という新しいカテゴリ

Jev は TypeSafe AI が2026年9月に公開したモデルで、System One モデルという新しいカテゴリに属します。

心理学のダニエル・カーネマンが言うところの「システム1(速い・直感的な判断)」と「システム2(遅い・熟考的な推論)」のうち、前者だけを担当するモデルです。

普通のLLM(System Two的なもの)は文章を生成します。だから私たちはこんなコードを書きがちです。

# よくあるやつ
response = llm.chat("この要件定義書に異常系の記載はありますか?JSONで答えて")
result = json.loads(response)  # ← パースが失敗する可能性
if result["has_exceptions"]:   # ← キーが無い可能性
    ...

プロンプトでJSONで返却するように設定し、返ってきた文字列をパースし、失敗に備えてリトライを書く。構造化出力や Function Calling(関数呼び出し)である程度マシにはなりますが、「モデルが文章を生成し、それを解釈する」という構造自体は変わりません。

Jev はここが根本的に違います。そもそもテキストを生成しません。

3つのプリミティブ

Jev に投げられる質問は3種類だけです。

種類 問い 返り値
Noul Yes/No で答えられる命題 その命題が真である確率(0〜1)
Score 順序付きの基準で採点 確率加重されたスコア + 各レベルの確率分布 + confidence
Choice 定義済みの選択肢から分類 選ばれた選択肢 + 全選択肢の確率分布 + confidence

返ってくるのはスキーマで定義した値のみです。定義外の値が返ることは構造上あり得ません。TypeSafe はこれを「ハルシネーションが数学的に不可能」と表現しています。パースも、バリデーションも、リトライも要りません。

ちなみに「Noul」は Jev 独自の用語で、yes/no 命題に対する確率値を指します。

並列評価とコンテキスト汚染がないこと

Jev は state(評価対象)と questions(質問の集合)を分けて渡します。各質問は同じ state に対して独立・並列に評価されます。

        ┌── Q1: 問題は書かれているか?  ─┐
state ──┼── Q2: 対象ユーザーは?        ─┼── すべて並列・独立に評価
        ├── Q3: 受け入れ条件は?        ─┤
        └── ... Q19                    ─┘

つまり、質問を増やしてもレイテンシがほぼ変わらず(19問でも1問でもほぼ同じ)、質問同士が干渉せず(Q1の判断がQ2に影響しない)、コンテキストも汚れません(会話履歴が積み上がらない)。

チャットLLMで19個の質問を投げると、後半の質問ほど前の回答に引きずられます。いわゆる context rot です。Jev にはそれがありません。19観点のレビューという用途に、これ以上ないほど噛み合っています。

料金

入力トークンのみ課金で、出力トークンは無料です。

価格
入力 $0.042 / 100万トークン
出力 $0(構造化された決定しか返さないため)

出力が「確率」や「選択肢」だけなので、課金するほどの量にならない、という理屈です。今回のアプリは要件定義書を1回送るだけなので、数千文字の文書なら1回のレビューが0.01円以下です。

向いていること・向いていないこと

向いているのは分類、ルーティング、フィルタリング、バリデーション、品質スコアリング、ガードレール、エージェントの分岐判断です。逆に文章生成、要約、翻訳、コード生成には使えません。

Jev は「書く」モデルではなく「判断する」モデルです。なので今回のアプリでも、改善アドバイスの文面は Jev に書かせていません。判断は Jev、文面はアプリ側の固定テキスト、という役割分担にしています。詳しくは review_definitions.py の項で触れます。


APIキーの取得

手順

  1. typesafe.ai にアクセスしてサインアップ
    • 2026年9月時点では早期アクセス(ウェイトリスト)経由の場合があります
  2. console.typesafe.ai/settings/keys を開く
  3. 「Create key」でキーを発行し、その場でコピーする(再表示できないタイプのキーです)

設定方法

本アプリでは、.streamlit/secrets.toml か環境変数のどちらでも読めるようにしています。

方法A、.streamlit/secrets.toml に書く。

TYPESAFE_API_KEY = "sk-..."

方法B、環境変数で渡す。

# Windows (PowerShell)
$env:TYPESAFE_API_KEY = "sk-..."

# macOS / Linux
export TYPESAFE_API_KEY="sk-..."

アプリ側の解決順は「secrets.toml → 環境変数」です。

def _get_api_key() -> str | None:
    try:
        secret = st.secrets.get("TYPESAFE_API_KEY")
    except Exception:
        secret = None
    value = secret or os.getenv("TYPESAFE_API_KEY")
    return str(value).strip() if value else None

.streamlit/secrets.toml は必ず .gitignore に入れてください。


作ったアプリ「Jev Requirements Reviewer」

できること

要件定義書(Markdown / プレーンテキスト)を貼り付けて「レビューする」を押すと、19観点の評価結果が出ます。

  • 総合準備度(%)、仕様成熟度、最大の不足領域、要確認件数をダッシュボード表示
  • 準備度バランスのレーダーチャートと、状態別件数の横棒グラフ
  • 不足項目を「不足の可能性 → 人間確認 → 補強推奨」の順に並べ、改善ガイダンスを表示
  • 全19項目の評価一覧と結果JSONのダウンロード
  • APIキー無しで動くデモモード(外部送信ゼロ)

アプリ画面
image.png

出力結果
image.png

技術スタック

項目 内容
言語 Python 3.13
UI Streamlit 1.49+(検証時は 1.64.0)
可視化 Altair 5+(検証時は 6.3.0)
AI TypeSafe Jev(typesafe-sdk 0.7.0)
パッケージ管理 uv
テスト 標準 unittest(外部API非依存・13ケース)

ディレクトリ構成

Jev_Requirements_Reviewer/
├── app.py                          # Streamlit UI層
├── jev_reviewer/                   # ドメインロジック
│   ├── __init__.py
│   ├── models.py                   # ReviewFinding / ReviewReport
│   ├── review_definitions.py       # 19の質問定義 + ガイダンス
│   ├── reviewer.py                 # Jevアダプタ + デモ実装 + 判定ロジック
│   └── charts.py                   # レーダーチャート + 状態別グラフ
├── tests/test_reviewer.py
├── pyproject.toml
└── .streamlit/
    ├── config.toml
    └── secrets.toml

ここから全ソースを載せます。依存の下から順に、pyproject.tomlmodels.pyreview_definitions.pyreviewer.pycharts.pyapp.py → テスト、という並びです。そのままコピーすれば動きます。

pyproject.toml

依存は Streamlit、Altair、typesafe-sdk の3つです。uv add で入れると自動で追記されます。

uv init            # 新規に作る場合
uv add "streamlit>=1.49.0,<2.0.0" "typesafe-sdk>=0.5.7,<1.0.0" "altair>=5.0.0,<7.0.0"

結果はこうなります。

pyproject.toml
[project]
name = "jev-requirements-reviewer"
version = "0.1.0"
description = "Jevで要件定義書を19観点から並列レビューするStreamlitアプリ"
readme = "README.md"
authors = [
    { name = "miso-taku", email = "wk.ta93mis@gmail.com" }
]
requires-python = ">=3.13"
dependencies = [
    "altair>=5.0.0,<7.0.0",
    "streamlit>=1.49.0,<2.0.0",
    "typesafe-sdk>=0.5.7,<1.0.0",
]

[tool.uv]
package = false

[tool.uv] package = false でハマったので書いておきます。uv init が既定で入れる uv_build バックエンドは src/<パッケージ名>/__init__.py を探すため、jev_reviewer/ を直置きした構成だとビルドに失敗します。

× Failed to build `jev-requirements-reviewer`
╰─▶ Expected a Python module at: src\jev_requirements_reviewer\__init__.py

配布用パッケージではなくアプリなので、package = false で「uv には環境だけ見てもらう」形にしました。src レイアウトへ移すか、パッケージ名を jev_reviewer に合わせるのでも解決します。

jev_reviewer/models.py

Jev の返り値を受け取るドメインモデルです。ReviewFinding が1項目の評価結果、ReviewReport が19件まとめたレポート。

numeric_value() は Choice(文字列)のとき None を返します。これで総合スコアの集計から分類結果が自然に外れます。

jev_reviewer/models.py
"""正規化されたレビュー結果のドメインモデル。"""

from __future__ import annotations

from dataclasses import asdict, dataclass, field
from typing import Any, Literal

QuestionKind = Literal["noul", "score", "choice"]
FindingStatus = Literal["good", "warning", "critical", "review"]


@dataclass(frozen=True)
class ReviewFinding:
    """レビュアーから返される正規化された1件の結果。

    Args:
        question_id: アプリケーションで使用される安定した識別子。
        label: ユーザー向けの日本語ラベル。
        group: この結果の表示グループ。
        kind: 質問で使用されるJevのプリミティブ。
        value: 選択された値、または正規化されたスコア。
        confidence: 結果に対して報告または導出された確信度。
        status: アプリケーションのポリシーによって決定される表示上の重要度。
        guidance: 静的な改善ガイダンス。
        probabilities: Jevから得られる確率分布(任意)。
    """

    question_id: str
    label: str
    group: str
    kind: QuestionKind
    value: str | float
    confidence: float | None
    status: FindingStatus
    guidance: str
    probabilities: dict[str, float] = field(default_factory=dict)

    def numeric_value(self) -> float | None:
        """集計スコアに利用できる場合にその値を返す。

        Returns:
            0から1の範囲の値。カテゴリカルな結果の場合はNone。
        """

        if isinstance(self.value, (int, float)):
            return max(0.0, min(1.0, float(self.value)))
        return None


@dataclass(frozen=True)
class ReviewReport:
    """正規化されたレビューレポート全体。

    Args:
        model: レポートを生成したモデルまたはモード。
        findings: 個々のレビュー結果。
        overall_score: 0から1の範囲の総合的な準備度スコア。
        overall_label: ユーザー向けの準備度ラベル。
        input_tokens: APIから報告された場合の入力トークン数。
    """

    model: str
    findings: tuple[ReviewFinding, ...]
    overall_score: float
    overall_label: str
    input_tokens: int | None = None

    def to_dict(self) -> dict[str, Any]:
        """レポートをJSONシリアライズ可能なデータに変換する。

        Returns:
            レポートの辞書表現。
        """

        return asdict(self)

    def get_finding(self, question_id: str) -> ReviewFinding | None:
        """安定した識別子から1件の結果を検索する。

        Args:
            question_id: 検索対象の識別子。

        Returns:
            一致した結果。存在しない場合はNone。
        """

        return next(
            (
                finding
                for finding in self.findings
                if finding.question_id == question_id
            ),
            None,
        )

jev_reviewer/review_definitions.py

19の評価観点をデータとして定義したファイルです。長いですが QuestionDefinition の羅列です。

構成は Noul 12 + Score 4 + Choice 3 = 19。Noul(12件)は「その記載があるか」を確率で判定します。

グループ 項目
目的・価値 解決する問題 / 対象ユーザー / 利用者への価値
境界・制約 スコープ / 制約条件
機能・検証 入力と出力 / 受け入れ条件 / 異常系・例外
品質・運用 非機能要件 / セキュリティ / 運用・復旧 / 責任者

Score(4件)は目的の明確さ / 検証可能性 / 実現可能性 / 運用準備度を5段階で評価し、基準は POSITIVE_SCORE_LEVELS として共通化しました。Choice(3件)は仕様成熟度、次に行うこと、最大の不足領域の分類です。

各定義の最後のフィールドが改善ガイダンスで、これは Jev の生成物ではなくアプリ側の固定文言です。Jev は「異常系が書かれているか」を判断するだけ、「何を書けばいいのか」はアプリが答える。アドバイスの品質が毎回ブレず、チームの知見で育てられて、生成コストもゼロになります。判断はAI、知識は人間、という分担です。

jev_reviewer/review_definitions.py
"""レビュー質問とアプリケーション側で管理するガイダンス。"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Literal

QuestionKind = Literal["noul", "score", "choice"]


@dataclass(frozen=True)
class QuestionDefinition:
    """1つの独立したレビュー質問の定義。

    Args:
        question_id: リクエストとレスポンスで使用される安定した識別子。
        label: ユーザー向けのラベル。
        group: 表示グループ。
        kind: Jevのプリミティブ型。
        instructions: Jevへ送信する質問文全体。
        criteria: Choiceのマッピング、またはScoreの順序付きレベル(任意)。
        guidance: 結果に改善が必要な場合に表示するアドバイス。
    """

    question_id: str
    label: str
    group: str
    kind: QuestionKind
    instructions: str
    criteria: dict[str, str] | tuple[str, ...] | None
    guidance: str


POSITIVE_SCORE_LEVELS = (
    "0: 記載がなく、判断できない",
    "1: わずかに示されているが、重要部分が曖昧",
    "2: 基本的な内容はあるが、補足が必要",
    "3: 具体的で、実務上ほぼ判断できる",
    "4: 明確かつ具体的で、境界条件まで判断できる",
)


QUESTIONS: tuple[QuestionDefinition, ...] = (
    QuestionDefinition(
        "problem_defined",
        "解決する問題",
        "目的・価値",
        "noul",
        "この要件定義書には、解決すべき現状の問題が具体的に記載されている。",
        None,
        "現状、困っている主体、発生している損失を具体的に記述してください。",
    ),
    QuestionDefinition(
        "target_user_defined",
        "対象ユーザー",
        "目的・価値",
        "noul",
        "この要件定義書には、システムを利用する対象ユーザーまたは役割が明確に記載されている。",
        None,
        "利用者、運用者、管理者など、関係する役割を分けて記述してください。",
    ),
    QuestionDefinition(
        "user_value_defined",
        "利用者への価値",
        "目的・価値",
        "noul",
        "この要件定義書には、実現後に利用者が得る価値または改善される状態が記載されている。",
        None,
        "機能ではなく、利用者の行動や状態がどう改善するかを記述してください。",
    ),
    QuestionDefinition(
        "scope_defined",
        "スコープ",
        "境界・制約",
        "noul",
        "この要件定義書には、今回実現する範囲と実現しない範囲の境界が記載されている。",
        None,
        "対象範囲と対象外を明記し、後から期待が広がらないようにしてください。",
    ),
    QuestionDefinition(
        "inputs_outputs_defined",
        "入力と出力",
        "機能・検証",
        "noul",
        "この要件定義書には、主要機能の入力と期待する出力が具体的に記載されている。",
        None,
        "主要ユースケースごとに、入力、処理結果、利用者へ返す情報を定義してください。",
    ),
    QuestionDefinition(
        "acceptance_criteria_defined",
        "受け入れ条件",
        "機能・検証",
        "noul",
        "この要件定義書には、実装完了を客観的に判定できる受け入れ条件が記載されている。",
        None,
        "Given/When/Thenなどを使い、期待結果を検証可能な条件にしてください。",
    ),
    QuestionDefinition(
        "exceptions_defined",
        "異常系・例外",
        "機能・検証",
        "noul",
        "この要件定義書には、入力不正、処理失敗、外部サービス停止などの異常系が記載されている。",
        None,
        "入力不正、タイムアウト、部分成功、再実行時の動作を定義してください。",
    ),
    QuestionDefinition(
        "constraints_defined",
        "制約条件",
        "境界・制約",
        "noul",
        "この要件定義書には、技術、期間、予算、既存システムなどの制約条件が記載されている。",
        None,
        "変更できない条件と、仮定して進める条件を分けて記述してください。",
    ),
    QuestionDefinition(
        "nonfunctional_defined",
        "非機能要件",
        "品質・運用",
        "noul",
        "この要件定義書には、性能、可用性、保守性など必要な非機能要件が記載されている。",
        None,
        "応答時間、処理量、可用性、保存期間などを測定可能な値で定義してください。",
    ),
    QuestionDefinition(
        "security_defined",
        "セキュリティ",
        "品質・運用",
        "noul",
        "この要件定義書には、認証、認可、機密情報、監査などのセキュリティ要件が記載されている。",
        None,
        "誰が何を閲覧・操作できるか、機密情報をどう扱うかを定義してください。",
    ),
    QuestionDefinition(
        "operations_defined",
        "運用・復旧",
        "品質・運用",
        "noul",
        "この要件定義書には、監視、障害対応、復旧、問い合わせ対応などの運用要件が記載されている。",
        None,
        "監視対象、通知先、復旧手順、判断責任者を定義してください。",
    ),
    QuestionDefinition(
        "owner_defined",
        "責任者",
        "品質・運用",
        "noul",
        "この要件定義書には、業務判断、運用、承認に責任を持つ役割が記載されている。",
        None,
        "仕様、運用、例外判断について、役割単位で責任者を定義してください。",
    ),
    QuestionDefinition(
        "goal_clarity",
        "目的の明確さ",
        "総合評価",
        "score",
        "この要件定義書の目的がどの程度明確か評価する。",
        POSITIVE_SCORE_LEVELS,
        "目的、対象者、期待する変化の因果関係を一文で説明できるようにしてください。",
    ),
    QuestionDefinition(
        "testability",
        "検証可能性",
        "総合評価",
        "score",
        "この要件定義書が、実装結果を客観的にテストできる状態か評価する。",
        POSITIVE_SCORE_LEVELS,
        "曖昧な形容詞を避け、観測可能な結果と合否条件へ置き換えてください。",
    ),
    QuestionDefinition(
        "feasibility",
        "実現可能性",
        "総合評価",
        "score",
        "記載された前提と制約の範囲で、この要件を実現できる見通しがどの程度あるか評価する。",
        POSITIVE_SCORE_LEVELS,
        "未知の技術要素、外部依存、データ入手性を明示し、検証タスクを追加してください。",
    ),
    QuestionDefinition(
        "operational_readiness",
        "運用準備度",
        "総合評価",
        "score",
        "リリース後の運用を開始できる程度に、監視、障害対応、責任分担が定義されているか評価する。",
        POSITIVE_SCORE_LEVELS,
        "正常運用だけでなく、障害検知、連絡、復旧、事後確認まで定義してください。",
    ),
    QuestionDefinition(
        "maturity",
        "仕様成熟度",
        "総合判定",
        "choice",
        "この要件定義書の現在の成熟度として最も近いものを選ぶ。",
        {
            "idea": "解決したいことのアイデアが中心で、詳細は未定義",
            "hypothesis": "目的と価値の仮説はあるが、検証や境界が不足",
            "requirements": "主要要件はあるが、例外や非機能要件の補強が必要",
            "implementation_ready": "実装と受け入れテストを開始できる具体性がある",
        },
        "不足項目を補い、実装担当者が追加質問なしで判断できる状態を目指してください。",
    ),
    QuestionDefinition(
        "next_action",
        "次に行うこと",
        "総合判定",
        "choice",
        "この要件定義書を前進させるため、次に最優先で行うべき活動を選ぶ。",
        {
            "clarify_problem": "問題、対象者、目的を整理する",
            "user_research": "ユーザーや業務担当者へヒアリングする",
            "detail_requirements": "機能、例外、受け入れ条件を具体化する",
            "technical_spike": "不確実な技術要素を小さく検証する",
            "start_implementation": "実装と受け入れテストを開始する",
        },
        "選択された活動を、担当者と期限を持つ具体的なタスクへ分解してください。",
    ),
    QuestionDefinition(
        "largest_gap",
        "最大の不足領域",
        "総合判定",
        "choice",
        "この要件定義書で、実装前に最も補う必要がある領域を選ぶ。",
        {
            "customer_value": "問題、対象ユーザー、提供価値",
            "business_rules": "機能要件、業務ルール、例外",
            "verification": "受け入れ条件、テスト可能性",
            "technology": "技術制約、外部依存、実現可能性",
            "operations_security": "非機能、運用、セキュリティ、責任分担",
        },
        "最大の不足領域から補強し、変更後に同じ観点で再レビューしてください。",
    ),
)

QUESTION_BY_ID = {question.question_id: question for question in QUESTIONS}

CHOICE_LABELS: dict[str, str] = {
    "idea": "アイデア段階",
    "hypothesis": "仮説整理中",
    "requirements": "要件整理中",
    "implementation_ready": "実装準備完了",
    "clarify_problem": "問題と目的を整理",
    "user_research": "ユーザーヒアリング",
    "detail_requirements": "要件を具体化",
    "technical_spike": "技術検証",
    "start_implementation": "実装を開始",
    "customer_value": "顧客価値",
    "business_rules": "業務ルール",
    "verification": "検証可能性",
    "technology": "技術",
    "operations_security": "運用・セキュリティ",
}

jev_reviewer/reviewer.py

アプリの心臓部です。Reviewer Protocol、本番用の JevReviewer、デモ用の DemoReviewer、両者が共有する判定ロジック(finding_statusbuild_report)が入っています。Protocol を挟んだので UI は Jev を一切知らず、テストは外部API無しで完結し、Jev を別のモデルへ差し替えても UI は無変更です。

JevReviewer.review() は19問を組み立てて client.system_one() を1回だけ呼びます。本文は state として1回送られ、19問がそれを共有するので、19回個別に呼ぶより入力トークンがおよそ1/19で済みます。Jev は入力課金なので、そのままコストに直結します。

statereview_policy も地味に効きました。これが無いと Jev は「こういう文書なら普通こうだよね」と行間を補完しがちです。「書かれていることだけで判断しろ、足りないなら低く評価しろ」と明示して、辛口な判定にしています。

_normalize_answer() は3種類の返り値を 0〜1 に揃えます。Noul はそのまま、Score は raw_score / (レベル数 - 1)、Choice は文字列のまま通します。

confidence だけ補足します。ScoreAnswerChoiceAnswerconfidence を持ちますが、NoulAnswertypenoul だけです。Noul の返り値はそれ自体が確率なので、0.5 からの距離がそのまま確信度になります。

confidence = abs(value - 0.5) * 2
# noul=0.95 → confidence 0.90
# noul=0.50 → confidence 0.00  ← 判断がつかない=人間が見るべき
# noul=0.05 → confidence 0.90  ← 「確実に書かれていない」も高確信

これを自前算出しておけば、3種類すべてを同じ confidence の土俵で扱え、「確信度が低い項目は人間が確認する」という共通ルールを全19項目へ適用できます。

finding_status() が confidence チェックを最優先にしているのが設計上の肝です。スコアが高くても Jev が自信を持てていないなら、それは「良好」ではなく「人間が見るべき項目」です。AIに丸投げせず、AIが迷ったところを人間に回す。閾値はサイドバーのスライダーで調整できます。

DemoReviewer は外部送信ゼロの決定論的ヒューリスティックです。単なるキーワードマッチで、UI確認用と画面上でも警告しています。ただ「Jev がキーワードマッチとどれだけ違うか」の比較対象にもなりました。デモは「エラー」の一語で異常系ありと判定しますが、Jev は「エラーが起きたらログに出す」程度の記述なら低く評価します。この差が System One モデルの価値そのものです。

jev_reviewer/reviewer.py
"""Jev APIアダプタと決定論的なデモレビュアー。"""

from __future__ import annotations

from collections.abc import Mapping
from typing import Any, Protocol

from jev_reviewer.models import FindingStatus, ReviewFinding, ReviewReport
from jev_reviewer.review_definitions import (
    CHOICE_LABELS,
    QUESTIONS,
    QuestionDefinition,
)


class Reviewer(Protocol):
    """実際のレビュアーとデモレビュアーが実装するインターフェース。"""

    def review(self, document: str) -> ReviewReport:
        """1つの要件定義書をレビューする。"""

        ...


class JevReviewer:
    """TypeSafe Jev APIを通じて要件定義書をレビューする。

    Args:
        api_key: TypeSafe APIキー。
        model: Jevモデルのエイリアス、または固定のモデル識別子。
        confidence_threshold: この値を下回る結果は人間による確認が必要になる。
    """

    def __init__(
        self,
        api_key: str,
        model: str = "jev-latest",
        confidence_threshold: float = 0.5,
    ) -> None:
        if not api_key:
            raise ValueError("TypeSafe APIキーが設定されていません。")
        self._api_key = api_key
        self._model = model
        self._confidence_threshold = confidence_threshold

    def review(self, document: str) -> ReviewReport:
        """Jevへ1回の並列レビューリクエストを送信する。

        Args:
            document: レビュー対象の要件定義書。

        Returns:
            正規化されたレビューレポート。

        Raises:
            ValueError: 文書が空の場合。
            TypeSafeError: APIリクエストが失敗した場合。
        """

        if not document.strip():
            raise ValueError("レビュー対象を入力してください。")

        from typesafe_sdk import Choice, Noul, Score, TypeSafeClient

        questions: dict[str, Any] = {}
        for definition in QUESTIONS:
            if definition.kind == "noul":
                questions[definition.question_id] = Noul(
                    instructions=definition.instructions,
                )
            elif definition.kind == "score":
                questions[definition.question_id] = Score(
                    instructions=definition.instructions,
                    criteria=list(definition.criteria or ()),
                )
            else:
                questions[definition.question_id] = Choice(
                    instructions=definition.instructions,
                    criteria=dict(definition.criteria or {}),
                )

        state = {
            "document_type": "software requirements specification",
            "review_policy": (
                "記載された内容だけを根拠に判断する。暗黙の前提を補わず、"
                "情報が不足している場合は低い評価または不確実な評価にする。"
            ),
            "document": document,
        }

        with TypeSafeClient(
            api_key=self._api_key,
            model=self._model,
        ) as client:
            response = client.system_one(state=state, questions=questions)

        findings = tuple(
            self._normalize_answer(
                definition,
                response.answers[definition.question_id],
            )
            for definition in QUESTIONS
        )
        usage = getattr(response, "usage", None)
        input_tokens = getattr(usage, "input_tokens", None)
        resolved_model = str(getattr(response, "model", self._model))
        return build_report(
            model=resolved_model,
            findings=findings,
            input_tokens=input_tokens,
        )

    def _normalize_answer(
        self,
        definition: QuestionDefinition,
        answer: Any,
    ) -> ReviewFinding:
        """SDKのレスポンスオブジェクトをアプリケーションのモデルに変換する。"""

        probabilities = _mapping_to_float_dict(
            getattr(answer, "probabilities", None)
        )
        confidence = _optional_float(getattr(answer, "confidence", None))

        if definition.kind == "noul":
            value = float(answer.noul)
            confidence = abs(value - 0.5) * 2
        elif definition.kind == "score":
            raw_score = float(answer.score)
            level_count = len(definition.criteria or ())
            value = raw_score / max(level_count - 1, 1)
        else:
            value = str(answer.choice)

        status = finding_status(
            kind=definition.kind,
            value=value,
            confidence=confidence,
            confidence_threshold=self._confidence_threshold,
        )
        return ReviewFinding(
            question_id=definition.question_id,
            label=definition.label,
            group=definition.group,
            kind=definition.kind,
            value=value,
            confidence=confidence,
            status=status,
            guidance=definition.guidance,
            probabilities=probabilities,
        )


class DemoReviewer:
    """アプリケーションのプレビューに使用する決定論的なローカルレビュアー。

    Args:
        confidence_threshold: この値を下回る結果は人間による確認が必要になる。
    """

    KEYWORDS: dict[str, tuple[str, ...]] = {
        "problem_defined": ("課題", "問題", "現状", "困って"),
        "target_user_defined": ("利用者", "ユーザー", "担当者", "管理者"),
        "user_value_defined": ("価値", "改善", "削減", "向上", "短縮"),
        "scope_defined": ("対象範囲", "対象外", "スコープ"),
        "inputs_outputs_defined": ("入力", "出力", "表示", "返す"),
        "acceptance_criteria_defined": ("受け入れ", "完了条件", "合格", "以内"),
        "exceptions_defined": ("異常", "例外", "失敗", "タイムアウト", "エラー"),
        "constraints_defined": ("制約", "期限", "予算", "既存"),
        "nonfunctional_defined": ("性能", "可用性", "応答時間", "保守", "保存期間"),
        "security_defined": ("認証", "認可", "権限", "機密", "監査"),
        "operations_defined": ("運用", "監視", "復旧", "通知", "障害対応"),
        "owner_defined": ("責任者", "承認者", "担当部門", "オーナー"),
    }

    def __init__(self, confidence_threshold: float = 0.5) -> None:
        self._confidence_threshold = confidence_threshold

    def review(self, document: str) -> ReviewReport:
        """外部にデータを送信せず、ローカルのサンプルレポートを作成する。

        Args:
            document: 簡易的なヒューリスティックで検査する要件定義書。

        Returns:
            決定論的なデモ用レポート。

        Raises:
            ValueError: 文書が空の場合。
        """

        if not document.strip():
            raise ValueError("レビュー対象を入力してください。")

        noul_values = {
            question_id: self._keyword_probability(document, keywords)
            for question_id, keywords in self.KEYWORDS.items()
        }
        positive_average = sum(noul_values.values()) / len(noul_values)
        length_bonus = min(len(document) / 3000, 1.0) * 0.15

        findings: list[ReviewFinding] = []
        for definition in QUESTIONS:
            if definition.kind == "noul":
                value: str | float = noul_values[definition.question_id]
                confidence = abs(float(value) - 0.5) * 2
                probabilities: dict[str, float] = {}
            elif definition.kind == "score":
                modifier = {
                    "goal_clarity": 0.05,
                    "testability": -0.02,
                    "feasibility": 0.03,
                    "operational_readiness": -0.08,
                }.get(definition.question_id, 0.0)
                value = max(
                    0.05,
                    min(0.95, positive_average + length_bonus + modifier),
                )
                confidence = 0.72
                probabilities = {}
            else:
                value = self._demo_choice(
                    definition.question_id,
                    positive_average,
                    noul_values,
                )
                confidence = 0.78
                probabilities = {str(value): 0.72, "other": 0.28}

            findings.append(
                ReviewFinding(
                    question_id=definition.question_id,
                    label=definition.label,
                    group=definition.group,
                    kind=definition.kind,
                    value=value,
                    confidence=confidence,
                    status=finding_status(
                        definition.kind,
                        value,
                        confidence,
                        self._confidence_threshold,
                    ),
                    guidance=definition.guidance,
                    probabilities=probabilities,
                )
            )

        return build_report(
            model="demo-local-heuristics",
            findings=tuple(findings),
        )

    @staticmethod
    def _keyword_probability(document: str, keywords: tuple[str, ...]) -> float:
        matches = sum(keyword in document for keyword in keywords)
        probability = 0.18 + min(matches, 3) * 0.24
        return round(min(probability, 0.92), 3)

    @staticmethod
    def _demo_choice(
        question_id: str,
        average: float,
        values: dict[str, float],
    ) -> str:
        if question_id == "maturity":
            if average >= 0.75:
                return "implementation_ready"
            if average >= 0.5:
                return "requirements"
            if average >= 0.3:
                return "hypothesis"
            return "idea"
        if question_id == "next_action":
            if average >= 0.75:
                return "start_implementation"
            if values["problem_defined"] < 0.5:
                return "clarify_problem"
            if values["acceptance_criteria_defined"] < 0.5:
                return "detail_requirements"
            return "technical_spike"

        groups = {
            "customer_value": (
                values["problem_defined"]
                + values["target_user_defined"]
                + values["user_value_defined"]
            ) / 3,
            "business_rules": (
                values["inputs_outputs_defined"]
                + values["exceptions_defined"]
                + values["scope_defined"]
            ) / 3,
            "verification": values["acceptance_criteria_defined"],
            "technology": values["constraints_defined"],
            "operations_security": (
                values["operations_defined"]
                + values["security_defined"]
                + values["nonfunctional_defined"]
            ) / 3,
        }
        return min(groups, key=groups.get)


def finding_status(
    kind: str,
    value: str | float,
    confidence: float | None,
    confidence_threshold: float,
) -> FindingStatus:
    """1件の結果をどのように表示すべきか判定する。

    Args:
        kind: 質問のプリミティブ型。
        value: 正規化された数値、または選択されたカテゴリ。
        confidence: 利用可能な場合の0から1の確信度。
        confidence_threshold: 人間による確認が必要となる閾値。

    Returns:
        good、warning、critical、reviewのいずれか。
    """

    if confidence is not None and confidence < confidence_threshold:
        return "review"
    if kind == "choice":
        return "good"
    numeric_value = float(value)
    if numeric_value >= 0.75:
        return "good"
    if numeric_value >= 0.5:
        return "warning"
    return "critical"


def build_report(
    model: str,
    findings: tuple[ReviewFinding, ...],
    input_tokens: int | None = None,
) -> ReviewReport:
    """個々の結果から集計レポートの値を作成する。

    Args:
        model: モデルまたはモード名。
        findings: 正規化された結果。
        input_tokens: トークン使用量(任意)。

    Returns:
        完全なレビューレポート。
    """

    numeric_values = [
        value
        for finding in findings
        if finding.kind != "choice"
        if (value := finding.numeric_value()) is not None
    ]
    overall_score = (
        sum(numeric_values) / len(numeric_values) if numeric_values else 0.0
    )
    if overall_score >= 0.8:
        label = "実装準備が整っています"
    elif overall_score >= 0.6:
        label = "一部の要件を補強してください"
    else:
        label = "要件整理が必要です"

    return ReviewReport(
        model=model,
        findings=findings,
        overall_score=overall_score,
        overall_label=label,
        input_tokens=input_tokens,
    )


def display_value(finding: ReviewFinding) -> str:
    """ユーザー向けにローカライズされた値を返す。

    Args:
        finding: 表示対象の結果。

    Returns:
        パーセンテージ、またはローカライズされたカテゴリラベル。
    """

    if isinstance(finding.value, str):
        return CHOICE_LABELS.get(finding.value, finding.value)
    return f"{finding.value:.0%}"


def _mapping_to_float_dict(value: Any) -> dict[str, float]:
    if value is None:
        return {}
    if isinstance(value, Mapping):
        return {str(key): float(item) for key, item in value.items()}
    try:
        return {str(key): float(item) for key, item in dict(value).items()}
    except (TypeError, ValueError):
        return {}


def _optional_float(value: Any) -> float | None:
    return None if value is None else float(value)

総合スコアは build_report() のとおり、Choice(数値でない)を除いた Noul 12 + Score 4 = 16件の平均です。

overall_score >= 0.8   実装準備が整っています
overall_score >= 0.6   一部の要件を補強してください
それ未満               要件整理が必要です

jev_reviewer/charts.py

数字の羅列だと「どこが弱いのか」が掴みにくいので、Altair のレーダーチャートと状態別件数の横棒グラフを足しました。

image.png

19項目をそのまま19軸にすると読めない図になるので、radar_axes() で畳んでいます。

  • Noul 12件は表示グループごとに平均して4軸(目的・価値 / 境界・制約 / 機能・検証 / 品質・運用)
  • Score 4件はそれぞれ1軸のまま
  • Choice 3件は数値軸に載らないので除外

結果として8軸のレーダーになります。デモモードで動かすとこんな値が出ます。

目的・価値    34%
境界・制約    18%
機能・検証    26%
品質・運用    18%
目的の明確さ  29%
検証可能性    22%
実現可能性    27%
運用準備度    16%

Choice を除外する理由は models.pynumeric_value() と同じで、「アイデア段階」のようなカテゴリ値は 0〜1 の軸上に置けないからです。総合スコアの集計と同じ基準を可視化にも使っています。

Altair にレーダーの組み込みマークは無いので、極座標を自分で計算して直交座標へ落としています。angle = 2πi/n - π/2 で各軸の方向を出し、値を半径として (cos, sin) を掛ける。あとは同心円(RADAR_RINGS)、スポーク、折れ線と塗り、頂点マーカー、ラベルの5レイヤーを alt.layer() で重ねるだけです。折れ線を閉じるため先頭の点を末尾へ複製しているのが地味なポイントで、忘れると図形が開いたままになります。

軸が3本未満ならレーダーとして成立しないので None を返し、呼び出し側でメッセージに切り替えます。

jev_reviewer/charts.py
"""レビュー結果を可視化するためのチャート生成。"""

from __future__ import annotations

import math
from collections import OrderedDict

import altair as alt

from jev_reviewer.models import ReviewReport

RADAR_RINGS = (0.25, 0.5, 0.75, 1.0)
STATUS_COLOR = {
    "good": "#16A34A",
    "warning": "#D97706",
    "critical": "#DC2626",
    "review": "#2563EB",
}


def radar_axes(report: ReviewReport) -> list[dict[str, object]]:
    """レーダーチャートの軸となる集計値を作成する。

    Noulは表示グループごとの平均、Scoreは項目単体を1軸として扱う。
    Choiceはカテゴリカルで数値軸に載らないため除外する。

    Args:
        report: 集計対象のレビューレポート。

    Returns:
        label、value、detailを持つ軸の一覧。
    """

    groups: OrderedDict[str, list[float]] = OrderedDict()
    axes: list[dict[str, object]] = []

    for finding in report.findings:
        numeric = finding.numeric_value()
        if numeric is None:
            continue
        if finding.kind == "noul":
            groups.setdefault(finding.group, []).append(numeric)
        else:
            axes.append(
                {
                    "label": finding.label,
                    "value": numeric,
                    "detail": f"{finding.label}(Score)",
                }
            )

    grouped_axes = [
        {
            "label": group,
            "value": sum(values) / len(values),
            "detail": f"{group}(Noul {len(values)}項目の平均)",
        }
        for group, values in groups.items()
    ]
    return grouped_axes + axes


def radar_chart(report: ReviewReport) -> alt.LayerChart | None:
    """レビュー結果の準備度をレーダーチャートとして描画する。

    Args:
        report: 描画対象のレビューレポート。

    Returns:
        Altairのレイヤーチャート。軸が3本未満の場合はNone。
    """

    axes = radar_axes(report)
    if len(axes) < 3:
        return None

    count = len(axes)
    points = []
    for index, axis in enumerate(axes):
        angle = 2 * math.pi * index / count - math.pi / 2
        value = float(axis["value"])
        points.append(
            {
                "label": axis["label"],
                "detail": axis["detail"],
                "value": value,
                "percent": f"{value:.0%}",
                "order": index,
                "x": value * math.cos(angle),
                "y": value * math.sin(angle),
                "axis_x": math.cos(angle),
                "axis_y": math.sin(angle),
                "label_x": 1.18 * math.cos(angle),
                "label_y": 1.18 * math.sin(angle),
            }
        )

    # 折れ線を閉じるため、先頭の点を末尾へ複製する。
    closing = dict(points[0])
    closing["order"] = count
    area_data = points + [closing]

    grid = [
        {
            "ring": ring,
            "x": ring * math.cos(2 * math.pi * index / count - math.pi / 2),
            "y": ring * math.sin(2 * math.pi * index / count - math.pi / 2),
            "order": index,
        }
        for ring in RADAR_RINGS
        for index in range(count + 1)
    ]

    scale = alt.Scale(domain=[-1.45, 1.45])
    axis_none = alt.Axis(
        title=None,
        labels=False,
        ticks=False,
        domain=False,
        grid=False,
    )
    x_position = alt.X("x:Q", scale=scale, axis=axis_none)
    y_position = alt.Y("y:Q", scale=scale, axis=axis_none)

    rings = (
        alt.Chart(alt.Data(values=grid))
        .mark_line(stroke="#CBD5E1", strokeWidth=1, opacity=0.7)
        .encode(
            x=x_position,
            y=y_position,
            order=alt.Order("order:Q"),
            detail=alt.Detail("ring:Q"),
        )
    )

    spokes = (
        alt.Chart(alt.Data(values=points))
        .mark_rule(stroke="#CBD5E1", strokeWidth=1, opacity=0.7)
        .encode(
            x=alt.X("zero:Q", scale=scale, axis=axis_none),
            y=alt.Y("zero:Q", scale=scale, axis=axis_none),
            x2="axis_x:Q",
            y2="axis_y:Q",
        )
        .transform_calculate(zero="0")
    )

    area = (
        alt.Chart(alt.Data(values=area_data))
        .mark_line(
            stroke="#2563EB",
            strokeWidth=2,
            fill="#2563EB",
            fillOpacity=0.18,
        )
        .encode(x=x_position, y=y_position, order=alt.Order("order:Q"))
    )

    markers = (
        alt.Chart(alt.Data(values=points))
        .mark_point(filled=True, size=70, color="#2563EB")
        .encode(
            x=x_position,
            y=y_position,
            tooltip=[
                alt.Tooltip("detail:N", title="評価軸"),
                alt.Tooltip("percent:N", title="準備度"),
            ],
        )
    )

    labels = (
        alt.Chart(alt.Data(values=points))
        .mark_text(fontSize=12, color="#172033")
        .encode(
            x=alt.X("label_x:Q", scale=scale, axis=axis_none),
            y=alt.Y("label_y:Q", scale=scale, axis=axis_none),
            text="label:N",
        )
    )

    return (
        alt.layer(rings, spokes, area, markers, labels)
        .properties(width=440, height=440)
        .configure_view(stroke=None)
    )


def status_chart(report: ReviewReport) -> alt.Chart:
    """状態ごとの件数を横棒グラフとして描画する。

    Args:
        report: 描画対象のレビューレポート。

    Returns:
        Altairの棒グラフ。
    """

    order = ("critical", "review", "warning", "good")
    japanese = {
        "critical": "不足の可能性",
        "review": "人間確認",
        "warning": "補強推奨",
        "good": "良好",
    }
    counts = {status: 0 for status in order}
    for finding in report.findings:
        counts[finding.status] += 1

    data = [
        {
            "status": japanese[status],
            "count": counts[status],
            "color": STATUS_COLOR[status],
        }
        for status in order
    ]

    return (
        alt.Chart(alt.Data(values=data))
        .mark_bar(cornerRadiusEnd=4, height=22)
        .encode(
            x=alt.X("count:Q", title="件数", axis=alt.Axis(tickMinStep=1)),
            y=alt.Y(
                "status:N",
                title=None,
                sort=[japanese[status] for status in order],
            ),
            color=alt.Color("color:N", scale=None, legend=None),
            tooltip=[
                alt.Tooltip("status:N", title="状態"),
                alt.Tooltip("count:Q", title="件数"),
            ],
        )
        .properties(height=140)
    )

jev_reviewer/init.py

公開するのは3つだけです。

jev_reviewer/__init__.py
"""Jev要件定義レビュアーパッケージ。"""

from jev_reviewer.models import ReviewReport
from jev_reviewer.reviewer import DemoReviewer, JevReviewer

__all__ = ["DemoReviewer", "JevReviewer", "ReviewReport"]

app.py

Streamlit の UI 層です。typesafe_sdk を import していないのがポイントで、どちらのレビュアーを組み立てるか決めたあとは、同じ ReviewReport を描画するだけです。

reviewer = (
    DemoReviewer(confidence_threshold)
    if demo_mode
    else JevReviewer(
        api_key=api_key or "",
        model=model,
        confidence_threshold=confidence_threshold,
    )
)
report = reviewer.review(document)   # ← どちらでも同じ ReviewReport が返る

結果は「チャート」「改善ポイント」「全評価」「JSON」の4タブです。全体のバランスを見てから個別項目へ降りる流れにしたかったので、先頭をチャートにしました。_render_charts() は左にレーダー、右に状態別の棒グラフと軸ごとの準備度テーブル(低い順)を並べます。

全体はこうなっています。

app.py
"""Jev要件定義レビューアプリケーションのStreamlitエントリーポイント。"""

from __future__ import annotations

import json
import os
from typing import Any

import streamlit as st

from jev_reviewer.charts import radar_axes, radar_chart, status_chart
from jev_reviewer.models import ReviewFinding, ReviewReport
from jev_reviewer.review_definitions import QUESTIONS
from jev_reviewer.reviewer import DemoReviewer, JevReviewer, display_value


STATUS_STYLE = {
    "good": ("", "良好"),
    "warning": ("⚠️", "補強推奨"),
    "critical": ("🔴", "不足の可能性"),
    "review": ("🔎", "人間確認"),
}


def main() -> None:
    """Streamlitアプリケーションを描画して実行する。"""

    st.set_page_config(
        page_title="Jev Requirements Reviewer",
        page_icon="🔍",
        layout="wide",
    )
    _apply_style()
    api_key = _get_api_key()

    st.title("要件定義書レビューアプリ")
    st.caption(
        "要件定義書を19の観点から並列評価し、実装前に補うべき項目を可視化します。"
    )

    with st.sidebar:
        st.header("設定")
        default_demo = not bool(api_key)
        demo_mode = st.toggle(
            "デモモード",
            value=default_demo,
            help="外部APIへ送信せず、ローカルの簡易判定で画面を確認します。",
        )
        model = st.text_input("Jevモデル", value="jev-latest")
        confidence_threshold = st.slider(
            "人間確認に回すconfidence",
            min_value=0.0,
            max_value=1.0,
            value=0.5,
            step=0.05,
        )
        st.divider()
        if demo_mode:
            st.info("デモモードではJev APIを呼び出しません。")
        elif api_key:
            st.success("Jev APIを利用できます。")
            st.warning("入力内容はTypeSafe APIへ送信されます。")
        else:
            st.error("TYPESAFE_API_KEYを設定してください。")

    editor_tab, questions_tab = st.tabs(["レビュー", "評価観点"])
    with editor_tab:
        _render_editor(
            api_key=api_key,
            demo_mode=demo_mode,
            model=model,
            confidence_threshold=confidence_threshold,
        )
    with questions_tab:
        _render_questions()


def _render_editor(
    api_key: str | None,
    demo_mode: bool,
    model: str,
    confidence_threshold: float,
) -> None:
    if "requirements_document" not in st.session_state:
        st.session_state.requirements_document = ""

    left, right = st.columns([5, 1])
    with left:
        st.subheader("レビュー対象")

    document = st.text_area(
        "要件定義書",
        key="requirements_document",
        height=380,
        placeholder="Markdownまたはプレーンテキストの要件定義書を貼り付けてください。",
        label_visibility="collapsed",
    )
    character_count = len(document)
    st.caption(f"{character_count:,}文字")

    if st.button(
        "レビューする",
        type="primary",
        width="stretch",
    ):
        if not document.strip():
            st.warning("要件定義書を入力してください。")
        elif not demo_mode and not api_key:
            st.warning("TYPESAFE_API_KEYを設定してください。")
        else:
            reviewer = (
                DemoReviewer(confidence_threshold)
                if demo_mode
                else JevReviewer(
                    api_key=api_key or "",
                    model=model,
                    confidence_threshold=confidence_threshold,
                )
            )
            try:
                with st.spinner("要件定義書を並列評価しています..."):
                    st.session_state.review_report = reviewer.review(document)
            except Exception as error:  # 外部APIのエラーをStreamlit上に表示する必要がある。
                st.error(f"レビューに失敗しました: {error}")

    report = st.session_state.get("review_report")
    if isinstance(report, ReviewReport):
        st.divider()
        _render_report(report, demo_mode)


def _render_report(report: ReviewReport, demo_mode: bool) -> None:
    maturity = report.get_finding("maturity")
    next_action = report.get_finding("next_action")
    largest_gap = report.get_finding("largest_gap")
    review_count = sum(
        finding.status == "review" for finding in report.findings
    )

    st.subheader("レビュー結果")
    if demo_mode:
        st.warning(
            "これはデモ用の簡易判定です。実際のJev評価ではありません。",
            icon="🧪",
        )

    columns = st.columns(4)
    columns[0].metric("総合準備度", f"{report.overall_score:.0%}")
    columns[1].metric(
        "仕様成熟度",
        display_value(maturity) if maturity else "",
    )
    columns[2].metric(
        "最大の不足領域",
        display_value(largest_gap) if largest_gap else "",
    )
    columns[3].metric("要確認判定", f"{review_count}")

    st.info(
        f"**総合判定:{report.overall_label}**  "
        f"次の推奨活動は「{display_value(next_action) if next_action else ''}」です。"
    )

    chart_tab, summary_tab, detail_tab, data_tab = st.tabs(
        ["チャート", "改善ポイント", "全評価", "JSON"]
    )
    with chart_tab:
        _render_charts(report)
    with summary_tab:
        _render_improvements(report)
    with detail_tab:
        _render_all_findings(report)
    with data_tab:
        payload = report.to_dict()
        st.json(payload)
        st.download_button(
            "結果JSONをダウンロード",
            data=json.dumps(payload, ensure_ascii=False, indent=2),
            file_name="jev_requirements_review.json",
            mime="application/json",
        )

    footer = f"モデル: {report.model}"
    if report.input_tokens is not None:
        footer += f" / 入力トークン: {report.input_tokens:,}"
    st.caption(footer)


def _render_charts(report: ReviewReport) -> None:
    chart = radar_chart(report)
    if chart is None:
        st.info("チャートを描画するには3軸以上の評価が必要です。")
        return

    left, right = st.columns([3, 2])
    with left:
        st.markdown("**準備度バランス**")
        st.altair_chart(chart, width="content")
        st.caption(
            "外側ほど準備が整っています。Noulは表示グループの平均、"
            "Scoreは項目単体を1軸としています。"
            "Choiceはカテゴリカルなため含みません。"
        )
    with right:
        st.markdown("**状態別の件数**")
        st.altair_chart(status_chart(report), width="stretch")
        st.markdown("**軸ごとの準備度**")
        st.dataframe(
            [
                {
                    "評価軸": axis["detail"],
                    "準備度": f"{float(axis['value']):.0%}",
                }
                for axis in sorted(
                    radar_axes(report),
                    key=lambda axis: float(axis["value"]),
                )
            ],
            width="stretch",
            hide_index=True,
        )


def _render_improvements(report: ReviewReport) -> None:
    candidates = [
        finding
        for finding in report.findings
        if finding.kind != "choice" and finding.status != "good"
    ]
    candidates.sort(key=_finding_sort_key)

    if not candidates:
        st.success("大きな不足項目は検出されませんでした。")
        return

    for finding in candidates:
        icon, status_label = STATUS_STYLE[finding.status]
        confidence_text = (
            f" / confidence {finding.confidence:.0%}"
            if finding.confidence is not None
            else ""
        )
        with st.expander(
            f"{icon} {finding.label}{display_value(finding)}"
            f"{status_label}{confidence_text}",
            expanded=finding.status == "critical",
        ):
            st.write(finding.guidance)


def _render_all_findings(report: ReviewReport) -> None:
    rows: list[dict[str, Any]] = []
    for finding in report.findings:
        icon, status_label = STATUS_STYLE[finding.status]
        rows.append(
            {
                "グループ": finding.group,
                "評価項目": finding.label,
                "種類": finding.kind.upper(),
                "結果": display_value(finding),
                "confidence": (
                    round(finding.confidence, 3)
                    if finding.confidence is not None
                    else None
                ),
                "状態": f"{icon} {status_label}",
            }
        )
    st.dataframe(rows, width="stretch", hide_index=True)


def _render_questions() -> None:
    st.subheader("Jevへ送信する19の評価観点")
    st.write(
        "各質問は要件定義書全体を共通のstateとして参照し、独立して評価されます。"
    )
    rows = [
        {
            "グループ": question.group,
            "項目": question.label,
            "種類": question.kind.upper(),
            "質問": question.instructions,
        }
        for question in QUESTIONS
    ]
    st.dataframe(rows, width="stretch", hide_index=True)


def _finding_sort_key(finding: ReviewFinding) -> tuple[int, float]:
    order = {"critical": 0, "review": 1, "warning": 2, "good": 3}
    numeric = finding.numeric_value()
    return order[finding.status], numeric if numeric is not None else 1.0


def _get_api_key() -> str | None:
    try:
        secret = st.secrets.get("TYPESAFE_API_KEY")
    except Exception:
        secret = None
    value = secret or os.getenv("TYPESAFE_API_KEY")
    return str(value).strip() if value else None


def _apply_style() -> None:
    st.markdown(
        """
        <style>
        .block-container {max-width: 1200px; padding-top: 2rem;}
        div[data-testid="stMetric"] {
            background: white;
            border: 1px solid #dbeafe;
            border-radius: 12px;
            padding: 14px;
        }
        div[data-testid="stTextArea"] textarea {
            font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
            line-height: 1.55;
        }
        </style>
        """,
        unsafe_allow_html=True,
    )


if __name__ == "__main__":
    main()

.streamlit/ の設定ファイル

テーマとサーバー設定です。

.streamlit/config.toml
# .streamlit/config.toml
[theme]
primaryColor = "#2563EB"
backgroundColor = "#F8FAFC"
secondaryBackgroundColor = "#EFF6FF"
textColor = "#172033"
font = "sans serif"

[server]
headless = true

APIキーのテンプレートはこれだけです。gitを使う場合は secrets.toml.gitignore に入れましょう。

.streamlit/secrets.toml
TYPESAFE_API_KEY = "your-api-key"

tests/test_reviewer.py

外部API非依存なので一瞬で終わります。

uv run python -m unittest discover -s tests -v
Ran 13 tests in 0.112s

OK

レビュー処理が8件。question_id の一意性 / 19問であること / confidence低→review / 低スコア→critical / 空文書の拒否 / 充実した文書が疎な文書を上回ること / 全質問が結果に含まれること / Choice が総合スコアに混入しないこと。

チャート側が5件。Choice が軸に混入しないこと / 軸数が「Noulのグループ数 + Score数」に一致すること / 全軸の値が 0〜1 に収まること / 描画可能な定義を返すこと / 軸が3本未満なら None になること。

tests/test_reviewer.py
"""アプリケーション側のレビュー処理に対する単体テスト。"""

from __future__ import annotations

import unittest

from jev_reviewer.charts import radar_axes, radar_chart
from jev_reviewer.models import ReviewFinding
from jev_reviewer.review_definitions import QUESTIONS
from jev_reviewer.reviewer import (
    DemoReviewer,
    build_report,
    finding_status,
)


class ReviewDefinitionsTest(unittest.TestCase):
    """安定したレビュー質問セットを検証する。"""

    def test_question_ids_are_unique(self) -> None:
        question_ids = [question.question_id for question in QUESTIONS]
        self.assertEqual(len(question_ids), len(set(question_ids)))

    def test_question_count_is_nineteen(self) -> None:
        self.assertEqual(len(QUESTIONS), 19)


class FindingStatusTest(unittest.TestCase):
    """表示ポリシーの閾値を検証する。"""

    def test_low_confidence_requires_review(self) -> None:
        status = finding_status("score", 0.9, 0.3, 0.5)
        self.assertEqual(status, "review")

    def test_low_score_is_critical(self) -> None:
        status = finding_status("noul", 0.2, 0.8, 0.5)
        self.assertEqual(status, "critical")


class DemoReviewerTest(unittest.TestCase):
    """決定論的なデモ出力を検証する。"""

    def test_blank_document_is_rejected(self) -> None:
        with self.assertRaises(ValueError):
            DemoReviewer().review("   ")

    def test_complete_document_scores_above_sparse_document(self) -> None:
        sparse = DemoReviewer().review("新しいシステムを作る。")
        complete = DemoReviewer().review(
            "課題、対象ユーザー、価値、対象範囲、対象外、入力、出力、"
            "受け入れ条件、異常、制約、性能、認証、権限、運用、監視、"
            "復旧、責任者を定義する。"
        )
        self.assertGreater(complete.overall_score, sparse.overall_score)

    def test_report_contains_every_question(self) -> None:
        report = DemoReviewer().review("課題と対象ユーザーを定義する。")
        self.assertEqual(len(report.findings), len(QUESTIONS))


class ReportTest(unittest.TestCase):
    """レポートの集計結果を検証する。"""

    def test_choice_is_not_included_in_overall_score(self) -> None:
        numeric = ReviewFinding(
            question_id="numeric",
            label="Numeric",
            group="Test",
            kind="noul",
            value=0.8,
            confidence=0.6,
            status="good",
            guidance="",
        )
        choice = ReviewFinding(
            question_id="choice",
            label="Choice",
            group="Test",
            kind="choice",
            value="idea",
            confidence=0.8,
            status="good",
            guidance="",
        )
        report = build_report("test", (numeric, choice))
        self.assertAlmostEqual(report.overall_score, 0.8)


class RadarChartTest(unittest.TestCase):
    """レーダーチャートの軸生成を検証する。"""

    def test_axes_exclude_choice_findings(self) -> None:
        report = DemoReviewer().review("課題と対象ユーザーを定義する。")
        labels = {axis["label"] for axis in radar_axes(report)}
        choice_labels = {
            question.label
            for question in QUESTIONS
            if question.kind == "choice"
        }
        self.assertFalse(labels & choice_labels)

    def test_axes_combine_noul_groups_and_scores(self) -> None:
        report = DemoReviewer().review("課題と対象ユーザーを定義する。")
        noul_groups = {
            question.group
            for question in QUESTIONS
            if question.kind == "noul"
        }
        score_count = sum(
            question.kind == "score" for question in QUESTIONS
        )
        self.assertEqual(
            len(radar_axes(report)), len(noul_groups) + score_count
        )

    def test_axis_values_are_within_unit_range(self) -> None:
        report = DemoReviewer().review("課題と対象ユーザーを定義する。")
        for axis in radar_axes(report):
            value = float(axis["value"])
            self.assertGreaterEqual(value, 0.0)
            self.assertLessEqual(value, 1.0)

    def test_chart_is_renderable(self) -> None:
        report = DemoReviewer().review("課題と対象ユーザーを定義する。")
        self.assertIn("layer", radar_chart(report).to_dict())

    def test_chart_requires_three_axes(self) -> None:
        single = ReviewFinding(
            question_id="only",
            label="Only",
            group="Test",
            kind="noul",
            value=0.5,
            confidence=1.0,
            status="warning",
            guidance="",
        )
        report = build_report("test", (single,))
        self.assertIsNone(radar_chart(report))


if __name__ == "__main__":
    unittest.main()

セットアップ手順

# uv のインストール(未導入の場合)
# Windows (PowerShell)
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
# macOS / Linux
curl -LsSf https://astral.sh/uv/install.sh | sh

# 依存関係の追加(.venv の作成と Python 3.13 の取得も自動)
uv add "streamlit>=1.49.0,<2.0.0" "typesafe-sdk>=0.5.7,<1.0.0" "altair>=5.0.0,<7.0.0"

# 起動
uv run streamlit run app.py

uv addpyproject.toml への追記、uv.lock の更新、.venv の作成をまとめてやってくれるので、これだけで環境が立ち上がります。Python 3.13 が入っていなければ uv が取ってきます。以降のコマンドは uv run を前に付けるだけで、仮想環境を手で有効化する必要はありません。

APIキーが見つからない場合は自動的にデモモードで起動します。ここだけ押さえれば動きます。


使ってみた所感

使ってみた感覚としては、まず19観点でもレイテンシが増えないこと。並列評価が効いていて、体感はほぼ一定です。チャットLLMに19回聞くのとは別物の体験でした。出力も完全に安定していて、パースエラー・リトライ・「JSONで返して」の懇願プロンプトが全部不要になりました。返り値は float か定義済みの文字列だけです。confidence も実用的で、「AIが迷った項目」が可視化されるので、人間のレビュー工数を本当に必要な箇所へ集中できます。しかも安い。入力課金のみで、1回のレビューが0.01円以下です。


まとめ

Jev を触ってみた発見は、LLMを使った設計と違いシンプルなコードになったことです。判断はAI、知識と文面は人間、統合はアプリケーションロジック。この分担にすると、AIの出力が構造化されている恩恵がそのままコードの単純さになって返ってきます。パーサもリトライもバリデーションも消えました。


参考リンク

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?