案件スコアリングの実装詳細 — automation-v2 解説 第2回
前回の記事で automation-v2 の全体構造を公開した。今回はその中核である「案件スコアリング」の実装を深掘りする。
案件スコアリングは一見シンプルなタスクに見えるが、LLM を使わずにルールベースで実装すると決めると、途端に設計判断が多くなる。本記事ではその全てを書く。
なぜLLMを使わなかったのか
最初はClaude APIに案件文面を投げて「この案件は応募すべきか」をYes/Noで返してもらう案を検討した。だが以下の理由で却下した。
- 毎日100件程度スコアリングすると API コストが無視できない規模になる
- Yes/Noが再現不能: 同じ案件でも呼び出しごとに判定がブレる
- なぜそう判定したか説明できない: デバッグ不能
代わりに、ルールベースで0.0〜1.0のスコアを返し、内訳を全て reasons 配列で返す設計にした。全ての判定が人間に説明可能であることを最優先にした。
スコア内訳(満点1.0)
5軸で加点し、2軸で減点する。
加点:
category_match 0.30 テンプレカテゴリにヒットしたか
budget_ok 0.25 予算下限10,000円を超えているか
skill_match 0.25 得意分野キーワードと一致
not_meeting 0.10 面談/Zoom系NGワードが不在
length_ok 0.10 タイトル10文字以上、本文50文字以上
─────────────
合計 1.00
減点:
suspicious -0.30 警戒ワード(MLM等)が存在
blacklist -1.00 ブラックリスト該当(即0点)
client_history -0.80〜+0.05 過去履歴に応じて加減算
軸1: カテゴリマッチ(0.30)
テンプレート側で定義した keywords が案件のタイトルか本文に含まれれば、そのカテゴリと判定する。
def detect_category(title, summary, templates_categories):
text = f"{title or ''} {summary or ''}".lower()
for cat_name, cat in templates_categories.items():
if cat_name == "default":
continue
for kw in cat.get("keywords", []) or []:
if kw.lower() in text:
return cat_name
return "default"
設計判断: 大文字小文字を区別しないが、日本語は正規化しない。「LP」と「lp」は同一視するが、「ランディング」と「らんでぃんぐ」は別扱いにした。理由は後者が実際のクライアント文面で混在しないため、正規化コストに見合わないからだ。
軸2: 予算抽出(0.25)
これが最も複雑な軸だ。クライアントは予算を様々な表記で書く。
- 「10,000円」
- 「1万円」
- 「¥10000」
- 「時給1000円〜」
- 「50,000〜100,000円」(レンジ表記)
- 「応相談」「予算未定」(数値なし)
最終的に2つの正規表現で拾うことにした。
_RE_YEN_PRICE = re.compile(r"([0-9,]+)\s*(?:円|¥|¥)")
_RE_MAN_PRICE = re.compile(r"([0-9]+)\s*万")
複数マッチしたら最大値を採用する。これは「10,000〜50,000円」のレンジ表記で上限を採用するためだ。理由は単純で、クライアントは上限値で魅力を訴求する傾向があり、下限はあくまで交渉スタート位置だからだ。
予算不明時の部分点
予算が抽出できなかった時に0点にすると、「予算未定です、相談して決めましょう」という良案件が全て下位に沈む。そこで満点の60%を部分点として付与している。
if budget_yen is None:
score += w_bud * 0.6
elif budget_yen >= 10000:
score += w_bud
else:
# 予算下限未満 → 0点
この「不明は部分点」設計は、情報不足を理由に良案件を切り捨てないという方針の表れだ。実運用で効く。
軸3: スキルマッチ(0.25)
プロファイルのスキルキーワードと、案件文面の重なりで計算する。
_PROFILE_SKILLS = [
"LP", "ランディング", "Webサイト", "ホームページ", "バナー",
"Instagram", "SNS", "リール", "ライティング", "記事",
"Canva", "Figma", "WordPress", "HTML", "CSS", "JavaScript",
"AI", "ChatGPT", "Claude", "Notion",
]
def skill_match_score(title, summary):
hits = sum(1 for skill in _PROFILE_SKILLS
if skill.lower() in f"{title} {summary}".lower())
return min(hits / 3.0, 1.0) # 3ヒットで満点
設計判断: 3ヒットで満点にしている。全スキル一致を満点にすると、ほとんどの案件が低スコアになってしまう。「主要スキルが3つ言及されれば十分」という実運用感覚に合わせた。
軸4: 面談検出(0.10)
ユーザーは面談NGを運用ルールとしている。文面に面談系ワードがあれば減点する。
_MEETING_KEYWORDS = ["面談", "通話", "ミーティング", "Zoom", "zoom",
"打ち合わせ", "打合せ", "対面"]
この軸は配点が小さい(0.10)が、ほぼ確実に効く。面談必須案件は経験上ほぼ100%で「まずはZoomで打ち合わせを」と書いてある。
ハマりどころ: 「面談不要」の誤検出
最初の実装では「面談不要です」と書いた案件も減点してしまっていた。文字列マッチの限界だ。解決策として、「面談」の前後5文字を見て「不要」「不問」「なし」が含まれたらスキップするロジックを後から追加した。
# 簡略形
if "面談" in text:
idx = text.index("面談")
surrounding = text[max(0, idx-5):idx+10]
if "不要" in surrounding or "なし" in surrounding or "不問" in surrounding:
return False # 検出せず
この手の「文脈依存の判定」はLLMの方が得意だが、コスト対効果で不採用とした。代わりに運用しながらルールを追加している。
軸5: 長さチェック(0.10)
タイトル10文字未満・本文50文字未満の案件は情報が薄すぎてテンプレ埋め込みができない。
実運用で「案件タイトル: LP制作」「本文: よろしくお願いします」だけの案件が月数件出現する。これらは応募しても埋まらない確率が極めて高いので機械的に低スコア化する。
減点軸: 疑似案件(-0.30)
明らかに「応募したら事故る」キーワードを踏んだら減点する。
suspicious_keywords:
- MLM
- 仮想通貨
- マルチレベル
- 情報商材
- 初期費用
- 教材購入
設計判断: 即0点ではなく-0.30にしている。「仮想通貨関連のLP制作」は正当な案件でも出現するため、即排除すると良案件を逃す。減点にしておけば、他の軸が全て満点でスコア0.70前後まで下がる程度で、ドラフト生成しきい値(0.60)は越える可能性を残す。
減点軸: ブラックリスト(-1.00)
クライアント名が完全一致でブラックリストに入っていれば、即0点返却。
if client in (config.get("blacklist_clients") or []):
return {"score": 0.0, "auto_send_eligible": False, ...}
これは「減点」というより「即決」の扱いにしている。他の軸を計算するCPUコストすら無駄だからだ。
クライアント履歴補正(-0.80〜+0.05)
前回も軽く触れた client_history.py がこの軸を計算する。
- 過去2回連続で辞退されたクライアント →
-0.80 - 過去1回辞退されたクライアント →
-0.30 - 過去に好反応(返信あり・納品完了)をもらったクライアント →
+0.05
なぜ加点は小さいのか
好反応の加点を +0.05 と控えめにしているのは、「過去に反応あり」が必ずしも「今回も受注できる」を意味しないからだ。強く加点すると、過去クライアントからの低品質案件にも応募してしまう。あくまで「同条件の他案件よりちょっと優先する」程度で十分。
最終判定ロジック
全軸を足し引きして、最後にしきい値で分岐する。
score = max(0.0, min(1.0, score)) # 0〜1にクランプ
return {
"score": round(score, 3),
"category": category,
"reasons": reasons,
"auto_send_eligible": score >= auto_send_threshold, # 0.85
"draft_eligible": score >= draft_threshold, # 0.60
}
reasons 配列に全ての加点・減点理由を文字列で格納しておく。これにより、後からJSONを見返した時に「なぜこの案件が0.75だったのか」が完全に再現できる。
実運用で効いた設計
運用して2ヶ月で分かったことを書いておく。
- 最も効くのは budget_ok と category_match の2軸: これだけで案件の8割は妥当なスコアになる
- skill_match は過剰評価の温床: 「LP」「AI」みたいな汎用語が混入すると、関係ない案件まで高スコアになる。語彙選定が重要
- length_ok は低配点で十分: 長い案件が良案件とは限らない。むしろ要件が煩雑な長文は避けたい
- client_history は運用開始から3ヶ月目に効き始める: 履歴データが溜まるまでは機能しない
LLMを使うべき場所
ルールベースで限界を感じた場所が2つあった。
- 文脈依存の否定表現(「面談不要」の誤検出のような)
- カテゴリのグラデーション(「LP的なWebサイト」はLPかWebか)
この2箇所は将来的にLLMで補助することを検討している。ただしコスト回避のため、ルールベースで灰色判定になった案件のみLLMに投げる、というハイブリッド構成にする予定だ。
次回予告
第3回では inbox_watcher.py と inbox_reply_drafter.py の話を書く。返信検知の仕組みと、穴埋めテンプレがうまく埋まらなかった事例、そして「送信可能な返信」と「人間が直すべき返信」をどう機械的に判定するかを公開する。
このシリーズでは automation-v2 の実装を順に解説している。前回の「全体構造編」と合わせて読むと、スコアがパイプラインのどこで使われるかが把握しやすい。
Agentive 編集部
AIエージェントを実際に使い倒す個人開発者。サイト制作の自動化を実践しながら、その知見を発信しています。