0
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?

ベンダー見積書を自動比較するAIエージェントの構築方法

0
Posted at

見積依頼書(RFQ)は明確な部品表(BOM)とともに送り出され、それに12社のサプライヤーが応じます。しかし、同じ形で答える企業は一つもありません。ある企業は、価格列が "Unit Price"、数量列が "Qty" というワークブックを送ってきます。別の企業は、同じ2つの列が "Rate" と "Pcs" になっているPDFを送ってきます。3社目はユーロで見積もり、数量列は Qty ではなく Lot を使い、さらに「価格には運送費が含まれ、消費税は含まれない」という脚注を付けています。1行でも明細を比較できるようになる前に、これらすべてを同じ意味に揃える必要があります。

vendor_a.xlsx   Part No   | Description   | Qty | Unit Price | Total
vendor_b.pdf    Item      | Specification | Pcs | Rate       | Amount
vendor_c.xlsx   SKU       | (空欄)        | Lot | Unit Cost  | Net (EUR)

同じ情報、3つの呼び方、3つの単位、1回の通貨換算。本記事では、この受信箱をランキング付きの購買レコメンデーションに変える完全な.NET実装を、Spire.Agent.Office を使って解説します。そして、さらに重要なのは、設計全体に1本の境界線を引くことです。

エージェントはサプライヤーのフィールドが何を意味するかを理解します。あなたのアプリケーションは、各パーツの勝者をどのサプライヤーにするかを決めます。

エージェント(意味的) アプリケーション(決定的)
Rate / Unit Cost / Unit PriceunitPrice にマッピング 通貨をUSDに換算
PDFとExcelから明細を抽出 数量と合計を検証
Qty / Pcsquantity にマッピング(Lot は見積書が数量として明確に示す場合のみ) スコアリングし、各パーツの勝者を選定

以下はすべて、この境界線を中心に構成されます。エージェントは異種のOffice/PDF文書を1つの正規スキーマ(canonical schema)に落とし込む責任を負い、アプリケーションは判断を生むすべての数値を担います。

1. 正規の見積スキーマを定義する

比較にまず必要なのは、あらゆる見積書を還元できる対象の形です。これを素のC#モデルとして定義します——これこそが正規スキーマです。

public sealed class QuoteLine
{
    public string Vendor { get; set; } = "";
    public string? Part { get; set; }
    public string? Description { get; set; }
    // 可空:エージェントは値が欠落または曖昧な場合に null を残す。
    public decimal? Quantity { get; set; }
    public decimal? UnitPrice { get; set; }
    public string? Currency { get; set; }
    public decimal? Total { get; set; }
    public int? DeliveryWeeks { get; set; }
    public int? PaymentTerms { get; set; }

    // アプリケーションが検証時に設定する —— エージェントは一切触らない。
    public string? Flag { get; set; }
}

サプライヤー独自のヘッダーからこのスキーマへのマッピングこそが、この問題の核心です。

ソースフィールド(サプライヤーごとに異なる) 正規フィールド
Part No / Item / SKU Part A-1024
Qty / Pcs Quantity 500
Lot(見積書が数量として明確に示す場合のみ) Quantity 5
Unit Price / Rate / Unit Cost UnitPrice 12.50
Net / Amount / Total Total 6,250.00
文書に記載された通貨記号 / コード Currency EUR
Delivery / Lead Time DeliveryWeeks 4
Terms PaymentTerms 30

手書きのパーサーは、サプライヤーが変わるたびにこの表を組み直す必要があります。エージェントの意義は、文字列の一致ではなく意味に基づいてこの表を適用できることです。

2. 異種の見積書を読み込む

受信箱は、2種類の文書形式が混在するフォルダです。前処理は一切ありません。エージェントが各ファイルをネイティブ形式のまま読むため、PDFとExcelは同じ1つの指示で、2つのオブジェクト型を通じて処理されます。

C:\sourcing\inbox\
   ├─ vendor_a.xlsx
   ├─ vendor_b.pdf
   └─ vendor_c.xlsx

届いたままの3通のサプライヤー見積書——Excelワークブック2つとPDF1つ。それぞれ版式と通貨が異なる

3. エージェントで各見積書を正規化する

Spire.Agent.Office は、Word、Excel、PowerPoint、PDF に対して1つの AI() プロセッサーを統一して公開します。パッケージを追加し、エージェントを一度設定してから、受信箱を走査します。

dotnet add package Spire.Agent.Office
using Spire.Agent.Office.AI;
using Spire.Agent.Office.Extensions;
using Spire.Pdf;
using Spire.Xls;

