こんにちは。公共工事の入札・落札情報を、建設会社の判断と営業につなげるNewsatsuを開発しています。
Newsatsuの考え方は、かなり単純です。
公開されている入札情報を、「次に誰が、どう動くか」までつなげる。
官公庁の入札情報は、もともと公開されています。
それでも、実務で使おうとすると簡単ではありません。
- 発注機関ごとにサイトが違う
- 公告、設計書、内訳書、落札結果が別々に置かれている
- PDFやExcelを開かないと、参加資格や数量が分からない
- 同じ会社でも表記が揺れる
- 落札後に必要になる専門工事が、案件名だけでは見えない
Newsatsuは、これらを収集して一つのデータモデルにそろえ、入札判断、概算見積、競合分析、落札後の営業候補までを表示するシステムです。
2026年8月14日の公開画面では、入札案件329,777件、落札結果281,140件、建設会社430,175社を掲載しています。都道府県単位で本番収集しているのは10県です。件数は更新のたびに増えるため、最新値はNewsatsuで確認できます。
スマートフォンアプリ
この記事では機能一覧ではなく、根本にある一つの設計判断を中心に書きます。
AIに何を任せ、何を絶対に任せないのか。
この線引きを決めないままAIを足すと、文章は作れても、業務システムとしては信用できませんでした。
技術スタック
まず、全体像です。
| 領域 | 主な技術 | 役割 |
|---|---|---|
| Web | Next.js | 検索、案件詳細、会社詳細、分析画面 |
| API | FastAPI | 検索、判定、見積、推薦、モバイルAPI |
| データベース | PostgreSQL 16 + pgvector | 案件、入札、落札、会社、文書、ベクトル、判定結果 |
| 収集 | Python + Playwright | 電子入札サイト、自治体ページ、公開帳票の収集 |
| 文書処理 | PDF/Excel解析 + OCR | 公告、設計書、内訳書のテキスト化 |
| ローカルAI | Ollama + gpt-oss / Gemma / bge-m3 / Qwen-VL | 抽出、要約、分類、埋め込み、画像文書処理 |
| 計算基盤 | NVIDIA DGX Spark 2台 | 本番処理とAI・バッチ処理の分担 |
| エッジ | Cloudflare Workers + D1 + R2 | 一部のWeb詳細ページをオリジンから肩代わり |
| モバイル | SwiftUI / Jetpack Compose | iPhone・Androidからの利用 |
2026年8月14日時点のPostgreSQLは34GB、基本テーブル336本、インデックス704本でした。
数字だけ見ると大きなシステムに見えますが、処理の流れは次の7段です。
官公庁の公開情報
↓
一覧・詳細・PDF・ZIPを収集
↓
原本を保存
↓
テキスト抽出/必要なページだけOCR
↓
ローカルLLMが事実を構造化
↓
決定論ルールが根拠と条件を検証
↓
PostgreSQLで関係をつなぎ、Web・アプリへ配信
大事なのは、LLMの直後に必ず「検証」があることです。
1. 入口は「Webサイト」ではなく、発注機関ごとの別世界
公共工事の情報を集める、と聞くと、検索結果をスクレイピングするだけに見えるかもしれません。
実際は、同じように見える電子入札サイトでも仕様が違います。
- 文字コードがShift-JIS
- フレーム内のJavaScriptを実行しないと一覧へ進めない
- 検索フォームの日付フィールド名が違う
- 工事、コンサル、業務委託のコード体系が県ごとに違う
- 結果一覧と詳細画面で案件番号の表記が違う
- 土日や深夜はポータル自体が閉まる
福岡県では、県内だけでも複数系統の仕組みが併存しています。「県を一つ追加する」は、設定を一行増やす作業ではありません。
そこで、共通する遷移や保存処理と、発注機関ごとの差分を分けました。
共通エンジン
├─ 一覧を開く
├─ ページを送る
├─ 原本を保存する
├─ 再試行する
└─ 巡回結果を記録する
発注機関ごとの設定・アダプタ
├─ URL
├─ 文字コード
├─ フォーム名
├─ 調達区分コード
└─ 詳細ページの読み方
2026年8月14日時点でスパイダーは47本です。県・市町村の入札ポータルだけでなく、国機関の案件、会社名簿、建設業許可なども別々に収集します。
ここでの教訓は、「似た画面」を「同じデータ源」と思わないことでした。
2. 解析より前に、原本を保存する
収集したHTMLをその場で解析し、必要な値だけDBへ入れる構成はシンプルです。
ただし、後からパーサの誤りが見つかったとき、元の画面が更新されていたら再現できません。公開期間が終わり、ページ自体が消えることもあります。
そのためNewsatsuでは、可能な範囲で次を先に保存します。
- 一覧・詳細画面の取得内容
- 公告PDF
- 設計書や内訳書のZIP
- 取得元URL
- 取得日時
- 文書のハッシュ
考え方は「解析してから保存」ではなく、「捕まえてから解析」です。
取得成功
↓
原本保存
↓
解析
↓
構造化
↓
表示
これにより、抽出ロジックを直した後に、同じ原本へ再実行できます。
AIを使うほど、原本は重要になります。AIの回答を保存するだけでは、回答が正しかったかを後から確認できないからです。
3. 「案件」という一枚の表に全部を押し込まない
最初は、案件名、発注者、予定価格、落札者を一つの行に入れたくなります。
しかし、実際の入札は一対一ではありません。
project(工事の実体)
├─ bid(入札単位)
│ ├─ award(落札結果)
│ └─ bid_attempts(各社の応札)
├─ raw_docs(公告・設計書・内訳書)
├─ eligibility(参加資格)
└─ work_items(工事内訳)
同じ工事に複数の入札区分が付くことがあります。結果が後から差し替わることもあります。共同企業体では、落札者表示も一社とは限りません。
そこで、Newsatsuでは少なくとも次を分離しています。
- 工事そのもの
- 入札手続き
- 落札結果
- 応札した会社と金額
- 原本文書
- 参加資格の根拠
- 内訳書の工事項目
この分離が、後段の「どの会社が落札したか」「誰が取りに来ていたか」「次にどの専門工事が必要か」をつなぐ土台になります。
4. LLMは「読む」。最終判定はルールが行う
公告PDFの文章は、自治体ごとに書き方が違います。
例えば、参加資格だけでも次のような情報があります。
- 建設業許可の種類
- 等級
- 営業所の所在地
- 経営事項審査の点数
- 同種工事の実績
- 配置予定技術者
- 指名停止を受けていないこと
これを正規表現だけで全国共通に読むのは難しいため、LLMで「何が書かれているか」を構造化します。
ただし、LLMには最終的な参加可否を決めさせません。
facts = local_llm.extract(source_text)
grounded_facts = verify_against_source(facts, source_text)
verdict = deterministic_rules.evaluate(
grounded_facts,
company_profile,
)
役割は次のように分けています。
| 処理 | LLM | 決定論ルール |
|---|---|---|
| 条件文を見つける | ○ | |
| 表記ゆれを吸収して項目化する | ○ | |
| 原文に数値が本当にあるか確認する | ○ | |
| 自社情報と条件を比較する | ○ | |
| 最終的なPASS / FAILを出す | ○ | |
| 根拠不足なら保留する | ○ |
判定は二値ではなく、三値です。
| 状態 | 意味 |
|---|---|
| eligible | 確認できた条件を満たす |
| ineligible | 確認できた条件を満たさない |
| needs_check | 自動判定に必要な根拠が足りない |
特に重要なのは、次の式です。
「分からない」≠「条件がない」
検証段階で、要件がまだ抽出されていない行を「制約なし」と解釈すると、十万件規模の誤PASSが生まれ得ることが分かりました。
利用者にとって最も危険なのは、取れない工事を「取れる」と表示することです。分からない場合は、無理に答えを作らず、確認が必要だと返します。
5. 数字を出せることと、数字を出してよいことは別
予定価格、最低制限価格、数量、落札率など、入札システムは数字だらけです。
LLMは、画像や崩れた表からも、それらしい数字を返せます。
しかし、それらしい数字は業務では使えません。
設計書の内訳、いわゆるBOQは三段階で処理しています。
表構造を保った精密抽出
↓ 取れない
節・見出し単位の抽出
↓ 取れない
必要なページだけOCR
抽出後は、名称、数量、単位、単価、金額を原文へ戻って照合します。
2026年8月14日の内部品質検証では、120文書・2,343行に対して次の結果でした。
| 指標 | 実測 |
|---|---|
| 桁誤り率 | 0.000% |
| 列取り違え率 | 0.043% |
| 数量の非接地率 | 0.341% |
ここでいう「非接地」は、抽出された数量が原文の逐語的な根拠に結び付いていない状態です。
私たちが欲しいのは、AIが賢そうに補った数量ではありません。原本のどこに書かれていたかを確認できる数量です。
6. 工種分類は、最長キーワード一致でも壊れる
BOQの項目を防水、塗装、足場、電気、管などへ分類すると、落札後に必要な専門工事が見えてきます。
当初は、長いキーワードを優先すれば十分だと考えました。
ところが、実データでは次のような誤りが起きます。
- 「LEDシーリングライト」が、シーリング工事として防水に入る
- 「アスファルトシート防水」が、アスファルト舗装に入る
- 「止水」という文字だけで、防水工事と断定される
単語が一致しても、意味は一致しません。
そこで、文字列だけでなく、周辺語、部材、施工動詞、上位の章、同じ行の単位を使って判定するようにしました。
2026年8月14日には、161,301件の項目名を全数比較し、1,056行を是正しました。変更された分類336件は正しい方向、悪化0件で、クローラ側のテストは2,059件が通過しました。
この作業から得たのは、「例を一つ直す」のではなく「全母集団で逆方向の悪化も測る」という習慣です。
7. 会社名はIDではない
落札結果には、会社名が文字列で載っています。
例えば、株式会社の有無、全角半角、空白、旧字体、支店名などが混ざります。さらに、まったく同じ商号の別会社も存在します。
Newsatsuでは、会社を結び付ける証拠を次の順で扱っています。
法人番号
↓ 無ければ
建設業許可番号
↓ 無ければ
正規化した会社名 + 本店都道府県
一度、「全国で同名が一社だけなら結合してよい」という近道を採用し、宮崎県の落札355件を他県の同名会社へ誤って結び付けました。この変更は全件を巻き戻し、本店都道府県を必須のアンカーにしました。
許可番号にも注意が必要です。知事許可の番号は都道府県ごとに採番されるため、番号だけを全国一意キーとして使えません。
検索では曖昧一致が便利です。しかし、履歴を確定させる名寄せでは、曖昧一致が事故になります。
この二つは、同じ「会社を探す」機能でも分けて設計しています。
8. 公告を集めるだけでは、現場の次の仕事は見えない
入札公告は「これから元請を決める情報」です。
一方、専門工事会社にとって重要なのは、落札後です。
公告
↓
元請が入札
↓
落札
↓
施工準備
↓
防水・塗装・足場・電気・管などの専門工事が動く
そこで、Newsatsuの営業レーダーは次をつなぎます。
- どの会社が落札したか
- 現場はどこか
- 自社から何kmか
- 設計書にどの工事項目があるか
- 自社の専門工事がどれくらい含まれるか
- 落札から何日たったか
例えば、案件名が「橋梁補修工事」だけでは、防水会社に関係があるか分かりません。内訳書に橋面防水2,426.9平方メートルがあり、落札会社と現場距離が分かれば、具体的な営業候補になります。
公開中の一例は橋梁補修工事の案件ページで確認できます。
なお、Newsatsuが扱うのは公開された法人・工事情報です。個人の連絡先収集や、自動の大量送信を目的にはしていません。
9. 推薦には「落札」だけでなく「応札」を使う
会社へ合う案件を推薦するとき、落札実績だけを見ると情報が偏ります。
落札は結果です。応札には「この会社が取りに行った」という意思が残っています。
そのため、推薦では次を組み合わせます。
- 過去に落札した工種
- 過去に応札した工種
- 得意な金額帯
- 工事と業務委託の比率
- 本店・営業所と現場の地域
- 参加資格の確認結果
実装上のポイントは、最初から候補を絞りすぎないことでした。
以前はSQLで上位12件だけを取り、その12件を後段で並べ替えていました。これでは、本当に合う13件目以降を救えません。
現在は候補を60件まで広げてから、履歴との適合度で再順位付けします。欠損値は原則として中立に扱い、「履歴がない」だけで新しい分野を排除しません。
無作為20社で変更前後を比べた検証では、候補が0件になった会社は0、上位5件の規模ずれが悪化した会社は0、業務委託の不要な混入は25件から1件へ減りました。
推薦は、強く絞るほど賢く見えます。しかし、未経験とデータ不足を混同すると、本物の機会を消します。
10. 類似案件検索は、毎回計算しない
案件名や工事概要をbge-m3でベクトル化し、類似案件を探しています。
最初は、リクエストごとにHNSW検索を実行していました。単発では1〜2秒でも、約25万案件を更新すると約82時間かかります。
試してみると、全件をNumPy / BLASでまとめて計算した方が約17分で終わりました。
そこで、リアルタイム検索をやめ、週次で上位候補を事前計算してPostgreSQLへ保存しました。
| 方式 | 更新 | 読み出し |
|---|---|---|
| 旧:案件ごとにHNSW検索 | 全体で約82時間 | 1件あたり約1.7秒 |
| 新:全件を一括計算して保存 | 約17分 | DB内の2クエリ合計約1.4ms |
「高性能な検索アルゴリズムを使う」より、「いつ計算するかを変える」方が効いた例です。
11. 2台のDGX Sparkで、AIの役割を分ける
2026年8月上旬時点で、AI呼び出しの台帳は53万件を超えていました。
2026年7月31日の実測では、生成・抽出処理は外部の生成AI APIではなく、2台のDGX Spark上のOllamaで動かしていました。
モデルは一つに統一していません。
| 役割 | 主なモデル |
|---|---|
| 公告・BOQの抽出、工種分類 | gpt-oss 20B |
| 案件要約、会社説明 | Gemma 12B |
| 類似検索、名寄せ候補 | bge-m3 |
| 画像文書の補助処理 | Qwen-VL |
大きいモデルを何でも使う構成にもしていません。120Bクラスは遅く、直近7日の実運用では8回だけでした。大量処理では、20B前後のモデルに根拠検証とルールを組み合わせた方が全体の処理量を確保できます。
2台の役割も分けています。
本番ノード
├─ PostgreSQL
├─ API
├─ Web
├─ 収集ジョブ
└─ 低遅延の埋め込み
計算ノード
├─ 案件要約
├─ 会社説明
├─ 文書抽出
└─ 重いバッチ
ローカル化の目的は「AIを所有しているように見せること」ではありません。
- 公開文書を大量に処理しても従量課金が増えない
- モデルの版を固定しやすい
- 同じ原本で再検証しやすい
- 外部サービスの障害からバッチを切り離せる
一方で、モデル更新、GPU負荷、キュー、失敗再実行まで自分たちで持つ必要があります。API料金がゼロでも、運用コストがゼロになるわけではありません。
12. Cloudflareは「全部を速くする層」ではない
Web、API、モバイルの全通信を同じ方法でエッジ化しているわけではありません。
Newsatsuでは、一部のWeb詳細ページだけをCloudflare Workers、D1、R2で肩代わりします。
PostgreSQLでデータ更新
↓
ページの版を記録
↓
Next.jsがHTMLを生成
↓
R2へ保存、D1へ参照情報を保存
↓
Workerが版を確認
├─ 信頼できる → R2のHTMLを返す
└─ 不一致・未生成 → オリジンへ通す
重要なのは、エッジが壊れても古いページを自信満々に返さないことです。版が不明なら、オリジンへフォールバックします。
2026年8月14日の24時間計測では、Workerが受けた30,149リクエストのうち、3,191件、10.6%の描画を肩代わりし、Workerエラーは0件でした。
この施策の目的はTTFBの数字を良く見せることではなく、本番のNext.jsとPostgreSQLが長い裾野の詳細ページを毎回描画しなくて済むようにすることです。
iPhoneとAndroidのAPI経路はこの詳細ページ用エッジとは別です。「Cloudflareを使っている」だけで、全チャネルが同じ経路だと思わないようにしています。
13. 39体のAIエージェントに書かせても、そのまま本番には入らない
対応地域を増やすため、過去に20体の生成役と19体の検証役、合計39体のAIエージェントで自治体パーサ候補を並列作成しました。
約6時間で21件の候補ができました。
しかし、独立検証レポートがそろった19件のうち、確認済みは6件、一部に誤りがあったものは13件でした。
20体の生成役による提出物も、そのまま構文が通るもの15件に対し、先頭へ異物が混入したもの4件、中身がプレースホルダだけのもの1件がありました。
速度は圧倒的です。ただし、速く作られたコードは、速くレビューすべきコードでもあります。
現在は、AIエージェントの成果物を「高速なプルリクエスト」として扱っています。
AIが候補を作る
↓
別のAIが独立検証する
↓
実サイトの原本で再現する
↓
既存県への退行を測る
↓
人が公開可否を決める
生成役と検証役を分けても、両方が同じ思い込みをすることがあります。最後の根拠は、実サイトと保存した原本です。
14. 「終了コード0」を成功と呼ばない
データ収集で怖いのは、派手に落ちる障害より、正常終了したまま何も取れていない障害です。
実際に、ある県の検索フォームで存在しない日付フィールド名を指定し、約17,000行を黙って取りこぼしていました。ログ上は成功、終了コードも0でした。
発見のきっかけは、「検索期間を1年から12日に縮めても件数が変わらない」という不自然さでした。
そこで、ジョブの結果を少なくとも次に分けます。
| 状態 | 意味 |
|---|---|
| success | 取得・解析・保存まで成功 |
| empty_confirmed | 原本側にも本当に0件 |
| unmeasurable | 取得できたが完全性を測れない |
| failed | 通信、解析、保存のいずれかで失敗 |
加えて、前日比、機関別件数、最終取得時刻、欠損率、原本との整合、AI失敗率を見ます。
0件は、正常値かもしれません。障害かもしれません。0という数字だけでは判定できません。
15. テストは「正解するか」だけでなく「壊すと落ちるか」を見る
推薦や分類のテストは、期待値に一致するだけでは不十分でした。
例えば、金額帯の評価を実装しても、別の工種ペナルティだけでテスト結果が決まっていれば、金額ロジックを丸ごと消してもテストが通ります。
そこで、主要なルールを意図的に無効化する変異テストを使います。
- 工種の加点を消す
- 規模の減点を消す
- 応札履歴を無視する
- 再順位付けを無効にする
- 混在企業の例外を消す
変更後のコードが通るだけでなく、重要なロジックを壊した版が確実に落ちることを確認します。
分類辞書の変更でも、一件の正解だけではなく全母集団の差分を取り、「直した方向」と「新しく壊した方向」を別々に数えます。
16. 現時点で、できていないこと
技術記事なので、制約も明記します。
- 都道府県単位で本番収集しているのは10県です
- 国機関由来の全国案件もありますが、47都道府県の県・市町村を同じ深さで収集できているわけではありません
- 発注機関側で公開されない値は、Newsatsuにも存在しません
- OCRやLLMの結果は品質ゲートを通しますが、完全無欠ではありません
- 参加資格、価格予測、推薦は判断材料であり、公告原本に代わるものではありません
- ポータルの仕様変更や公開時刻の違いにより、反映まで時間がかかる場合があります
公開情報の取得方針、出典表示、更新時刻、免責はデータポリシーにまとめています。
サービス上でも、最終判断は必ず発注機関の公告・原本で確認してもらう設計です。
まとめ
Newsatsuを作る間、何度も次の二つを問い直しました。
この公開情報は、利用者の次の行動につながっているか。
このAIの出力を、そのまま利用者に見せてよいか。
今の役割分担は、こうなっています。
クローラ 公開情報を集める
原本ストア 後から検証できる状態を残す
OCR 読めない文書を読める形にする
LLM 文章から事実候補を取り出す
ルール 根拠を照合し、業務上の判定を行う
PostgreSQL 案件・会社・落札・工種の関係をつなぐ
Next.js/API 人が判断できる画面と機能にする
Cloudflare 一部のWeb描画負荷を安全に肩代わりする
人 公開可否と最終判断を担う
AIを中心に置いたのではなく、原本と検証の間にAIを置いた。
それが、約33万件の公共工事を「読める情報」から「動ける情報」へ変えるために、いま採用している設計です。
実際の画面はNewsatsuで公開しています。iPhone・Android版はアプリ案内から確認できます。
参考リンク
※この記事はNewsatsu開発者による自社サービスの技術記事です。記載した件数・構成・実測値は、各節に示した時点のものです。