Update core/ai_judgement.py
Browse files- core/ai_judgement.py +30 -203
core/ai_judgement.py
CHANGED
|
@@ -1,215 +1,42 @@
|
|
| 1 |
# core/ai_judgement.py
|
| 2 |
from __future__ import annotations
|
| 3 |
import os, json
|
| 4 |
-
from typing import Dict, Any, List, Optional
|
| 5 |
-
|
| 6 |
-
# OpenAI は任意(キー未設定でも動く)。使える環境なら市場/製品の補足を LLM に頼みます。
|
| 7 |
-
try:
|
| 8 |
-
from openai import OpenAI # requirements: openai>=1.33
|
| 9 |
-
except Exception: # ランタイム最小化のための保険
|
| 10 |
-
OpenAI = None # type: ignore
|
| 11 |
|
| 12 |
OPENAI_MODEL_TEXT = os.environ.get("OPENAI_TEXT_MODEL", "gpt-4o-mini")
|
| 13 |
|
| 14 |
-
def _client()
|
| 15 |
-
key = os.environ.get("OPENAI_API_KEY")
|
| 16 |
-
if not key or OpenAI is None:
|
| 17 |
-
return None
|
| 18 |
-
# 互換性のため proxies 等は渡さない
|
| 19 |
-
return OpenAI(api_key=key, timeout=30)
|
| 20 |
-
|
| 21 |
-
# ---------------- KPI 抽出(定量) ----------------
|
| 22 |
-
def _to_f(x) -> Optional[float]:
|
| 23 |
try:
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
| 26 |
except Exception:
|
| 27 |
return None
|
| 28 |
|
| 29 |
-
def
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
total_eq = _to_f(bs.get("total_equity"))
|
| 46 |
-
current_assets = _to_f(bs.get("current_assets"))
|
| 47 |
-
current_liab = _to_f(bs.get("current_liabilities"))
|
| 48 |
-
|
| 49 |
-
sales = _to_f(is_.get("sales"))
|
| 50 |
-
op_income = _to_f(is_.get("operating_income"))
|
| 51 |
-
net_income = _to_f(is_.get("net_income"))
|
| 52 |
-
cogs = _to_f(is_.get("cost_of_sales"))
|
| 53 |
-
gross_profit = _to_f(is_.get("gross_profit"))
|
| 54 |
-
op_exp = _to_f(is_.get("operating_expenses"))
|
| 55 |
-
|
| 56 |
-
op_cf = _to_f(cf.get("operating_cash_flow"))
|
| 57 |
-
|
| 58 |
-
equity_ratio = None if (total_assets is None or total_assets == 0) else (total_eq or 0)/total_assets*100.0
|
| 59 |
-
current_ratio = None if (current_liab is None or current_liab == 0) else (current_assets or 0)/current_liab*100.0
|
| 60 |
-
gp_margin = None if (sales is None or sales == 0 or gross_profit is None) else gross_profit/sales*100.0
|
| 61 |
-
op_margin = None if (sales is None or sales == 0 or op_income is None) else op_income/sales*100.0
|
| 62 |
-
net_margin = None if (sales is None or sales == 0 or net_income is None) else net_income/sales*100.0
|
| 63 |
-
op_cf_ratio = _safe_div(op_cf, sales)
|
| 64 |
-
return dict(
|
| 65 |
-
sales=sales, op_income=op_income, net_income=net_income,
|
| 66 |
-
equity_ratio=equity_ratio, current_ratio=current_ratio,
|
| 67 |
-
gp_margin=gp_margin, op_margin=op_margin, net_margin=net_margin,
|
| 68 |
-
op_cf_ratio=None if op_cf_ratio is None else op_cf_ratio*100.0,
|
| 69 |
-
)
|
| 70 |
-
|
| 71 |
-
# ---------------- LLM による市場/製品の補足(任意) ----------------
|
| 72 |
-
def _llm_market_product(company: str, business_text: str) -> Optional[Dict[str, Any]]:
|
| 73 |
-
"""
|
| 74 |
-
事業説明テキストから『市場CAGR・市場規模・製品の差別化度』を簡易推定(JSON)。
|
| 75 |
-
OpenAI キーが無い/失敗時は None を返す(UIはそのまま動く)。
|
| 76 |
-
"""
|
| 77 |
-
cli = _client()
|
| 78 |
-
if not cli or not business_text.strip():
|
| 79 |
-
return None
|
| 80 |
-
try:
|
| 81 |
-
prompt = f"""
|
| 82 |
-
あなたは投資アナリストです。以下の会社説明から、市場の定量情報と製品の定量評価を推定してください。
|
| 83 |
-
厳密な JSON オブジェクトのみを日本語の単位なし半角数値で返します。
|
| 84 |
-
|
| 85 |
-
出力 JSON 仕様:
|
| 86 |
-
{{
|
| 87 |
-
"market_cagr_pct": null, // 想定CAGR(%)
|
| 88 |
-
"market_size_next3y_jpy": null, // 3年後の市場規模(円)
|
| 89 |
-
"product_innovation_score": null, // 製品の革新性/差別化(0-10)
|
| 90 |
-
"signals": [] // 箇条書き根拠(日本語)
|
| 91 |
-
}}
|
| 92 |
-
|
| 93 |
-
[会社名] {company or '不明'}
|
| 94 |
-
[事業説明]
|
| 95 |
-
{business_text[:5000]}
|
| 96 |
"""
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
temperature=0.2,
|
| 105 |
-
)
|
| 106 |
-
data = json.loads(res.choices[0].message.content)
|
| 107 |
-
# 型のゆらぎを軽く補正
|
| 108 |
-
for k in ("market_cagr_pct","market_size_next3y_jpy","product_innovation_score"):
|
| 109 |
-
v = data.get(k, None)
|
| 110 |
-
try:
|
| 111 |
-
data[k] = None if v in (None,"", "null") else float(v)
|
| 112 |
-
except Exception:
|
| 113 |
-
data[k] = None
|
| 114 |
-
if not isinstance(data.get("signals", []), list):
|
| 115 |
-
data["signals"] = []
|
| 116 |
-
return data
|
| 117 |
-
except Exception:
|
| 118 |
-
return None
|
| 119 |
-
|
| 120 |
-
# ---------------- パブリック API ----------------
|
| 121 |
-
def make_ai_memo(
|
| 122 |
-
company: str,
|
| 123 |
-
fin: Dict[str, Any],
|
| 124 |
-
score_internal: Optional[Dict[str, Any]] = None, # 社内ルールのスコア(任意)
|
| 125 |
-
score_external: Optional[Dict[str, Any]] = None, # 外部(定量化)スコア(任意)
|
| 126 |
-
business_text: str = "" # 事業説明や製品説明(あると LLM が強化)
|
| 127 |
-
) -> str:
|
| 128 |
-
"""
|
| 129 |
-
中立・簡潔な AI 所見(Markdown)を返します。
|
| 130 |
-
- 財務からの **定量 KPI** を先頭に表で提示
|
| 131 |
-
- 良い点 / リスクをバランスよく列挙(定量しきい値で判断)
|
| 132 |
-
- 可能なら LLM で『市場CAGR・市場規模・製品の差別化度』を推定して補足
|
| 133 |
-
"""
|
| 134 |
-
kpi = _extract_kpi(fin)
|
| 135 |
-
|
| 136 |
-
# しきい値で素朴に判定(中立・再現性重視)
|
| 137 |
-
positives: List[str] = []
|
| 138 |
-
risks: List[str] = []
|
| 139 |
-
|
| 140 |
-
if (kpi["equity_ratio"] or 0) >= 40: positives.append(f"自己資本比率 {_pct(kpi['equity_ratio'])} と健全")
|
| 141 |
-
if (kpi["current_ratio"] or 0) >= 120: positives.append(f"流動比率 {_pct(kpi['current_ratio'])} と手元流動性に余裕")
|
| 142 |
-
if (kpi["op_margin"] or 0) >= 8: positives.append(f"営業利益率 {_pct(kpi['op_margin'])} と収益性まずまず")
|
| 143 |
-
if (kpi["op_cf_ratio"] or 0) >= 5: positives.append(f"営業CF/売上 {_pct(kpi['op_cf_ratio'])} とキャッシュ創出力あり")
|
| 144 |
-
|
| 145 |
-
if (kpi["equity_ratio"] or 100) < 15: risks.append(f"自己資本比率 {_pct(kpi['equity_ratio'])} と財務余力に懸念")
|
| 146 |
-
if (kpi["current_ratio"] or 999) < 100: risks.append(f"流動比率 {_pct(kpi['current_ratio'])} と短期支払能力に注意")
|
| 147 |
-
if (kpi["op_margin"] or 100) < 3: risks.append(f"営業利益率 {_pct(kpi['op_margin'])} と採算性に課題")
|
| 148 |
-
if (kpi["net_margin"] or 100) < 2: risks.append(f"純利益率 {_pct(kpi['net_margin'])} と底堅さに欠ける可能性")
|
| 149 |
-
|
| 150 |
-
# LLM による市場/製品の補足(任意)
|
| 151 |
-
mp = _llm_market_product(company, business_text) if business_text else None
|
| 152 |
-
if mp:
|
| 153 |
-
if mp.get("market_cagr_pct") is not None:
|
| 154 |
-
if float(mp["market_cagr_pct"]) >= 10:
|
| 155 |
-
positives.append(f"想定市場CAGR {mp['market_cagr_pct']:.1f}% と高成長")
|
| 156 |
-
elif float(mp["market_cagr_pct"]) <= 0:
|
| 157 |
-
risks.append(f"想定市場CAGR {mp['market_cagr_pct']:.1f}% と市場縮小の懸念")
|
| 158 |
-
if mp.get("product_innovation_score") is not None:
|
| 159 |
-
s = float(mp["product_innovation_score"])
|
| 160 |
-
if s >= 7.5:
|
| 161 |
-
positives.append(f"製品の差別化度(LLM推定){s:.1f}/10 と強み")
|
| 162 |
-
elif s <= 3.0:
|
| 163 |
-
risks.append(f"製品の差別化度(LLM推定){s:.1f}/10 と競争激化の恐れ")
|
| 164 |
-
|
| 165 |
-
# スコアの見出し(社内/外部で基準を分けていることを明示)
|
| 166 |
-
internal_line = ""
|
| 167 |
-
if score_internal:
|
| 168 |
-
internal_line = f"- 社内スコア: **{score_internal.get('total_score','—')} / 100**(グレード: {score_internal.get('grade','—')})\n"
|
| 169 |
-
external_line = ""
|
| 170 |
-
if score_external:
|
| 171 |
-
external_line = f"- 外部スコア: **{score_external.get('external_total','—')} / 100**(ディスクロージャー等の客観指標ベース)\n"
|
| 172 |
-
|
| 173 |
-
# Markdown 組み立て
|
| 174 |
-
md = []
|
| 175 |
-
md.append(f"### {company or '対象企業'} — AI所見(中立)")
|
| 176 |
-
md.append("#### 主要KPI(単位:% は百分率)")
|
| 177 |
-
md.append(
|
| 178 |
-
f"""
|
| 179 |
-
| KPI | 値 |
|
| 180 |
-
|---|---:|
|
| 181 |
-
| 売上高 | { _num(kpi['sales']) } |
|
| 182 |
-
| 営業利益 | { _num(kpi['op_income']) } |
|
| 183 |
-
| 純利益 | { _num(kpi['net_income']) } |
|
| 184 |
-
| 自己資本比率 | { _pct(kpi['equity_ratio']) } |
|
| 185 |
-
| 流動比率 | { _pct(kpi['current_ratio']) } |
|
| 186 |
-
| 売上総利益率 | { _pct(kpi['gp_margin']) } |
|
| 187 |
-
| 営業利益率 | { _pct(kpi['op_margin']) } |
|
| 188 |
-
| 純利益率 | { _pct(kpi['net_margin']) } |
|
| 189 |
-
| 営業CF/売上 | { _pct(kpi['op_cf_ratio']) } |
|
| 190 |
-
""".strip()
|
| 191 |
)
|
| 192 |
-
|
| 193 |
-
if internal_line or external_line:
|
| 194 |
-
md.append("#### スコア概況")
|
| 195 |
-
md.append(internal_line + external_line)
|
| 196 |
-
|
| 197 |
-
if positives:
|
| 198 |
-
md.append("#### プラス要因")
|
| 199 |
-
md.extend([f"- {p}" for p in positives])
|
| 200 |
-
if risks:
|
| 201 |
-
md.append("#### リスク/留意点")
|
| 202 |
-
md.extend([f"- {r}" for r in risks])
|
| 203 |
-
|
| 204 |
-
if mp:
|
| 205 |
-
md.append("#### 市場/製品の補足(LLM)")
|
| 206 |
-
line1 = f"- 想定市場CAGR: **{_pct(mp.get('market_cagr_pct'))}**"
|
| 207 |
-
line2 = f"- 3年後市場規模(推定): **{_num(mp.get('market_size_next3y_jpy'))} 円**"
|
| 208 |
-
line3 = f"- 製品の差別化度(0-10): **{mp.get('product_innovation_score') if mp.get('product_innovation_score') is not None else '—'}**"
|
| 209 |
-
md.append("\n".join([line1, line2, line3]))
|
| 210 |
-
if mp.get("signals"):
|
| 211 |
-
md.append("**根拠(要旨)**")
|
| 212 |
-
md.extend([f"- {s}" for s in mp["signals"]])
|
| 213 |
-
|
| 214 |
-
md.append("\n> 注記: 欠損値は算定不能のため **—** 表示。所見は公開情報/入力値に基づく中立的サマリーであり、投資勧誘を目的としません。")
|
| 215 |
-
return "\n\n".join(md)
|
|
|
|
| 1 |
# core/ai_judgement.py
|
| 2 |
from __future__ import annotations
|
| 3 |
import os, json
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
OPENAI_MODEL_TEXT = os.environ.get("OPENAI_TEXT_MODEL", "gpt-4o-mini")
|
| 6 |
|
| 7 |
+
def _client():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
try:
|
| 9 |
+
from openai import OpenAI
|
| 10 |
+
key = os.environ.get("OPENAI_API_KEY")
|
| 11 |
+
if not key:
|
| 12 |
+
return None
|
| 13 |
+
return OpenAI(api_key=key, timeout=30)
|
| 14 |
except Exception:
|
| 15 |
return None
|
| 16 |
|
| 17 |
+
def make_ai_memo(company: str, fin, score_internal, score_external, business_text: str) -> str:
|
| 18 |
+
client = _client()
|
| 19 |
+
if client is None:
|
| 20 |
+
return "(通知)OPENAI_API_KEY未設定のため、AI所見はスキップしました。"
|
| 21 |
+
|
| 22 |
+
prompt = f"""あなたは中立なアナリストです。過度な断定や主観を避け、可観測な数値指標を軸に簡潔に述べてください。
|
| 23 |
+
- 良い点(定量指標ベース)3個以内
|
| 24 |
+
- 懸念点(定量指標ベース)3個以内
|
| 25 |
+
- 市場/製品の補足(PDF本文から推定される事業の定量的観点があれば一言)
|
| 26 |
+
- 総評(80字以内、結論は仮説ベースと明記)
|
| 27 |
+
|
| 28 |
+
[会社候補] {company or '—'}
|
| 29 |
+
[財務(JSON)] {json.dumps(fin, ensure_ascii=False)}
|
| 30 |
+
[内部スコア] {json.dumps(score_internal, ensure_ascii=False)}
|
| 31 |
+
[外部評価] {json.dumps(score_external, ensure_ascii=False)}
|
| 32 |
+
[事業テキスト候補] {business_text[:1200]}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
"""
|
| 34 |
+
resp = client.chat.completions.create(
|
| 35 |
+
model=OPENAI_MODEL_TEXT,
|
| 36 |
+
messages=[
|
| 37 |
+
{"role": "system", "content": "出力は日本語。見出しは使わず、箇条書きと短い総評のみ。感情語や煽りは禁止。"},
|
| 38 |
+
{"role": "user", "content": prompt},
|
| 39 |
+
],
|
| 40 |
+
temperature=0.2,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
)
|
| 42 |
+
return resp.choices[0].message.content
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|