# Dify Workflow: Voucher Extractor (VLM Single)
# 見積書/注文書 → 構造化JSON 抽出 (Claude Sonnet 4.6 ビジョン単体)
#
# Dify 1.13.3 で動作確認想定。
# モデルプロバイダ:
#   - anthropic/claude-sonnet-4-6 (またはBedrock/Vertex経由のクロード)
#   - 環境変数 ANTHROPIC_API_KEY を Dify > Settings > Model Providers で登録
#
# インポート方法:
#   1. Dify Studio > Studio > Create from blank > Import DSL file
#   2. このYAMLを選択
#   3. インポート後、LLMノードのモデルを実際のプロバイダに再選択
#   4. 必要に応じてプロンプトを 110/dify/prompts/ から最新版に差し替え

app:
  description: "見積書/注文書から発注/受注伝票用の構造化JSONを抽出する(VLM単体版)"
  icon: 🧾
  icon_background: "#FFEAD5"
  mode: workflow
  name: "Voucher Extractor (VLM Single)"
  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__pdf_split
        source: start_node
        target: pdf_split_node
        sourceHandle: source
        targetHandle: target
        type: custom
      - id: pdf_split__to__extract_llm
        source: pdf_split_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:
      # ====================
      # 1. Start: ファイル受領
      # ====================
      - 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: "見積書/注文書 のいずれかを示唆する自由記述"

      # ====================
      # 2. Code: PDF→画像変換 (PDFの場合)
      # ====================
      - id: pdf_split_node
        type: custom
        position: {x: 360, y: 240}
        data:
          type: code
          title: "PDF→画像 変換"
          desc: "PDFを各ページの画像URLに展開。画像は通過"
          code_language: python3
          code: |
            # Difyの実行環境ではファイルのURLを取得しpdf2imageで分割する。
            # 簡略化: Difyの最新版では file 変数から url を取り出し HTTPで取得→pdf2image→1ページずつアップロード→imageオブジェクト配列を返す
            # ここではPoC初版なので、PDF 1ページ目のみを使う想定の最小実装

            def main(input_file: dict, doc_kind_hint: str = "") -> dict:
                # Dify の file オブジェクトは {url, mime_type, name, ...} を持つ
                mime = (input_file or {}).get("mime_type", "")
                url = (input_file or {}).get("url", "")

                if mime.startswith("image/"):
                    return {
                        "image_url": url,
                        "page_count": 1,
                        "is_pdf": False,
                    }

                # PDFの場合: Dify組み込みのHTTPノードでバイナリ取得→pdf2imageは別ノードに任せる
                # PoC初版では「PDFはDify外部で画像化したものをアップロードしてください」と運用カバー
                return {
                    "image_url": url,
                    "page_count": -1,
                    "is_pdf": True,
                }
          variables:
            - variable: input_file
              value_selector: ["start_node", "input_file"]
            - variable: doc_kind_hint
              value_selector: ["start_node", "doc_kind_hint"]
          outputs:
            image_url:
              type: string
            page_count:
              type: number
            is_pdf:
              type: boolean

      # ====================
      # 3. LLM: VLM 抽出 (Claude Sonnet 4.6)
      # ====================
      - 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: anthropic
            name: claude-sonnet-4-6
            mode: chat
            completion_params:
              temperature: 0.1
              max_tokens: 4096
              top_p: 0.95
          prompt_template:
            - role: system
              text: |
                あなたは日本の中小企業向け会計業務に精通した、帳票読取の専門家です。
                これから提示する画像は、見積書 (quotation) または 注文書 (purchase_order) のいずれかです。
                帳票の内容を正確に読み取り、指定された JSON Schema に厳密に従ったJSONオブジェクトを出力してください。

                ## 厳守ルール
                1. 出力は JSON オブジェクトのみ。前後に説明文・コードフェンスを含めないこと。
                2. 読み取れない項目は推測せず null を入れる。捏造禁止。
                3. 金額・数量は数値型(number)。「¥1,200」「1,200円」等は 1200 に正規化。
                4. 日付は YYYY-MM-DD 形式に正規化 (令和7年4月15日 → 2025-04-15)。
                5. 税率: 10% → 0.1, 軽減8% → 0.08, 非課税 → 0, 不明 → null
                6. 適格請求書番号は T+13桁の数字。形式不一致は null。
                7. 品名・仕様は省略せず全文転記。
                8. document_type: 「見積書/Estimate/Quotation」→ quotation、「注文書/発注書/Purchase Order」→ purchase_order

                ## 信頼度報告
                extraction_meta.overall_confidence は 0.0〜1.0:
                - クリアに読める → 0.95+
                - 一部不鮮明 → 0.7〜0.9
                - 半分推測 → 0.5以下
                extraction_meta.low_confidence_fields に項目名のドット記法パスを列挙。
                extraction_meta.warnings に特記事項を文字列で列挙。

                ## JSON Schema (概要)
                {
                  "document_type": "quotation | purchase_order",
                  "document_id": "string | null",
                  "issue_date": "YYYY-MM-DD",
                  "expiration_date": "YYYY-MM-DD | null",
                  "delivery_date": "YYYY-MM-DD | null",
                  "supplier": {"name": "string", "address": "string|null", "tax_id": "T...|null", "tel": "string|null", "person_in_charge": "string|null"},
                  "buyer": {"name": "string", "department": "string|null", "person_in_charge": "string|null"},
                  "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"}],
                  "subtotal": "number",
                  "tax": "number",
                  "total": "number",
                  "currency": "JPY",
                  "payment_terms": "string | null",
                  "notes": "string | null",
                  "extraction_meta": {"overall_confidence": "number", "low_confidence_fields": ["string"], "warnings": ["string"]}
                }
            - 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: remote_url
                  url: "{{#pdf_split_node.image_url#}}"
          vision:
            enabled: true
            variable_selector: ["pdf_split_node", "image_url"]
            configs:
              detail: "high"
          context:
            enabled: false
          structured_output:
            enabled: true
            schema:
              type: object
              required: ["document_type", "issue_date", "supplier", "buyer", "items", "subtotal", "tax", "total"]
              # 完全スキーマは dify/schemas/voucher_input.schema.json 参照

      # ====================
      # 4. Code: バリデーション
      # ====================
      - id: validate_node
        type: custom
        position: {x: 1040, y: 240}
        data:
          type: code
          title: "バリデーション"
          desc: "税計算照合・整合性チェック・信頼度算出"
          code_language: python3
          code: |
            # 実装は dify/code/validate.py を参照。
            # Difyの Code ノードは単一関数 main(args) -> dict の形式。
            # 以下はその要約版。最新版は外部ファイルから貼り直すこと。

            import re

            CONFIDENCE_AUTO_APPROVE = 0.95
            CONFIDENCE_HITL_THRESHOLD = 0.70
            TAX_ID_PATTERN = re.compile(r"^T\d{13}$")
            DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$")

            def _to_number(v):
                if v is None: return None
                if isinstance(v, (int, float)): return float(v)
                if isinstance(v, str):
                    c = re.sub(r"[,\s¥円]", "", v)
                    try: return float(c)
                    except: return None
                return None

            def main(extracted_json):
                voucher = dict(extracted_json or {})
                errors, warnings, low_conf = [], [], []
                meta = voucher.get("extraction_meta") or {}
                warnings.extend(meta.get("warnings") or [])
                low_conf.extend(meta.get("low_confidence_fields") or [])

                for r in ("document_type","issue_date","items","subtotal","tax","total"):
                    if voucher.get(r) in (None, "", []):
                        errors.append(f"missing:{r}")

                for d in ("issue_date","expiration_date","delivery_date"):
                    v = voucher.get(d)
                    if v and not DATE_PATTERN.match(str(v)):
                        errors.append(f"bad_date:{d}={v}")

                sup = voucher.get("supplier") or {}
                if sup.get("tax_id") and not TAX_ID_PATTERN.match(str(sup["tax_id"])):
                    warnings.append(f"bad_tax_id:{sup['tax_id']}")
                    sup["tax_id"] = None

                items = list(voucher.get("items") or [])
                item_subtotal = 0.0
                norm_items = []
                for i, it in enumerate(items):
                    q = _to_number(it.get("quantity"))
                    p = _to_number(it.get("unit_price"))
                    a = _to_number(it.get("amount"))
                    tr = it.get("tax_rate")
                    if tr is not None:
                        tr = _to_number(tr)
                        if tr is not None and tr > 1: tr = tr / 100.0
                    if q is not None and p is not None:
                        calc = round(q * p)
                        if a is None:
                            a = calc
                            warnings.append(f"items[{i}].amount_inferred")
                        elif abs(a - calc) > 2:
                            errors.append(f"items[{i}].amount_mismatch:{a}vs{calc}")
                            low_conf.append(f"items[{i}].amount")
                    if tr is None and a is not None:
                        tr = 0.1
                        warnings.append(f"items[{i}].tax_rate_defaulted")
                    if a is not None: item_subtotal += a
                    it2 = dict(it); it2.update({"quantity": q, "unit_price": p, "amount": a, "tax_rate": tr})
                    norm_items.append(it2)
                voucher["items"] = norm_items

                ss = _to_number(voucher.get("subtotal"))
                st = _to_number(voucher.get("tax"))
                tt = _to_number(voucher.get("total"))
                if ss is not None and abs(ss - item_subtotal) > 2:
                    warnings.append(f"subtotal_mismatch:{ss}vs{item_subtotal}")
                    low_conf.append("subtotal")
                if ss is not None and st is not None and tt is not None and abs(ss + st - tt) > 2:
                    errors.append(f"total_mismatch:{ss+st}vs{tt}")
                    low_conf.append("total")

                base = float(meta.get("overall_confidence") or 0.5)
                penalty = min(0.5, len(errors)*0.10 + len(set(low_conf))*0.05)
                conf = max(0.0, min(1.0, base - penalty))
                needs_hitl = conf < CONFIDENCE_AUTO_APPROVE or len(errors) > 0
                rejected = conf < CONFIDENCE_HITL_THRESHOLD

                voucher["extraction_meta"] = {
                    "overall_confidence": round(conf, 3),
                    "low_confidence_fields": sorted(set(low_conf)),
                    "warnings": sorted(set(warnings)),
                }
                return {
                    "voucher_json": voucher,
                    "validation_errors": errors,
                    "confidence": round(conf, 3),
                    "needs_hitl": needs_hitl,
                    "rejected": rejected,
                }
          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

      # ====================
      # 5. IfElse: HITL 分岐
      # ====================
      - 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

      # ====================
      # 6a. End (自動承認)
      # ====================
      - 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"]

      # ====================
      # 6b. End (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"]