AIOptions agent = new AIOptions
{
    SpireToken = spireToken,        // Spire.Agent.Office アカウントから取得
    WorkDir    = @"C:\sourcing\work",
    TimeoutMs  = 300_000
};

const string instruction =
    "このサプライヤー見積書を読み、明細を抽出してください。入力文書と同じディレクトリに新しい .json ファイルを作成し、" +
    "そこに1つのJSON配列を書き込んでください。各明細行が1つのオブジェクトで、フィールドは以下のみです:vendor、" +
    "part、description、quantity、unitPrice、currency、total、deliveryWeeks、paymentTerms。" +
    "vendor はこの見積書を発行した会社で、文書のヘッダーから取得してください。絶対に null にしないでください。" +
    "deliveryWeeks と paymentTerms は整数です:deliveryWeeks は納期(週)、paymentTerms は" +
    "支払い期限(日)なので、'Net 30' は 30 になります。サプライヤー独自のヘッダーを" +
    "このスキーマにマッピングしてください——'Rate'、'Unit Cost'、'Unit Price' はすべて unitPrice を意味し、'Qty'、" +
    "'Pcs' は quantity を意味します。'Lot' は、文書がそれを明確に数量として示す場合にのみマッピングします。" +
    "値が欠落または曖昧な場合は、推測せずに null を残してください。出力ファイルは成果物です——必ずディスク上に作成してください。";

foreach (string file in Directory.GetFiles(inboxPath))
{
    string jsonPath = Path.Combine(normPath, Path.GetFileNameWithoutExtension(file) + ".json");

    // エージェント呼び出しはLLMが裏側にあるため、失敗したり出力ファイルを書かなかったりすることがある。リトライする。
    AIResult r = null!;
    Exception? last = null;
    for (int attempt = 1; attempt <= 3; attempt++)
    {
        try
        {
            if (file.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase))
            {
                using var quote = new PdfDocument();
                quote.LoadFromFile(file);
                r = quote.AI(agent).ExecuteInstruction(quote, instruction, jsonPath, Array.Empty<string>());
            }
            else
            {
                using var quote = new Workbook();
                quote.LoadFromFile(file);
                r = quote.AI(agent).ExecuteInstruction(quote, instruction, jsonPath, Array.Empty<string>());
            }
        }
        catch (Exception e) { last = e; }

        if (r is not null && r.Success) break;
    }

    if (r is null || !r.Success)
        throw new InvalidOperationException($"正規化に失敗しました {Path.GetFileName(file)}{r?.ErrorMessage ?? last?.Message}");
}

呼び出しごとに AIResult が返り、各見積書は正規スキーマにすでに適合したJSONファイルになります——vendor_b.pdf が生成するフィールドは vendor_a.xlsx と同じです。ここでエージェントが行うのは意味的な抽出とマッピングだけで、定量的な計算は一切ありません。

同じ添付ファイル機構は、さらに先まで使えます。歴史価格表や部品仕様書を見積書と一緒に添付すれば、エージェントはその部品の通常価格から大きく逸脱した明細を指摘できます——これはどの閾値でもエンコードできない判断です。これを「助言」の性質に留めてください。問題を提起するのはエージェントですが、判断を下すのは引き続きあなたのコードです。

正規化の前後:エージェントが各サプライヤー独自のヘッダーを正規スキーマにマッピング

4. データを検証して通貨を換算する

ここから決定的な半分が始まります。正規化された各JSONをモデルに読み戻し、固定の為替レート表で単一通貨に換算し、成立しない行を除外します。

using System.Text.Json;

private static readonly Dictionary<string, decimal> Fx = new()
{
    ["USD"] = 1.00m,
    ["EUR"] = 1.09m,
    ["GBP"] = 1.27m,
};

