app:
  description: 見積書/注文書 → 構造化JSON 抽出 (gpt-4o ビジョン, Start→LLM直結)
  icon: 🧾
  icon_background: '#FFEAD5'
  mode: workflow
  name: Voucher Extractor (VLM Single, OpenAI v2)
  use_icon_as_answer_icon: false
kind: app
version: 0.1.5
workflow:
  conversation_variables: []
  environment_variables: []
  features:
    file_upload:
      allowed_file_extensions:
      - .pdf
      - .png
      - .jpg
      - .jpeg
      - .tiff
      allowed_file_types:
      - image
      - document
      allowed_file_upload_methods:
      - local_file
      - remote_url
      enabled: true
      image:
        enabled: true
        number_limits: 5
        transfer_methods:
        - local_file
        - remote_url
      number_limits: 5
    opening_statement: ''
    retriever_resource:
      enabled: false
    sensitive_word_avoidance:
      enabled: false
    speech_to_text:
      enabled: false
    suggested_questions: []
    suggested_questions_after_answer:
      enabled: false
    text_to_speech:
      enabled: false
  graph:
    edges:
    - id: start__to__extract_llm
      source: start_node
      target: extract_llm_node
      sourceHandle: source
      targetHandle: target
      type: custom
    - id: extract_llm__to__validate
      source: extract_llm_node
      target: validate_node
      sourceHandle: source
      targetHandle: target
      type: custom
    - id: validate__to__branch
      source: validate_node
      target: hitl_branch_node
      sourceHandle: source
      targetHandle: target
      type: custom
    - id: branch_true__to__end_auto
      source: hitl_branch_node
      target: end_auto_node
      sourceHandle: 'true'
      targetHandle: target
      type: custom
    - id: branch_false__to__end_hitl
      source: hitl_branch_node
      target: end_hitl_node
      sourceHandle: 'false'
      targetHandle: target
      type: custom
    nodes:
    - id: start_node
      type: custom
      position:
        x: 80
        y: 240
      data:
        type: start
        title: Start
        desc: 見積書/注文書を受け取る
        variables:
        - variable: input_file
          label: 帳票ファイル (PDF/画像)
          type: file
          required: true
          allowed_file_types:
          - image
          - document
          allowed_file_extensions:
          - .pdf
          - .png
          - .jpg
          - .jpeg
          - .tiff
          max_length: 1
        - variable: doc_kind_hint
          label: 種別ヒント (任意)
          type: text-input
          required: false
          default: ''
          description: 見積書/注文書 のいずれかを示唆する自由記述
    - id: extract_llm_node
      type: custom
      position:
        x: 700
        y: 240
      data:
        type: llm
        title: VLM抽出 (Claude Sonnet 4.6)
        desc: 見積書/注文書から JSON Schema に従って抽出
        model:
          provider: openai
          name: gpt-4o
          mode: chat
          completion_params:
            temperature: 0.1
            max_tokens: 4096
            top_p: 0.95
        prompt_template:
        - role: system
          text: "あなたは日本の中小企業向け会計業務に精通した、帳票読取の専門家です。\nこれから提示する画像は、見積書 (quotation) または 注文書 (purchase_order) のいずれかです。\n帳票の内容を正確に読み取り、指定された JSON Schema に厳密に従ったJSONオブジェクトを出力してください。\n\n## 厳守ルール\n1. 出力は JSON オブジェクトのみ。前後に説明文・コードフェンスを含めないこと。\n2. 読み取れない項目は推測せず null を入れる。捏造禁止。\n3. 金額・数量は数値型(number)。「¥1,200」「1,200円」等は 1200 に正規化。\n4. 日付は YYYY-MM-DD 形式に正規化 (令和7年4月15日 → 2025-04-15)。\n5. 税率: 10% → 0.1, 軽減8% → 0.08, 非課税 → 0, 不明 → null\n6. 適格請求書番号は T+13桁の数字。形式不一致は null。\n7. 品名・仕様は省略せず全文転記。\n8. document_type: 「見積書/Estimate/Quotation」→ quotation、「注文書/発注書/Purchase Order」→ purchase_order\n\n## 信頼度報告\nextraction_meta.overall_confidence は 0.0〜1.0:\n- クリアに読める → 0.95+\n- 一部不鮮明 → 0.7〜0.9\n- 半分推測 → 0.5以下\nextraction_meta.low_confidence_fields に項目名のドット記法パスを列挙。\nextraction_meta.warnings に特記事項を文字列で列挙。\n\n## JSON Schema (概要)\n{\n  \"document_type\": \"quotation | purchase_order\",\n  \"document_id\": \"string | null\",\n  \"issue_date\": \"YYYY-MM-DD\",\n  \"expiration_date\": \"YYYY-MM-DD | null\",\n  \"delivery_date\"\
            : \"YYYY-MM-DD | null\",\n  \"supplier\": {\"name\": \"string\", \"address\": \"string|null\", \"tax_id\": \"T...|null\", \"tel\": \"string|null\", \"person_in_charge\": \"string|null\"},\n  \"buyer\": {\"name\": \"string\", \"department\": \"string|null\", \"person_in_charge\": \"string|null\"},\n  \"items\": [{\"line_no\": \"int|null\", \"item_code\": \"string|null\", \"item_name\": \"string\", \"spec\": \"string|null\", \"quantity\": \"number\", \"unit\": \"string|null\", \"unit_price\": \"number\", \"amount\": \"number\", \"tax_rate\": \"0|0.08|0.1|null\", \"remarks\": \"string|null\"}],\n  \"subtotal\": \"number\",\n  \"tax\": \"number\",\n  \"total\": \"number\",\n  \"currency\": \"JPY\",\n  \"payment_terms\": \"string | null\",\n  \"notes\": \"string | null\",\n  \"extraction_meta\": {\"overall_confidence\": \"number\", \"low_confidence_fields\": [\"string\"], \"warnings\": [\"string\"]}\n}\n"
        - role: user
          text: '以下の画像 (帳票) からJSONを抽出してください。


            ヒント (任意): {{#start_node.doc_kind_hint#}}


            Few-shot 例 1 (シンプル注文書):

            {"document_type":"purchase_order","document_id":"PO-2026-0042","issue_date":"2026-04-15","delivery_date":"2026-04-30","supplier":{"name":"株式会社サンプル商事","address":"東京都千代田区サンプル1-2-3","tax_id":"T1234567890123","tel":"03-XXXX-XXXX","person_in_charge":null},"buyer":{"name":"テスト工業株式会社","department":"資材部","person_in_charge":"山田太郎"},"items":[{"line_no":1,"item_code":"A-100","item_name":"コピー用紙A4","spec":"500枚/箱","quantity":10,"unit":"箱","unit_price":500,"amount":5000,"tax_rate":0.1,"remarks":null}],"subtotal":5000,"tax":500,"total":5500,"currency":"JPY","payment_terms":"末締め翌月末払い","notes":null,"extraction_meta":{"overall_confidence":0.98,"low_confidence_fields":[],"warnings":[]}}


            Few-shot 例 2 (軽減税率混在):

            {"document_type":"quotation","document_id":"Q-25-0103","issue_date":"2026-05-10","expiration_date":"2026-06-09","supplier":{"name":"合同会社テスト食品","tax_id":"T9876543210987"},"buyer":{"name":"サンプル商店"},"items":[{"item_name":"業務用米 10kg","spec":"新潟県産コシヒカリ","quantity":5,"unit":"袋","unit_price":4500,"amount":22500,"tax_rate":0.08},{"item_name":"紙袋 (米用)","quantity":50,"unit":"枚","unit_price":40,"amount":2000,"tax_rate":0.1}],"subtotal":24500,"tax":2000,"total":26500,"currency":"JPY","notes":"有効期限内のご注文をお願いいたします。","extraction_meta":{"overall_confidence":0.93,"low_confidence_fields":["items[0].item_code"],"warnings":[]}}


            JSON のみを出力してください。

            '
          files:
          - type: image
            transfer_method: local_file
            upload_file_id: '{{#start_node.input_file.id#}}'
        vision:
          enabled: true
          configs:
            variable_selector:
            - start_node
            - input_file
            detail: high
        context:
          enabled: false
        structured_output_enabled: true
        structured_output:
          type: object
          required:
          - document_type
          - issue_date
          - supplier
          - buyer
          - items
          - subtotal
          - tax
          - total
    - id: validate_node
      type: custom
      position:
        x: 1040
        y: 240
      data:
        type: code
        title: バリデーション
        desc: 税計算照合・整合性チェック・信頼度算出
        code_language: python3
        code: "# 実装は dify/code/validate.py を参照。\n# Difyの Code ノードは単一関数 main(args) -> dict の形式。\n# 以下はその要約版。最新版は外部ファイルから貼り直すこと。\n\nimport re\n\nCONFIDENCE_AUTO_APPROVE = 0.95\nCONFIDENCE_HITL_THRESHOLD = 0.70\nTAX_ID_PATTERN = re.compile(r\"^T\\d{13}$\")\nDATE_PATTERN = re.compile(r\"^\\d{4}-\\d{2}-\\d{2}$\")\n\ndef _to_number(v):\n    if v is None: return None\n    if isinstance(v, (int, float)): return float(v)\n    if isinstance(v, str):\n        c = re.sub(r\"[,\\s¥円]\", \"\", v)\n        try: return float(c)\n        except: return None\n    return None\n\ndef main(extracted_json):\n    voucher = dict(extracted_json or {})\n    errors, warnings, low_conf = [], [], []\n    meta = voucher.get(\"extraction_meta\") or {}\n    warnings.extend(meta.get(\"warnings\") or [])\n    low_conf.extend(meta.get(\"low_confidence_fields\") or [])\n\n    for r in (\"document_type\",\"issue_date\",\"items\",\"subtotal\",\"tax\",\"total\"):\n        if voucher.get(r) in (None, \"\", []):\n            errors.append(f\"\
          missing:{r}\")\n\n    for d in (\"issue_date\",\"expiration_date\",\"delivery_date\"):\n        v = voucher.get(d)\n        if v and not DATE_PATTERN.match(str(v)):\n            errors.append(f\"bad_date:{d}={v}\")\n\n    sup = voucher.get(\"supplier\") or {}\n    if sup.get(\"tax_id\") and not TAX_ID_PATTERN.match(str(sup[\"tax_id\"])):\n        warnings.append(f\"bad_tax_id:{sup['tax_id']}\")\n        sup[\"tax_id\"] = None\n\n    items = list(voucher.get(\"items\") or [])\n    item_subtotal = 0.0\n    norm_items = []\n    for i, it in enumerate(items):\n        q = _to_number(it.get(\"quantity\"))\n        p = _to_number(it.get(\"unit_price\"))\n        a = _to_number(it.get(\"amount\"))\n        tr = it.get(\"tax_rate\")\n        if tr is not None:\n            tr = _to_number(tr)\n            if tr is not None and tr > 1: tr = tr / 100.0\n        if q is not None and p is not None:\n            calc = round(q * p)\n            if a is None:\n                a = calc\n    \
          \            warnings.append(f\"items[{i}].amount_inferred\")\n            elif abs(a - calc) > 2:\n                errors.append(f\"items[{i}].amount_mismatch:{a}vs{calc}\")\n                low_conf.append(f\"items[{i}].amount\")\n        if tr is None and a is not None:\n            tr = 0.1\n            warnings.append(f\"items[{i}].tax_rate_defaulted\")\n        if a is not None: item_subtotal += a\n        it2 = dict(it); it2.update({\"quantity\": q, \"unit_price\": p, \"amount\": a, \"tax_rate\": tr})\n        norm_items.append(it2)\n    voucher[\"items\"] = norm_items\n\n    ss = _to_number(voucher.get(\"subtotal\"))\n    st = _to_number(voucher.get(\"tax\"))\n    tt = _to_number(voucher.get(\"total\"))\n    if ss is not None and abs(ss - item_subtotal) > 2:\n        warnings.append(f\"subtotal_mismatch:{ss}vs{item_subtotal}\")\n        low_conf.append(\"subtotal\")\n    if ss is not None and st is not None and tt is not None and abs(ss + st - tt) > 2:\n        errors.append(f\"\
          total_mismatch:{ss+st}vs{tt}\")\n        low_conf.append(\"total\")\n\n    base = float(meta.get(\"overall_confidence\") or 0.5)\n    penalty = min(0.5, len(errors)*0.10 + len(set(low_conf))*0.05)\n    conf = max(0.0, min(1.0, base - penalty))\n    needs_hitl = conf < CONFIDENCE_AUTO_APPROVE or len(errors) > 0\n    rejected = conf < CONFIDENCE_HITL_THRESHOLD\n\n    voucher[\"extraction_meta\"] = {\n        \"overall_confidence\": round(conf, 3),\n        \"low_confidence_fields\": sorted(set(low_conf)),\n        \"warnings\": sorted(set(warnings)),\n    }\n    return {\n        \"voucher_json\": voucher,\n        \"validation_errors\": errors,\n        \"confidence\": round(conf, 3),\n        \"needs_hitl\": needs_hitl,\n        \"rejected\": rejected,\n    }\n"
        variables:
        - variable: extracted_json
          value_selector:
          - extract_llm_node
          - structured_output
        outputs:
          voucher_json:
            type: object
          validation_errors:
            type: array[string]
          confidence:
            type: number
          needs_hitl:
            type: boolean
          rejected:
            type: boolean
    - id: hitl_branch_node
      type: custom
      position:
        x: 1380
        y: 240
      data:
        type: if-else
        title: 信頼度判定
        desc: ≥0.95 自動承認 / <0.95 HITL
        conditions:
        - variable_selector:
          - validate_node
          - needs_hitl
          comparison_operator: is
          value: 'false'
          logical_operator: and
        logical_operator: and
    - id: end_auto_node
      type: custom
      position:
        x: 1720
        y: 120
      data:
        type: end
        title: 'End: 自動承認'
        desc: 信頼度0.95以上、エラーなし
        outputs:
        - variable: voucher_json
          value_selector:
          - validate_node
          - voucher_json
        - variable: confidence
          value_selector:
          - validate_node
          - confidence
        - variable: needs_hitl
          value_selector:
          - validate_node
          - needs_hitl
    - id: end_hitl_node
      type: custom
      position:
        x: 1720
        y: 360
      data:
        type: end
        title: 'End: HITL/差戻し'
        desc: 人手レビュー or 再投入が必要
        outputs:
        - variable: voucher_json
          value_selector:
          - validate_node
          - voucher_json
        - variable: validation_errors
          value_selector:
          - validate_node
          - validation_errors
        - variable: confidence
          value_selector:
          - validate_node
          - confidence
        - variable: needs_hitl
          value_selector:
          - validate_node
          - needs_hitl
