Corin1998 commited on
Commit
bc41609
·
verified ·
1 Parent(s): eb687c0

Update core/ai_judgement.py

Browse files
Files changed (1) hide show
  1. 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() -> Optional[OpenAI]:
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
- if x in (None, "", "null"): return None
25
- return float(x)
 
 
 
26
  except Exception:
27
  return None
28
 
29
- def _pct(n: Optional[float]) -> str:
30
- return "—" if n is None else f"{n:.1f}%"
31
-
32
- def _num(n: Optional[float]) -> str:
33
- return "—" if n is None else f"{n:,.0f}"
34
-
35
- def _safe_div(a: Optional[float], b: Optional[float]) -> Optional[float]:
36
- if a is None or b is None or b == 0: return None
37
- return a / b
38
-
39
- def _extract_kpi(fin: Dict[str, Any]) -> Dict[str, Optional[float]]:
40
- bs = fin.get("balance_sheet") or {}
41
- is_ = fin.get("income_statement") or {}
42
- cf = fin.get("cash_flows") or {}
43
-
44
- total_assets = _to_f(bs.get("total_assets"))
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
- res = cli.chat.completions.create(
98
- model=OPENAI_MODEL_TEXT,
99
- messages=[
100
- {"role":"system","content":"出力は必ず有効な JSON。説明文やコードブロックを含めない。"},
101
- {"role":"user","content":prompt},
102
- ],
103
- response_format={"type":"json_object"},
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