var quotes = new List<QuoteLine>();
foreach (string json in Directory.GetFiles(normPath, "*.json"))
{
    quotes.AddRange(JsonSerializer.Deserialize<List<QuoteLine>>(File.ReadAllText(json),
        new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!);
}

foreach (var q in quotes)
{
    if (string.IsNullOrWhiteSpace(q.Part)) q.Flag = "パーツ番号が欠落";
    else if (q.Quantity is null or <= 0)   q.Flag = "数量が非正";
    else if (q.UnitPrice is null or <= 0)  q.Flag = "単価が非正";
    else if (!Fx.TryGetValue(q.Currency ?? "", out var fx))
                                           q.Flag = $"未対応の通貨:{q.Currency ?? "(なし)"}";
    else if (q.Total is null)              q.Flag = "合計が欠落";
    else if (q.DeliveryWeeks is null)      q.Flag = "納期が欠落";
    else if (q.PaymentTerms is null)       q.Flag = "支払い条件が欠落";
    else if (Math.Abs((q.Quantity.Value * q.UnitPrice.Value
                     - q.Total.Value) * fx) > 0.01m)
                                           q.Flag = "数量 × 単価 ≠ 合計";
}

為替レート表、検証の閾値、算術はすべてコード内に置かれます——そこでレビューでき、バージョン管理でき、監査の際に引用できます。レートのない通貨は黙ってUSDとして扱われるのではなくフラグが立ちます。スコアリングが依存するのにエージェントが null のまま残したフィールドも同様にフラグされます。Description だけは null のままでよい唯一のフィールドです——どの判断にも関与しないためです。エージェントはこれらの判断を一切行っておらず、フィールドを提供しただけです。

5. 決定的なルールでスコアリングとランキング

クリーンな行はスコアリングできます。重み付けは固定関数であり、モデルの意見ではありません。

private const decimal PriceTargetUsd = 15.00m;   // 1個あたりの予算

private static double Score(decimal unitUsd, int deliveryWeeks, int paymentTerms)
{
    double price    = unitUsd <= PriceTargetUsd ? 50.0
                    : 50.0 * (double)(PriceTargetUsd / unitUsd);
    double delivery = deliveryWeeks <= 4 ? 30.0 : 0.0;
    double terms    = paymentTerms >= 30 ? 20.0 : 0.0;
    return price + delivery + terms;
}

これをすべての有効な行に適用し、並べ替えて、レコメンデーションのステップが読む2つのデータセット——ランキング済みの見積行と、除外された異常行——を書き出します。ここにある行はすべてステップ4の検証を通過しているため、フィールドは非nullです。!.Value はコンパイラーを満足させるためだけのものです。

public sealed record RankedQuote(
    string Vendor, string Part, decimal UnitUsd,
    int DeliveryWeeks, int PaymentTerms, double Score);

var scored = new List<RankedQuote>();
foreach (var q in quotes.Where(q => q.Flag is null))
{
    decimal unitUsd = q.UnitPrice!.Value * Fx[q.Currency!];
    int delivery    = q.DeliveryWeeks!.Value;
    int terms       = q.PaymentTerms!.Value;
    scored.Add(new RankedQuote(q.Vendor, q.Part!, unitUsd, delivery, terms,
        Score(unitUsd, delivery, terms)));
}
scored.Sort((a, b) => b.Score.CompareTo(a.Score));

var flagged = quotes.Where(q => q.Flag is not null).ToList();

File.WriteAllText(@"C:\sourcing\data\ranked-quotes.json",
    JsonSerializer.Serialize(scored, new JsonSerializerOptions { WriteIndented = true }));
File.WriteAllText(@"C:\sourcing\data\flagged-lines.json",
    JsonSerializer.Serialize(flagged, new JsonSerializerOptions { WriteIndented = true }));

ランキング済みの見積書と除外された異常行——決定的なスコアリングのステップが生み出す2つのデータセット

6. 購買レコメンデーションを生成する

最後の文書は再びエージェントの仕事です——ただし、ランキング済みデータから書くのであって、選ぶのではありません。ランキングはステップ5で既に決まっており、勝者も同様です——パーツごとに1つです。

using Spire.Doc;

// パーツごとの勝者1社。モデルではなくコードが決める。
string winners = string.Join("、", scored
    .GroupBy(v => v.Part)
    .Select(g => g.OrderByDescending(v => v.Score).First())
    .Select(w => $"{w.Vendor}{w.Part})"));

// エージェントはファイル添付ではなく、指示からデータを読む。
string rankedJson  = File.ReadAllText(@"C:\sourcing\data\ranked-quotes.json");
string flaggedJson = File.ReadAllText(@"C:\sourcing\data\flagged-lines.json");

using (Document report = new Document())
{
    string instruction = $"""
        購買レコメンデションを書いてください。
        ランキング済みの見積行(すでにスコアリングされ並び替え済み、11オブジェクト):{rankedJson}

        スコアリングから除外された異常行(それぞれに "flag" フィルドがあります):{flaggedJson}

        ツごとの勝者サプライヤは次のとおりです:{winners}
        上記で示された結果をそのまま、パツごとの勝者として記述してください。

        ランキング上位3件に名前を付け、ランキング済み見積行の比較表(サプライヤ、パツ、
        USD単価、納期、条件、スコア)を追加し、異常行をリストアップして、それぞれの
        flag を購買担当者がそのまま行動できる平易な日本語1文で説明してください。
        スコアを再計算したり、別の勝者を選んだりしないでください。
        レイアウトを整え、印刷可能な状態に保ってください。
        """;

    AIResult result = report.AI(agent).ExecuteInstruction(
        report,
        instruction,
        @"C:\sourcing\output\purchasing-recommendation.docx",
        Array.Empty<string>());

    if (result is null || !result.Success)
        throw new InvalidOperationException($"レポート生成に失敗しました:{result?.ErrorMessage}");
}

勝者は指示に書かれた単なるデータであり、モデルがランキングから引き出した結論ではありません。部品表の各パーツは、最もスコアが高いサプライヤーに個別に与えられます。エージェントはテーブルと説明文のためにランキングデータを読むのです。判断は上で既に下されており、指示はそれを再導出することを明示的に禁じています。savePath.pdf に変えれば、同じ指示で配布用のレコメンデーションを書き出せます。異常フラグもステップ4でコードが判定しています——エージェントは1行たりとも判定していません。この側でエージェントが貢献するのは言語です。数量 × 単価 ≠ 合計 のような素っ気ない Flag を受け取り、購買マネージャーが実際に読む文を書きます——その判断を再審議することなく。コードで判断し、モデルで叙述する——同じ境界線が、今度は出力側に現れています。

生成された purchasing-recommendation.docx:上位3件、比較表、エージェントが書いた異常行の平易な説明

7. エージェントのセッションを検証する

すべての ExecuteInstruction 呼び出しはセッションとして実行され、Spire.Agent.Office は入力、生成された出力、実行成果物を WorkDir の下にまとめて保持します。

C:\sourcing\work\
└─ .office_use_tmp\
   ├─ Excel\
   │  └─ 0814140210_a1b2c3d4\        ← vendor_a.xlsx の正規化
   │     ├─ vendor_a.xlsx
   │     └─ normalized.json
   ├─ Pdf\
   │  └─ 0814140223_e5f6a7b8\        ← vendor_b.pdf の正規化
   │     ├─ vendor_b.pdf
   │     └─ normalized.json
   └─ Word\
      └─ 0814140411_c9d0e1f2\        ← レコメンデーション生成
         ├─ ranked-quotes.json
         └─ purchasing-recommendation.docx

セッションフォルダは、何が処理され何が生成されたのかの完全な記録です——ワークフロー全体の監査証跡です。重要なのは、エージェントが抽出を内部でどう実行するかではありません。アプリケーションレベルでは、異種のサプライヤー文書が1つの正規スキーマに還元され、検証・通貨換算・スコアリング・サプライヤー選定はすべてあなたのコード内で明示的かつ決定的に保たれます。

だからこそ、この出力は実際の購買ワークフローで信頼できます。サプライヤーが数字に異議を唱えたら、セッションを指し示して、自社の見積書がどう読み取られたかを正確に見せられます。

8. 完全なワークフロー

パイプライン全体を、最初から最後まで。

static void Run()
{
    AIOptions agent = new() { SpireToken = spireToken, WorkDir = workDir, TimeoutMs = 300_000 };

    NormalizeQuotes(agent, inboxPath, normPath);       // ステップ3 —— エージェント、意味的
    var quotes = LoadAndValidate(normPath);             // ステップ4 —— コード、決定的
    var (scored, flagged) = ScoreAndRank(quotes);       // ステップ5 —— コード、決定的
    WriteDatasets(scored, flagged);                     //             JSONにシリアライズ
    GenerateRecommendation(agent, outputPath);          // ステップ6 —— エージェント、文書作成
}

この設計で再利用できるのは見積ロジックではありません——それが引く境界線です。意味的な抽出(異種のOffice/PDF文書から意味を読むこと)はエージェントに属し、定量的な判断(通貨、検証、スコアリング、ランキング)はすべてあなたのコードに属します。同じ分割は、請求書処理、履歴書スクリーニング、RFPへの応答にもそのまま持ち込めます。正規スキーマと指示とスコアリング関数を差し替え、境界線を守るだけです。

最後に、このワークフローを自分で動かすには、Spire.Agent.Office の統合入門チュートリアルがセットアップ手順を説明しており、製品概要にはSDKが公開する4つのエージェント(Word、Excel、PowerPoint、PDF)が一覧されています。

0
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
0
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?