app:
  description: 帳票（請求書・納品書など）のテキストから項目抽出・妥当性チェックを行い、OK/要確認/NGを判定するワークフロー
  icon: 🤖
  icon_background: '#FFEAD5'
  mode: workflow
  name: 帳票OCR＋妥当性チェックワークフロー
  use_icon_as_answer_icon: false
kind: app
version: 0.1.5
workflow:
  conversation_variables: []
  environment_variables: []
  features:
    file_upload:
      image:
        enabled: false
    opening_statement: ''
    suggested_questions: []
    suggested_questions_after_answer:
      enabled: false
    text_to_speech:
      enabled: false
  graph:
    edges:
    - data:
        sourceType: start
        targetType: parameter-extractor
      id: start-to-extractor1
      source: start
      target: extractor1
    - data:
        sourceType: parameter-extractor
        targetType: code
      id: extractor1-to-code1
      source: extractor1
      target: code1
    - data:
        sourceType: code
        targetType: llm
      id: code1-to-llm1
      source: code1
      target: llm1
    - data:
        sourceType: llm
        targetType: end
      id: llm1-to-end
      source: llm1
      target: end
    nodes:
    - data:
        title: 開始
        type: start
        variables:
        - label: query
          max_length: 2048
          options: []
          required: true
          type: paragraph
          variable: query
      height: 90
      id: start
      position:
        x: 80
        y: 282
      type: custom
      width: 244
    - data:
        title: 項目抽出（パラメータ抽出）
        type: parameter-extractor
        model:
          provider: openai
          name: gpt-5-mini
          mode: chat
        query:
        - start
        - query
        parameters:
        - name: items
          type: string
          description: '明細行の一覧。JSON文字列で、各行は item_name, quantity, unit_price, amount
            を持つオブジェクト配列として返す。例: [{"item_name":"A","quantity":10,"unit_price":100,"amount":1000}]'
          required: true
        - name: total_amount
          type: number
          description: 請求書または納品書の合計金額（数値のみ。カンマや通貨記号は除去）
          required: false
        - name: date
          type: string
          description: 取引日または発行日（YYYY-MM-DD形式が望ましい。分からなければ空文字）
          required: false
        - name: partner
          type: string
          description: 取引先名（会社名または個人名）
          required: false
        - name: currency
          type: string
          description: '通貨（例: JPY, USD など。分からなければ空文字）'
          required: false
        reasoning_mode: prompt
        instruction: 'あなたは経理担当者として、与えられた請求書・納品書テキストから構造化情報を抽出します。


          必ず以下の制約を守ってください:

          - items は JSON 配列文字列として返してください。各要素は item_name(品目名:文字列), quantity(数量:数値),
          unit_price(単価:数値), amount(金額:数値) を持つオブジェクトとします。

          - 数値はカンマや通貨記号を除去し、単位も考慮して純粋な数値として返してください。

          - 合計金額が本文に明示されている場合は total_amount に数値で入れてください。なければ空欄または 0 として構いません。

          - 日付はもっとも代表的な日付（支払日よりは請求日または発行日）を date に文字列で入れてください。

          - partner には請求先または取引先の名称を入れてください。


          入力テキスト:

          {{#start.query#}}'
      height: 90
      id: extractor1
      position:
        x: 380
        y: 282
      type: custom
      width: 244
    - data:
        title: 妥当性チェック計算
        type: code
        code_language: python3
        code: "def _safe_str(v):\n    \"\"\"Convert any value to string safely.\"\"\
          \"\n    if v is None:\n        return \"\"\n    if isinstance(v, list):\n\
          \        return \"\\n\".join(str(i) for i in v)\n    return str(v)\n\ndef\
          \ _safe_list(v):\n    \"\"\"Ensure value is a list.\"\"\"\n    if v is None:\n\
          \        return []\n    if isinstance(v, str):\n        return [v]\n   \
          \ if isinstance(v, list):\n        return v\n    return [v]\n\ndef main(**kwargs)\
          \ -> dict:\n    inputs = kwargs\n    import json\n\n    text = \"\"\n  \
          \  if \"text\" in inputs:\n        text = str(inputs[\"text\"])\n\n    items_str\
          \ = inputs.get(\"items\") or \"[]\"\n    total_amount_input = inputs.get(\"\
          total_amount\")\n    date = inputs.get(\"date\") or \"\"\n    partner =\
          \ inputs.get(\"partner\") or \"\"\n    currency = inputs.get(\"currency\"\
          ) or \"\"\n\n    parsed_items = []\n    items_parse_error = \"\"\n\n   \
          \ try:\n        stripped = items_str.strip()\n        if stripped == \"\"\
          :\n            parsed_items = []\n        else:\n            parsed_items\
          \ = json.loads(stripped)\n            if not isinstance(parsed_items, list):\n\
          \                items_parse_error = \"items が配列ではありません。\"\n           \
          \     parsed_items = []\n    except Exception as e:\n        items_parse_error\
          \ = \"items のJSONパースに失敗しました: \" + str(e)\n        parsed_items = []\n\n\
          \    normalized_items = []\n    line_check_results = []\n    sum_computed\
          \ = 0.0\n    abnormal_lines = []\n\n    for idx, it in enumerate(parsed_items):\n\
          \        index = idx + 1\n        item_name = \"\"\n        quantity = None\n\
          \        unit_price = None\n        amount = None\n\n        if isinstance(it,\
          \ dict):\n            item_name = str(it.get(\"item_name\") or it.get(\"\
          name\") or \"\")\n            try:\n                q = it.get(\"quantity\"\
          )\n                if q is not None and q != \"\":\n                   \
          \ quantity = float(q)\n            except Exception:\n                quantity\
          \ = None\n            try:\n                u = it.get(\"unit_price\")\n\
          \                if u is not None and u != \"\":\n                    unit_price\
          \ = float(u)\n            except Exception:\n                unit_price\
          \ = None\n            try:\n                a = it.get(\"amount\")\n   \
          \             if a is not None and a != \"\":\n                    amount\
          \ = float(a)\n            except Exception:\n                amount = None\n\
          \        else:\n            item_name = str(it)\n\n        line = dict(\n\
          \            index=index,\n            item_name=item_name,\n          \
          \  quantity=quantity,\n            unit_price=unit_price,\n            amount=amount,\n\
          \        )\n        normalized_items.append(line)\n\n        calc_ok = None\n\
          \        calc_diff = None\n        if quantity is not None and unit_price\
          \ is not None and amount is not None:\n            expected = quantity *\
          \ unit_price\n            calc_diff = amount - expected\n            tolerance\
          \ = max(abs(expected) * 0.01, 1.0)\n            calc_ok = abs(calc_diff)\
          \ <= tolerance\n            sum_computed += amount\n        elif amount\
          \ is not None:\n            sum_computed += amount\n\n        line_result\
          \ = dict(\n            index=index,\n            item_name=item_name,\n\
          \            quantity=quantity,\n            unit_price=unit_price,\n  \
          \          amount=amount,\n            calc_ok=calc_ok,\n            calc_diff=calc_diff,\n\
          \        )\n        line_check_results.append(line_result)\n\n    total_amount\
          \ = None\n    if total_amount_input is not None and str(total_amount_input)\
          \ != \"\":\n        try:\n            total_amount = float(total_amount_input)\n\
          \        except Exception:\n            total_amount = None\n\n    total_check_ok\
          \ = None\n    total_diff = None\n    if total_amount is not None:\n    \
          \    total_diff = total_amount - sum_computed\n        tolerance_total =\
          \ max(abs(sum_computed) * 0.01, 1.0)\n        total_check_ok = abs(total_diff)\
          \ <= tolerance_total\n\n    mandatory_missing = []\n    if len(normalized_items)\
          \ == 0:\n        mandatory_missing.append(\"明細行（items）が1件も抽出されていません。\")\n\
          \    if partner.strip() == \"\":\n        mandatory_missing.append(\"取引先（partner）が抽出されていません。\"\
          )\n    if date.strip() == \"\":\n        mandatory_missing.append(\"日付（date）が抽出されていません。\"\
          )\n    if total_amount is None:\n        mandatory_missing.append(\"合計金額（total_amount）が抽出されていないか数値化できません。\"\
          )\n\n    abnormality_messages = []\n\n    if total_amount is not None and\
          \ len(normalized_items) > 0:\n        normal_estimate = 0.0\n        for\
          \ it in normalized_items:\n            a = it.get(\"amount\")\n        \
          \    if isinstance(a, (int, float)) and a > 0:\n                normal_estimate\
          \ += a\n        if normal_estimate > 0:\n            ratio = total_amount\
          \ / normal_estimate\n            if ratio >= 3.0:\n                msg =\
          \ \"請求書全体の合計金額が明細金額合計のおおよそ3倍以上に見えます。想定外の高額請求の可能性があります。\"\n             \
          \   abnormality_messages.append(msg)\n\n    for line in line_check_results:\n\
          \        amount = line.get(\"amount\")\n        unit_price = line.get(\"\
          unit_price\")\n        quantity = line.get(\"quantity\")\n        index\
          \ = line.get(\"index\")\n        item_name = line.get(\"item_name\") or\
          \ \"\"\n\n        if amount is None or unit_price is None or quantity is\
          \ None:\n            continue\n\n        if unit_price > 0 and quantity\
          \ > 0:\n            avg = unit_price * quantity\n            if amount >=\
          \ avg * 3.0:\n                msg = \"明細行 \" + str(index) + \"（\" + item_name\
          \ + \"）の金額が通常想定の3倍以上に見えます。\"\n                abnormality_messages.append(msg)\n\
          \                abnormal_lines.append(index)\n\n    calc_ng_lines = []\n\
          \    for line in line_check_results:\n        if line.get(\"calc_ok\") is\
          \ False:\n            calc_ng_lines.append(line.get(\"index\"))\n\n    status\
          \ = \"OK\"\n    summary_reasons = []\n\n    if len(mandatory_missing) >\
          \ 0:\n        status = \"NG\"\n        summary_reasons.append(\"必須項目の抽出に問題があります。\"\
          )\n\n    if (total_check_ok is False) or (len(calc_ng_lines) > 0):\n   \
          \     if status == \"OK\":\n            status = \"要確認\"\n        summary_reasons.append(\"\
          数量×単価=金額 または 合計金額の整合性に問題があります。\")\n\n    if len(abnormality_messages) >\
          \ 0:\n        if status == \"OK\":\n            status = \"要確認\"\n     \
          \   summary_reasons.append(\"異常に高額と思われる金額が検知されています。\")\n\n    if items_parse_error\
          \ != \"\" and status == \"OK\":\n        status = \"要確認\"\n        summary_reasons.append(\"\
          明細(items)の構造が不正な可能性があります。\")\n\n    if status == \"OK\" and len(summary_reasons)\
          \ == 0:\n        summary_reasons.append(\"特に大きな問題は検知されていません。\")\n\n    overall_reason\
          \ = \" / \".join(summary_reasons)\n\n    result_object = dict(\n       \
          \ status=status,\n        overall_reason=overall_reason,\n        items=normalized_items,\n\
          \        line_check_results=line_check_results,\n        total_amount=total_amount,\n\
          \        total_check_ok=total_check_ok,\n        total_diff=total_diff,\n\
          \        mandatory_missing=mandatory_missing,\n        abnormality_messages=abnormality_messages,\n\
          \        items_parse_error=items_parse_error,\n        partner=partner,\n\
          \        date=date,\n        currency=currency,\n        original_text=text,\n\
          \    )\n\n    try:\n        result_json = json.dumps(result_object, ensure_ascii=False)\n\
          \    except Exception:\n        result_json = \"{}\"\n\n    return dict(\n\
          \        status=status,\n        overall_reason=overall_reason,\n      \
          \  json=result_json,\n    )\n"
        variables:
        - value_selector:
          - start
          - query
          variable: text
        - value_selector:
          - extractor1
          - items
          variable: items
        - value_selector:
          - extractor1
          - total_amount
          variable: total_amount
        - value_selector:
          - extractor1
          - date
          variable: date
        - value_selector:
          - extractor1
          - partner
          variable: partner
        - value_selector:
          - extractor1
          - currency
          variable: currency
        outputs:
          status:
            type: string
            children: null
          overall_reason:
            type: string
            children: null
          json:
            type: string
            children: null
      height: 90
      id: code1
      position:
        x: 680
        y: 282
      type: custom
      width: 244
    - data:
        title: 最終レポート整形（LLM要約）
        type: llm
        model:
          provider: openai
          name: gpt-5-mini
          mode: chat
          completion_params:
            temperature: 0.2
        prompt_template:
        - role: system
          text: 'あなたは経理・監査担当者向けに帳票チェック結果をわかりやすく日本語で整理するアシスタントです。事実ベースで簡潔かつ丁寧にまとめてください。


            ---


            【最終出力フォーマット (この指示が最優先)】

            最終出力は **読みやすい日本語の Markdown** で返してください。JSON

            配列・オブジェクトをそのまま出力してはいけません。

            - 見出し (## / ###) でセクションを区切る

            - リスト (- / 1.) や表 (| ... |) で構造化

            - 各項目の意図や根拠が分かるよう短い説明文を添える

            - 営業デモで顧客に直接見せられる体裁にする


            (構造化されたデータ項目を内部で扱うのは構いませんが、出力時に Markdown

            に変換してください。)'
        - role: user
          text: "以下は請求書・納品書テキストの妥当性チェック結果です。\n\n【判定ステータス】\n{{#code1.status#}}\n\n\
            【総合コメント】\n{{#code1.overall_reason#}}\n\n【詳細JSON】\n{{#code1.json#}}\n\n\
            上記をもとに、以下のフォーマットで日本語のレポートを作成してください。\n\n---- 出力フォーマット ----\n1. 判定結果: （OK\
            \ / 要確認 / NG のいずれか）\n2. 判定理由（要約）: 箇条書きで3〜6行程度\n3. 主なチェック内容:\n   - 数量×単価=金額\
            \ のチェック結果（問題の有無と、問題があれば明細番号を記載）\n   - 合計金額の整合性（問題の有無）\n   - 必須項目（取引先・日付・明細行・合計金額など）の有無\n\
            \   - 異常値検知（通常の3倍以上などがあれば、その概要）\n4. 補足: システム判定の限界や、人手で確認した方がよいポイントがあれば記載\n\
            ---------------------------\n\nJSONの構造に沿って事実ベースで記述し、過度な推測は避けてください。"
        vision:
          enabled: false
        memory: null
        context:
          enabled: false
      height: 90
      id: llm1
      position:
        x: 980
        y: 282
      type: custom
      width: 244
    - data:
        title: 終了
        type: end
        outputs:
        - value_selector:
          - llm1
          - text
          variable: result
      height: 90
      id: end
      position:
        x: 1280
        y: 282
      type: custom
      width: 244
  hash: null
