Corin1998 commited on
Commit
ea4dc31
·
verified ·
1 Parent(s): ef5275e

Update ui/ui_app.py

Browse files
Files changed (1) hide show
  1. ui/ui_app.py +135 -56
ui/ui_app.py CHANGED
@@ -1,18 +1,82 @@
1
  from __future__ import annotations
2
- import os, io, base64, json, traceback
3
  from typing import List, Dict, Any
4
 
 
 
 
5
  import gradio as gr
6
  import pandas as pd
 
 
 
7
  import plotly.graph_objects as go
8
 
9
- from core.ai_client import get_client, VISION_MODEL, TEXT_MODEL
10
- from core.pdf_utils import has_poppler, pdf_to_images, pdf_to_text
11
- from core.scorer import score_company
 
 
 
 
12
 
13
- # Gradio の解析情報や分析の送信を無効化(念のため)
14
- os.environ["GRADIO_ANALYTICS_ENABLED"] = "False"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
 
16
  SYSTEM_JSON = """あなたは有能な財務アナリストです。
17
  与えられた決算書(画像またはテキスト)から、次の厳密な JSON 構造のみを日本語の単位なし・半角数値で返してください。分からない項目は null。
18
  {
@@ -38,7 +102,7 @@ def _b64(img: bytes) -> str:
38
  return base64.b64encode(img).decode("utf-8")
39
 
40
  def extract_financials(images: List[bytes] | None, text_blob: str | None, company_hint: str) -> Dict[str, Any]:
41
- client = get_client()
42
  if images:
43
  content = [{"type": "text", "text": SYSTEM_JSON}]
44
  if company_hint:
@@ -46,9 +110,9 @@ def extract_financials(images: List[bytes] | None, text_blob: str | None, compan
46
  for im in images:
47
  content.append({"type": "input_image", "image_url": f"data:image/png;base64,{_b64(im)}"})
48
  resp = client.chat.completions.create(
49
- model=VISION_MODEL,
50
  messages=[
51
- {"role": "system", "content": "返答は必ず有効な JSON オブジェクトのみ。説明を含めない。"},
52
  {"role": "user", "content": content},
53
  ],
54
  response_format={"type": "json_object"},
@@ -58,7 +122,7 @@ def extract_financials(images: List[bytes] | None, text_blob: str | None, compan
58
  else:
59
  prompt = f"{SYSTEM_JSON}\n\n以下は決算書のテキストです。上記の JSON だけを返してくださ��。\n\n{text_blob or ''}"
60
  resp = client.chat.completions.create(
61
- model=TEXT_MODEL,
62
  messages=[
63
  {"role": "system", "content": "返答は必ず有効な JSON オブジェクトのみ。"},
64
  {"role": "user", "content": prompt},
@@ -68,6 +132,33 @@ def extract_financials(images: List[bytes] | None, text_blob: str | None, compan
68
  )
69
  return json.loads(resp.choices[0].message.content)
70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  def fin_to_df(fin: Dict[str, Any]) -> pd.DataFrame:
72
  rows = []
73
  def add(cat, d):
@@ -90,6 +181,7 @@ def df_to_fin(df: pd.DataFrame) -> Dict[str, Any]:
90
  out[cat][item] = parsed
91
  return out
92
 
 
93
  def radar(score: Dict[str, Any]) -> go.Figure:
94
  labels = [d["metric"] for d in score["details"]]
95
  values = [d["score"] for d in score["details"]]
@@ -98,25 +190,21 @@ def radar(score: Dict[str, Any]) -> go.Figure:
98
  fig.update_layout(
99
  polar=dict(radialaxis=dict(visible=True, range=[0, 100])),
100
  showlegend=False, margin=dict(l=20, r=20, t=30, b=20), height=380,
101
- title=f"総合スコア: {score['total_score']}(グレード: {score['grade']})"
102
  )
103
  return fig
104
 
 
105
  def run_analyze(company: str, use_vision: bool, files: list[str]):
106
  if not files:
107
  raise gr.Error("PDF をアップロードしてください。")
108
 
109
- # 画像→Vision、失敗したらテキスト→Text にフォールバック(例外は UI に吸収)
110
  try:
111
- if use_vision:
112
- if not has_poppler():
113
- raise RuntimeError("Poppler が見つかりません(pdf2image が使えません)")
114
- all_images: List[bytes] = []
115
- for p in files:
116
- all_images += pdf_to_images(p, dpi=220, max_pages=6)
117
- fin = extract_financials(all_images, None, company or "")
118
- else:
119
- raise RuntimeError("Vision 無効")
120
  except Exception:
121
  text_blob = ""
122
  for p in files:
@@ -127,9 +215,9 @@ def run_analyze(company: str, use_vision: bool, files: list[str]):
127
  score = score_company(fin)
128
  fig = radar(score)
129
 
130
- # AI所見(失敗しても UI は返す
131
  try:
132
- client = get_client()
133
  prompt = f"""次の財務データとスコア結果から、箇条書きで短く日本語でコメントしてください。
