app:
  description: Document AI Invoice Parser で voucher_json 雛形を生成、 VLM Vision が雛形を画像照合して補完・修正
  icon: 🧾
  icon_background: '#FFEAD5'
  mode: workflow
  name: Voucher Extractor (DocAI 雛形 → VLM 補完, gpt-5-mini)
  use_icon_as_answer_icon: false
kind: app
version: 0.1.5
workflow:
  conversation_variables: []
  environment_variables: []
  features:
    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: 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
    - id: start__to__ocr
      source: start_node
      target: ocr_node
      sourceHandle: source
      targetHandle: target
      type: custom
    - id: ocr__to__llm
      source: ocr_node
      target: extract_llm_node
      sourceHandle: source
      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
          allowed_file_extensions:
          - .png
          - .jpg
          - .jpeg
          allowed_file_upload_methods:
          - local_file
          - remote_url
          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-5-mini
          mode: chat
          completion_params: {}
        prompt_template:
        - role: system
          text: "あなたは日本の中小企業向け会計業務に精通した、帳票読取の専門家です。\nこれから提示する画像は、**見積書 (quotation)** または **注文書 (purchase_order)** のいずれかです。\n帳票の内容を正確に読み取り、指定された JSON Schema に厳密に従ったJSONオブジェクトを出力してください。\n\n## 厳守ルール\n\n1. **出力は JSON オブジェクトのみ**。前後に説明文・コードフェンス・コメントを含めないこと。\n2. JSON Schema (`voucher_input.schema.json`) を厳密に守ること。必須項目を欠かさない。\n3. **読み取れない項目は推測せず `null`** を入れる。捏造は絶対禁止。\n4. **金額・数量は数値型 (number)** で出力。「¥1,200」「1,200円」等は `1200` に正規化する。\n5. **日付は `YYYY-MM-DD` 形式**。「令和7年4月15日」「2025/4/15」「H7.4.15」等を `2025-04-15` に正規化。\n6. **税率の判定**:\n   - 10% → `0.1`\n   - 軽減税率8% → `0.08`\n   - 非課税・対象外 → `0`\n   - 不明 → `null`\n7. **適格請求書番号** (インボイス番号) は `T` で始まる13桁の数字。形式不一致なら `null`。\n8. **品名・仕様は省略せず全文転記**。半角/全角は原文に従う。\n9. **手書き訂正の取り扱い (最重要)**:\n   - 取り消し線で消された印刷値の横/上に手書きで別の値が書かれている場合 → **手書きの訂正後の値を採用**。\n   - 赤・青などの色付きペンの書き加え、印影・スタンプによる訂正も同様に**訂正後を優先**。\n   - 訂正があった場合、必ず以下を実施:\n     1. items[*].remarks に `\"手書き訂正: 元値=¥XXX → 訂正値=¥YYY\"` の形式で記録\n     2. extraction_meta.warnings に `\"handwritten_correction_on_<field>\"` (例:\
            \ `handwritten_correction_on_items[0].unit_price`) を追加\n     3. extraction_meta.overall_confidence は最大でも 0.85 に下げる（HITLに必ず回るように）\n   - 余白の手書きメモは notes フィールドに転記。\n   - 数量×単価=金額の整合性が手書き訂正で崩れた場合、**訂正後の値を優先**して再計算した結果を amount に入れる（subtotal/tax/total も再計算した値を入れる、元の印刷集計とのズレは warnings に `subtotal_recomputed_after_handwriting` として記録）。\n10. **document_type の判定基準**:\n   - タイトルに「見積書」「Estimate」「Quotation」 → `quotation`\n   - タイトルに「注文書」「発注書」「Purchase Order」「ご注文書」 → `purchase_order`\n   - 判別不能なら `quotation` を選び、`extraction_meta.warnings` に `document_type_uncertain` を追加\n\n## 信頼度報告\n\n`extraction_meta.overall_confidence` は 0.0〜1.0 の数値で：\n- 文字がクリアに読める / 全項目埋まる → 0.95 以上\n- 一部の数値や品名が不鮮明 → 0.7〜0.9\n- 半分以上の項目が推測 → 0.5 以下\n\n`extraction_meta.low_confidence_fields` には自信が持てなかったフィールドのドット記法パスを列挙：\n- 例: `[\"items[2].unit_price\", \"supplier.tax_id\"]`\n\n`extraction_meta.warnings` には特記事項を文字列で列挙：\n- 例: `[\"handwritten_correction_on_total\", \"stamp_overlapping_amount\", \"document_type_uncertain\"]`\n\
            \n## 集計整合性\n\n- `subtotal` = items[].amount の合計と一致するはず\n- `total` = `subtotal` + `tax` と一致するはず\n- 一致しない場合、**帳票上の表示金額を優先**し、warnings に `subtotal_mismatch` 等を追加\n\n## 出力例\n\nFew-shot 例は user message で示します。それに従ってください。"
        - role: user
          text: '## あなたの役割

            Google Document AI が事前に抽出した voucher_json の **雛形** を受け取り、 画像と照らし合わせて 補完・修正します。 ゼロから抽出するのではなく、 雛形をベースに 修正だけ行ってください。


            ## Document AI が生成した voucher 雛形 (JSON)

            ```json

            {{#ocr_node.body#}}

            ```


            ## あなたの作業

            1. **None の field を画像から読み取って埋める** (特に supplier.name, buyer.name は雛形で None になりがち)

            2. **明らかに誤っている値を修正** (例: 2035年→2026年、 PO-25-0188→PO-2026-0188 等の桁抜け)

            3. **items を画像と照合して整理** (DocAI の items は分裂・誤検出が多いので、 画像から正しい行数で再構成してよい)

            4. **document_type** を 注文書/見積書/請求書/納品書 から決定

            5. tax_id 等で T が抜けていれば 補完


            ## 制約

            - 画像と DocAI 雛形が一致しない場合、 **画像優先**

            - 数値は半角・カンマなし

            - 日付は ISO 8601 (YYYY-MM-DD)

            - DocAI が拾えた値で正しいものはそのまま採用 (再抽出して値が変わると不安定)

            - 信頼度を extraction_meta.confidence にまとめる (DocAI 元値: voucher.confidence 参照)

            '
          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
          properties:
            document_type:
              type: string
              enum:
              - quotation
              - purchase_order
            document_id:
              type:
              - string
              - 'null'
            issue_date:
              type: string
            expiration_date:
              type:
              - string
              - 'null'
            delivery_date:
              type:
              - string
              - 'null'
            supplier:
              type: object
              required:
              - name
              properties:
                name:
                  type: string
                address:
                  type:
                  - string
                  - 'null'
                tax_id:
                  type:
                  - string
                  - 'null'
                tel:
                  type:
                  - string
                  - 'null'
                person_in_charge:
                  type:
                  - string
                  - 'null'
            buyer:
              type: object
              required:
              - name
              properties:
                name:
                  type: string
                department:
                  type:
                  - string
                  - 'null'
                person_in_charge:
                  type:
                  - string
                  - 'null'
            items:
              type: array
              minItems: 1
              items:
                type: object
                required:
                - item_name
                - quantity
                - unit_price
                - amount
                properties:
                  line_no:
                    type:
                    - integer
                    - 'null'
                  item_code:
                    type:
                    - string
                    - 'null'
                  item_name:
                    type: string
                  spec:
                    type:
                    - string
                    - 'null'
                  quantity:
                    type: number
                    minimum: 0
                  unit:
                    type:
                    - string
                    - 'null'
                  unit_price:
                    type: number
                    minimum: 0
                  amount:
                    type: number
                    minimum: 0
                  tax_rate:
                    type:
                    - number
                    - 'null'
                    enum:
                    - 0
                    - 0.08
                    - 0.1
                    - null
                  remarks:
                    type:
                    - string
                    - 'null'
            subtotal:
              type: number
              minimum: 0
            tax:
              type: number
              minimum: 0
            total:
              type: number
              minimum: 0
            currency:
              type: string
            payment_terms:
              type:
              - string
              - 'null'
            notes:
              type:
              - string
              - 'null'
            extraction_meta:
              type: object
              properties:
                overall_confidence:
                  type: number
                  minimum: 0
                  maximum: 1
                low_confidence_fields:
                  type: array
                  items:
                    type: string
                warnings:
                  type: array
                  items:
                    type: string
    - 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
    - id: ocr_node
      type: custom
      position:
        x: 400
        y: 240
      data:
        type: http-request
        title: Document AI → voucher 雛形
        desc: entities を voucher_json schema に直接マッピング
        method: post
        url: http://voucher-helper:8081/api/docai-to-voucher
        authorization:
          type: no-auth
          config: null
        headers: 'Content-Type: application/json'
        params: ''
        body:
          type: json
          data: '{"file_url": "{{#start_node.input_file.url#}}"}'
        timeout:
          max_connect_timeout: 10
          max_read_timeout: 90
          max_write_timeout: 30