134
  - 良い点 3つ
135
  - 懸念点 3つ
@@ -142,7 +230,7 @@ def run_analyze(company: str, use_vision: bool, files: list[str]):
142
  {json.dumps(score, ensure_ascii=False)}
143
  """
144
  resp = client.chat.completions.create(
145
- model=TEXT_MODEL,
146
  messages=[{"role": "system", "content": "簡潔で公正な財務アナリスト。"},
147
  {"role": "user", "content": prompt}],
148
  temperature=0.3,
@@ -156,7 +244,7 @@ def run_analyze(company: str, use_vision: bool, files: list[str]):
156
  df,
157
  json.dumps(score, ensure_ascii=False, indent=2),
158
  fig,
159
- insight
160
  )
161
 
162
  def run_recalc(df: pd.DataFrame):
@@ -167,36 +255,40 @@ def run_recalc(df: pd.DataFrame):
167
  return (
168
  json.dumps(score, ensure_ascii=False, indent=2),
169
  fig,
170
- json.dumps(fin, ensure_ascii=False, indent=2)
171
  )
172
  except Exception as e:
173
  tb = traceback.format_exc(limit=6)
174
  raise gr.Error(f"再計算に失敗しました: {e}\n\n<pre style='white-space:pre-wrap'>{tb}</pre>")
175
 
176
- def create_demo():
177
- with gr.Blocks(theme=gr.themes.Soft(primary_hue="indigo"),
178
- fill_height=True, analytics_enabled=False) as demo:
179
- gr.Markdown("## 🧮 企業スコアリング(PDF解析 × OpenAI)")
180
  with gr.Row():
181
  with gr.Column(scale=1):
182
  company = gr.Textbox(label="企業名(任意)", placeholder="例:株式会社OO")
183
  use_vision = gr.Checkbox(value=True, label="OpenAIでPDFをAI解析(Vision)")
184
  files = gr.File(label="決算書PDF(複数可)", file_count="multiple", type="filepath")
185
- run_btn = gr.Button("📄 解析する", variant="primary")
186
- recalc_btn = gr.Button("🔁 表の値で再計算")
 
 
 
187
 
188
  with gr.Column(scale=1):
189
  fin_json = gr.Code(label="抽出JSON", language="json", interactive=False)
190
 
191
  with gr.Tabs():
192
  with gr.Tab("抽出結果(表で編集可)"):
193
- # スキーマ明示て API 情報生成で落ちないようにする
194
  df_out = gr.Dataframe(
195
  headers=["category", "item", "value"],
196
  datatype=["str", "str", "number"],
197
  col_count=(3, "fixed"),
198
  row_count=(1, "dynamic"),
199
- interactive=True
 
200
  )
201
  with gr.Tab("スコアリング"):
202
  score_json = gr.Code(label="スコア(JSON)", language="json")
@@ -204,29 +296,16 @@ def create_demo():
204
  with gr.Tab("AI診断(日本語)"):
205
  insight_md = gr.Markdown()
206
 
207
- run_btn.click(
208
- run_analyze,
209
- inputs=[company, use_vision, files],
210
- outputs=[fin_json, df_out, score_json, chart, insight_md],
211
- concurrency_limit=2
212
- )
213
- recalc_btn.click(
214
- run_recalc,
215
- inputs=[df_out],
216
- outputs=[score_json, chart, fin_json],
217
- concurrency_limit=2
218
- )
219
- return demo
220
 
221
- # Spaces/コンテナ両対応
222
- demo = create_demo()
223
-
224
- def main():
225
- # Spaces などローカル疎通ができない環境では share=True が必須
226
- share_default = os.getenv("GRADIO_SHARE", "1") # "1"=True / "0"=False
227
- share = (share_default != "0")
228
- port = int(os.getenv("PORT", "7860"))
229
- demo.launch(server_name="0.0.0.0", server_port=port, share=share, max_threads=8)
230
 
231
  if __name__ == "__main__":
232
  main()
 
1
  from __future__ import annotations
2
+ import os, io, json, base64, traceback, shutil
3
  from typing import List, Dict, Any
4
 
5
+ # ---- 起動安定化 ----
6
+ os.environ["GRADIO_ANALYTICS_ENABLED"] = "False"
7
+
8
  import gradio as gr
9
  import pandas as pd
10
+ from pdf2image import convert_from_path
11
+ import pdfplumber
12
+ from openai import OpenAI
13
  import plotly.graph_objects as go
14
 
15
+ # ====== 🔧 Gradio API 情報ページの安全パッチ ======
16
+ # 4.44.1 + gradio-client 1.3.0 でも、一部構成で API 情報生成時に
17
+ # additionalProperties bool になり例外が出ることがある。
18
+ # 例外が出たら空の API 情報を返し、UI は正常起動させる。
19
+ try:
20
+ import gradio.blocks as _grb
21
+ _orig_get_api_info = _grb.Blocks.get_api_info
22
 
23
+ def _safe_get_api_info(self, *a, **kw):
24
+ try:
25
+ return _orig_get_api_info(self, *a, **kw)
26
+ except Exception:
27
+ return {"named_endpoints": {}, "unnamed_endpoints": []}
28
+
29
+ _grb.Blocks.get_api_info = _safe_get_api_info # type: ignore[attr-defined]
30
+ except Exception:
31
+ pass
32
+ # ===============================================
33
+
34
+ # ---- モデル設定(環境変数で上書き可)----
35
+ OPENAI_MODEL_VISION = os.environ.get("OPENAI_VISION_MODEL", "gpt-4o-mini")
36
+ OPENAI_MODEL_TEXT = os.environ.get("OPENAI_TEXT_MODEL", "gpt-4o-mini")
37
+
38
+ # ---- OpenAI クライアント ----
39
+ def _client() -> OpenAI:
40
+ key = os.environ.get("OPENAI_API_KEY")
41
+ if not key:
42
+ raise gr.Error("OPENAI_API_KEY が未設定です。Spaces > Settings > Variables and secrets に設定してください。")
43
+ # proxies を渡さない(古い httpx 互換問題の回避)
44
+ return OpenAI(api_key=key, timeout=30)
45
+
46
+ # ---- ヘルスチェック ----
47
+ def health() -> str:
48
+ msgs = []
49
+ msgs.append("✅ OPENAI_API_KEY: " + ("検出" if os.environ.get("OPENAI_API_KEY") else "未設定"))
50
+ for b in ("pdftoppm", "pdftocairo"):
51
+ ok = bool(shutil.which(b))
52
+ msgs.append(("✅" if ok else "❌") + f" {b}: " + ("検出" if ok else "未検出(packages.txt に poppler-utils が必要)"))
53
+ msgs.append(f"ℹ️ Vision={OPENAI_MODEL_VISION} / Text={OPENAI_MODEL_TEXT}")
54
+ return "<br>".join(msgs)
55
+
56
+ # ---- PDF -> 画像/テキスト ----
57
+ def pdf_to_images(pdf_path: str, dpi: int = 220, max_pages: int = 6) -> List[bytes]:
58
+ pages = convert_from_path(pdf_path, dpi=dpi, fmt="png")
59
+ out: List[bytes] = []
60
+ for i, p in enumerate(pages):
61
+ if i >= max_pages:
62
+ break
63
+ buf = io.BytesIO()
64
+ p.save(buf, format="PNG")
65
+ out.append(buf.getvalue())
66
+ return out
67
+
68
+ def pdf_to_text(pdf_path: str, max_chars: int = 15000) -> str:
69
+ chunks: List[str] = []
70
+ with pdfplumber.open(pdf_path) as pdf:
71
+ for i, page in enumerate(pdf.pages):
72
+ t = (page.extract_text() or "").strip()
73
+ if t:
74
+ chunks.append(f"[page {i+1}]\n{t}")
75
+ if sum(len(c) for c in chunks) > max_chars:
76
+ break
77
+ return "\n\n".join(chunks)[:max_chars]
78
 
79
+ # ---- Vision / Text 抽出 ----
80
  SYSTEM_JSON = """あなたは有能な財務アナリストです。
81
  与えられた決算書(画像またはテキスト)から、次の厳密な JSON 構造のみを日本語の単位なし・半角数値で返してください。分からない項目は null。
82
  {
 
102
  return base64.b64encode(img).decode("utf-8")
103
 
104
  def extract_financials(images: List[bytes] | None, text_blob: str | None, company_hint: str) -> Dict[str, Any]:
105
+ client = _client()
106
  if images:
107
  content = [{"type": "text", "text": SYSTEM_JSON}]
108
  if company_hint:
 
110
  for im in images:
111
  content.append({"type": "input_image", "image_url": f"data:image/png;base64,{_b64(im)}"})
112
  resp = client.chat.completions.create(
113
+ model=OPENAI_MODEL_VISION,
114
  messages=[
115
+ {"role": "system", "content": "返答は必ず有効な JSON オブジェクトのみ。説明文は不要。"},
116
  {"role": "user", "content": content},
117
  ],
118
  response_format={"type": "json_object"},
 
122
  else:
123
  prompt = f"{SYSTEM_JSON}\n\n以下は決算書のテキストです。上記の JSON だけを返してくださ��。\n\n{text_blob or ''}"
124
  resp = client.chat.completions.create(
125
+ model=OPENAI_MODEL_TEXT,
126
  messages=[
127
  {"role": "system", "content": "返答は必ず有効な JSON オブジェクトのみ。"},
128
  {"role": "user", "content": prompt},
 
132
  )
133
  return json.loads(resp.choices[0].message.content)
134
 
135
+ # ---- スコアリング(ダミー実装 or 既存 scorer.py を呼び出し)----
136
+ def score_company(fin: Dict[str, Any]) -> Dict[str, Any]:
137
+ # もし既存の scorer.py があるなら import して差し替えてください。
138
+ # ここでは安定動作用の簡易版を同梱します。
139
+ def g(v: Any, div: float = 1.0):
140
+ try:
141
+ return float(v) / div
142
+ except Exception:
143
+ return 0.0
144
+
145
+ is_ = fin.get("income_statement") or {}
146
+ bs_ = fin.get("balance_sheet") or {}
147
+ margin = g(is_.get("operating_income")) / (g(is_.get("sales")) + 1e-9) * 100
148
+ equity_ratio = g(bs_.get("total_equity")) / (g(bs_.get("total_assets")) + 1e-9) * 100
149
+
150
+ details = [
151
+ {"metric": "売上規模", "score": min(100, g(is_.get("sales")) ** 0.5)},
152
+ {"metric": "営業利益率", "score": max(0, min(100, margin + 50))},
153
+ {"metric": "自己資本比率", "score": max(0, min(100, equity_ratio))},
154
+ {"metric": "利益水準", "score": min(100, max(0, g(is_.get("operating_income")) ** 0.5 + 50))},
155
+ {"metric": "安全性", "score": max(0, min(100, 50 + equity_ratio / 2))},
156
+ ]
157
+ total = int(sum(d["score"] for d in details) / len(details))
158
+ grade = "S" if total >= 85 else "A" if total >= 70 else "B" if total >= 55 else "C"
159
+ return {"total_score": total, "grade": grade, "details": details}
160
+
161
+ # ---- DF 変換 ----
162
  def fin_to_df(fin: Dict[str, Any]) -> pd.DataFrame:
163
  rows = []
164
  def add(cat, d):
 
181
  out[cat][item] = parsed
182
  return out
183
 
184
+ # ---- 可視化 ----
185
  def radar(score: Dict[str, Any]) -> go.Figure:
186
  labels = [d["metric"] for d in score["details"]]
187
  values = [d["score"] for d in score["details"]]
 
190
  fig.update_layout(
191
  polar=dict(radialaxis=dict(visible=True, range=[0, 100])),
192
  showlegend=False, margin=dict(l=20, r=20, t=30, b=20), height=380,
193
+ title=f"総合スコア: {score['total_score']}(グレード: {score['grade']})",
194
  )
195
  return fig
196
 
197
+ # ---- ハンドラ ----
198
  def run_analyze(company: str, use_vision: bool, files: list[str]):
199
  if not files:
200
  raise gr.Error("PDF をアップロードしてください。")
201
 
202
+ # 1) Vision(画像化) 2) テキスト抽出 の順にフォールバック
203
  try:
204
+ all_images: List[bytes] = []
205
+ for p in files:
206
+ all_images += pdf_to_images(p, dpi=220, max_pages=6)
207
+ fin = extract_financials(all_images if use_vision else None, None, company or "")
 
 
 
 
 
208
  except Exception:
209
  text_blob = ""
210
  for p in files:
 
215
  score = score_company(fin)
216
  fig = radar(score)
217
 
218
+ # AI 所見(短め
219
  try:
220
+ client = _client()
221
  prompt = f"""次の財務データとスコア結果から、箇条書きで短く日本語でコメントしてください。
222
  - 良い点 3つ
223
  - 懸念点 3つ
 
230
  {json.dumps(score, ensure_ascii=False)}
231
  """
232
  resp = client.chat.completions.create(
233
+ model=OPENAI_MODEL_TEXT,
234
  messages=[{"role": "system", "content": "簡潔で公正な財務アナリスト。"},
235
  {"role": "user", "content": prompt}],
236
  temperature=0.3,
 
244
  df,
245
  json.dumps(score, ensure_ascii=False, indent=2),
246
  fig,
247
+ insight,
248
  )
249
 
250
  def run_recalc(df: pd.DataFrame):
 
255
  return (
256
  json.dumps(score, ensure_ascii=False, indent=2),
257
  fig,
258
+ json.dumps(fin, ensure_ascii=False, indent=2),
259
  )
260
  except Exception as e:
261
  tb = traceback.format_exc(limit=6)
262
  raise gr.Error(f"再計算に失敗しました: {e}\n\n<pre style='white-space:pre-wrap'>{tb}</pre>")
263
 
264
+ # ---- UI ----
265
+ def main():
266
+ with gr.Blocks(theme=gr.themes.Soft(primary_hue="indigo"), fill_height=True, analytics_enabled=False) as demo:
267
+ gr.Markdown("## 🧮 企業スコアリング(PDF解析 × OpenAI Vision)")
268
  with gr.Row():
269
  with gr.Column(scale=1):
270
  company = gr.Textbox(label="企業名(任意)", placeholder="例:株式会社OO")
271
  use_vision = gr.Checkbox(value=True, label="OpenAIでPDFをAI解析(Vision)")
272
  files = gr.File(label="決算書PDF(複数可)", file_count="multiple", type="filepath")
273
+ run_btn = gr.Button("📄 PDFを解析してテンプレに反映", variant="primary")
274
+ recalc_btn = gr.Button("🔁 この表の値で再計算")
275
+ health_btn = gr.Button("🩺 環境チェック")
276
+ health_out = gr.HTML()
277
+ gr.Markdown("※ 画像化やVisionに失敗した場合はテキスト抽出に自動フォールバックします。")
278
 
279
  with gr.Column(scale=1):
280
  fin_json = gr.Code(label="抽出JSON", language="json", interactive=False)
281
 
282
  with gr.Tabs():
283
  with gr.Tab("抽出結果(表で編集可)"):
284
+ # JSON schema 例外の回避:列型/列数固定、初期値を空にする
285
  df_out = gr.Dataframe(
286
  headers=["category", "item", "value"],
287
  datatype=["str", "str", "number"],
288
  col_count=(3, "fixed"),
289
  row_count=(1, "dynamic"),
290
+ value=[],
291
+ interactive=True,
292
  )
293
  with gr.Tab("スコアリング"):
294
  score_json = gr.Code(label="スコア(JSON)", language="json")
 
296
  with gr.Tab("AI診断(日本語)"):
297
  insight_md = gr.Markdown()
298
 
299
+ run_btn.click(run_analyze, inputs=[company, use_vision, files],
300
+ outputs=[fin_json, df_out, score_json, chart, insight_md],
301
+ concurrency_limit=1)
302
+ recalc_btn.click(run_recalc, inputs=[df_out], outputs=[score_json, chart, fin_json],
303
+ concurrency_limit=1)
304
+ health_btn.click(health, outputs=health_out, concurrency_limit=1)
 
 
 
 
 
 
 
305
 
306
+ # Spaces では share=False/サーバ設定不要。show_api=False で API 情報生成も抑制。
307
+ demo.queue(max_size=10)
308
+ demo.launch(show_api=False)
 
 
 
 
 
 
309
 
310
  if __name__ == "__main__":
311
  main()