Corin1998 commited on
Commit
5c82b31
·
verified ·
1 Parent(s): bba60b0

Update ui/ui_app.py

Browse files
Files changed (1) hide show
  1. ui/ui_app.py +227 -210
ui/ui_app.py CHANGED
@@ -1,234 +1,251 @@
1
  # ui/ui_app.py
2
  from __future__ import annotations
3
- import os, io, json, base64, traceback
 
 
4
  import gradio as gr
5
  import pandas as pd
6
  import plotly.graph_objects as go
7
 
8
- from core.extract import parse_pdf, ExtractError
9
- from core.scoring import score_company
10
- from core.external_scoring import score_external_from_df
11
- from core.ai_judgement import make_ai_memo
12
-
13
- # ================= 共通ユーティリティ =================
14
-
15
- def _b64(img_bytes):
16
- return base64.b64encode(img_bytes).decode("utf-8")
17
-
18
- def fin_to_df(fin):
19
- rows = []
20
- def add(cat, d):
21
- for k, v in (d or {}).items():
22
- rows.append({"category": cat, "item": k, "value": v})
23
- add("balance_sheet", fin.get("balance_sheet"))
24
- add("income_statement", fin.get("income_statement"))
25
- add("cash_flows", fin.get("cash_flows"))
26
- return pd.DataFrame(rows, columns=["category", "item", "value"])
27
-
28
- def df_to_fin(df):
29
- out = {"balance_sheet": {}, "income_statement": {}, "cash_flows": {}}
30
- for _, r in df.iterrows():
31
- cat, item, val = str(r["category"]), str(r["item"]), r["value"]
32
- try:
33
- parsed = None if val in (None, "", "null") else float(str(val).replace(",",""))
34
- except Exception:
35
- parsed = None
36
- if cat in out:
37
- out[cat][item] = parsed
38
- return out
39
-
40
- def radar(score):
41
- labels = [d["metric"] for d in score["details"]]
42
- values = [d["score"] for d in score["details"]]
43
- fig = go.Figure()
44
- fig.add_trace(go.Scatterpolar(r=values + values[:1], theta=labels + labels[:1], fill="toself"))
45
- fig.update_layout(
46
- polar=dict(radialaxis=dict(visible=True, range=[0, 100])),
47
- showlegend=False,
48
- margin=dict(l=20, r=20, t=30, b=20),
49
- height=380,
50
- title=f"総合スコア: {score['total_score']}(グレード: {score['grade']})"
51
- )
52
- return fig
53
-
54
- # ================ OpenAI 抽出(Vision / Text) =================
55
-
56
- OPENAI_MODEL_VISION = os.environ.get("OPENAI_VISION_MODEL", "gpt-4o-mini")
57
- OPENAI_MODEL_TEXT = os.environ.get("OPENAI_TEXT_MODEL", "gpt-4o-mini")
58
-
59
- SYSTEM_JSON = """あなたは有能な財務アナリストです。
60
- 与えられた決算書(画像またはテキスト)から、次の厳密な JSON 構造のみを日本語の単位なし・半角数値で返してください。分からない項目は null。
61
- {
62
- "company": {"name": null},
63
- "period": {"start_date": null, "end_date": null},
64
- "balance_sheet": {
65
- "total_assets": null, "total_liabilities": null, "total_equity": null,
66
- "current_assets": null, "fixed_assets": null,
67
- "current_liabilities": null, "long_term_liabilities": null
68
- },
69
- "income_statement": {
70
- "sales": null, "cost_of_sales": null, "gross_profit": null,
71
- "operating_expenses": null, "operating_income": null,
72
- "ordinary_income": null, "net_income": null
73
- },
74
- "cash_flows": {
75
- "operating_cash_flow": null, "investing_cash_flow": null, "financing_cash_flow": null
76
- }
77
- }
78
- """
79
-
80
- def _openai_client():
81
- # openai==1.x の公式クライアント。proxies を渡さない(互換性エラー回避)。
82
- from openai import OpenAI
83
- key = os.environ.get("OPENAI_API_KEY")
84
- if not key:
85
- raise gr.Error("OPENAI_API_KEY が未設定です。Spaces → Settings → **Variables and secrets** に `OPENAI_API_KEY` を追加してください。")
86
- return OpenAI(api_key=key, timeout=30)
87
-
88
- def extract_financials(images, text_blob, company_hint):
89
- client = _openai_client()
90
- if images:
91
- content = [{"type": "text", "text": SYSTEM_JSON}]
92
- if company_hint:
93
- content.append({"type": "text", "text": f"会社名の候補: {company_hint}"})
94
- for im in images:
95
- content.append({"type": "input_image", "image_url": f"data:image/png;base64,{_b64(im)}"})
96
- resp = client.chat.completions.create(
97
- model=OPENAI_MODEL_VISION,
98
- messages=[
99
- {"role": "system", "content": "返答は必ず有効な JSON オブジェクトのみ。説明を含めない。"},
100
- {"role": "user", "content": content},
101
- ],
102
- response_format={"type": "json_object"},
103
- temperature=0.1,
104
- )
105
- return json.loads(resp.choices[0].message.content)
106
- else:
107
- prompt = f"{SYSTEM_JSON}\n\n以下は決算書のテキストです。上記の JSON だけを返してください。\n\n{text_blob or ''}"
108
- resp = client.chat.completions.create(
109
- model=OPENAI_MODEL_TEXT,
110
- messages=[
111
- {"role": "system", "content": "返答は必ず有効な JSON オブジェクトのみ。"},
112
- {"role": "user", "content": prompt},
113
- ],
114
- response_format={"type": "json_object"},
115
- temperature=0.1,
116
- )
117
- return json.loads(resp.choices[0].message.content)
118
-
119
- # ================== ハンドラ(型ヒントなしで安定化) ==================
120
-
121
- def run_analyze(company, use_vision, files, force_ocr):
122
- if not files:
123
- raise gr.Error("PDF をアップロードしてください。")
124
-
125
- # 1) PDF抽出(テキスト→足りなければ画像化)
126
  try:
127
- images, raw_text, business_text, dbg = parse_pdf(files, force_ocr=force_ocr)
128
- except ExtractError as e:
129
- raise gr.Error(f"PDF読み込みに失敗: {e}")
130
-
131
- # 2) Vision 優先 → 失敗ならテキスト
132
- try:
133
- if use_vision and images:
134
- fin = extract_financials(images, None, company or "")
135
- else:
136
- fin = extract_financials(None, raw_text, company or "")
137
  except Exception:
138
- try:
139
- fin = extract_financials(None, raw_text, company or "")
140
- except Exception as e:
141
- raise gr.Error(f"AI抽出に失敗: {e}")
142
 
143
- df = fin_to_df(fin)
144
- score_int = score_company(fin)
145
- fig = radar(score_int)
146
-
147
- # 3) 外部評価(定量化)
148
- try:
149
- score_ext = score_external_from_df(df)
150
- except Exception as e:
151
- score_ext = {"name": "外部評価(失敗)", "external_total": None, "items": [], "notes": str(e)}
152
-
153
- # 4) AI 所見(中立)
154
- try:
155
- memo = make_ai_memo(
156
- company=company or "",
157
- fin=fin,
158
- score_internal=score_int,
159
- score_external=score_ext,
160
- business_text=business_text
161
- )
162
- except Exception as e:
163
- memo = f"AI所見の生成に失敗: {e}"
164
-
165
- return (
166
- json.dumps(fin, ensure_ascii=False, indent=2),
167
- df,
168
- json.dumps(score_int, ensure_ascii=False, indent=2),
169
- fig,
170
- memo,
171
- json.dumps(score_ext, ensure_ascii=False, indent=2),
172
- dbg
173
- )
174
-
175
- def run_recalc(df):
176
- try:
177
- fin = df_to_fin(df)
178
- score_int = score_company(fin)
179
- fig = radar(score_int)
180
- return (
181
- json.dumps(score_int, ensure_ascii=False, indent=2),
182
- fig,
183
- json.dumps(fin, ensure_ascii=False, indent=2)
184
- )
185
- except Exception as e:
186
- tb = traceback.format_exc(limit=6)
187
- raise gr.Error(f"再計算に失敗しました: {e}\n\n<pre style='white-space:pre-wrap'>{tb}</pre>")
188
 
189
- # ================== UI 組み立て ==================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
 
191
  def build_ui():
192
  with gr.Blocks(theme=gr.themes.Soft(primary_hue="indigo"), fill_height=True, analytics_enabled=False) as demo:
193
- gr.Markdown("## 🧮 企業スコアリング(PDF解析 × OpenAI Vision)")
 
 
 
 
 
194
 
195
  with gr.Row():
196
- with gr.Column(scale=1):
197
  company = gr.Textbox(label="企業名(任意)", placeholder="例:株式会社OO")
198
- use_vision = gr.Checkbox(value=True, label="OpenAIでPDFをAI解析(Vision)")
199
- force_ocr = gr.Checkbox(value=False, label="OCRを強制(スキャンPDF向け)")
200
  files = gr.File(label="決算書PDF(複数可)", file_count="multiple", type="filepath")
201
- run_btn = gr.Button("📄 PDFを解析してテンプレに反映", variant="primary")
202
- recalc_btn = gr.Button("🔁 この表の値で再計算")
203
- gr.Markdown("※ 画像化やVisionに失敗した場合はテキスト抽出に自動フォールバックします。")
204
-
205
- with gr.Column(scale=1):
206
- fin_json = gr.Code(label="抽出JSON(編集不可)", language="json", interactive=False)
207
-
208
- with gr.Tabs():
209
- with gr.Tab("抽出結果(表で編集可)"):
210
- df_out = gr.Dataframe(headers=["category", "item", "value"], interactive=True, wrap=True)
211
- with gr.Tab("スコアリン(内部ルール)"):
212
- score_json = gr.Code(label="スコア(JSON)", language="json")
213
- chart = gr.Plot(label="スコアレーダー")
214
- with gr.Tab("AI診断(中立・日本語)"):
215
- insight_md = gr.Markdown()
216
- with gr.Tab("外部評価(定量化)"):
217
- ext_json = gr.Code(label="外部評価JSON", language="json")
218
- with gr.Tab("抽出ログ/デバッグ"):
219
- debug_out = gr.Textbox(label="ログ",lines=12, interactive=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
 
221
  run_btn.click(
222
- run_analyze,
223
- inputs=[company, use_vision, files, force_ocr],
224
- outputs=[fin_json, df_out, score_json, chart, insight_md, ext_json, debug_out],
225
- concurrency_limit=1
 
 
 
 
 
 
 
 
226
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
  recalc_btn.click(
228
- run_recalc,
229
- inputs=[df_out],
230
- outputs=[score_json, chart, fin_json],
231
- concurrency_limit=1
232
  )
233
 
234
  return demo
 
1
  # ui/ui_app.py
2
  from __future__ import annotations
3
+ import os, json, io
4
+ from typing import Any, Dict, List, Tuple
5
+
6
  import gradio as gr
7
  import pandas as pd
8
  import plotly.graph_objects as go
9
 
10
+ # 既存スコア計算(無ければ簡易版でフォールバック)
11
+ try:
12
+ from core.scoring import score_company # 既存の社内スコア(レーダー)
13
+ except Exception:
14
+ def score_company(fin: Dict[str,Any]) -> Dict[str,Any]:
15
+ bs = fin.get("balance_sheet",{}) or {}
16
+ is_ = fin.get("income_statement",{}) or {}
17
+ safe = lambda x: float(x) if x not in (None,"") else 0.0
18
+ kpis = {
19
+ "総資産": safe(bs.get("total_assets")),
20
+ "売上高": safe(is_.get("sales")),
21
+ "営業利益": safe(is_.get("operating_income")),
22
+ "純利益": safe(is_.get("net_income")),
23
+ }
24
+ details = []
25
+ for k,v in kpis.items():
26
+ val = max(0.0, min(100.0, (v/(kpis["売上高"]+1e-9))*30 if k!="売上高" else 50))
27
+ details.append({"metric": k, "score": round(val,1)})
28
+ total = round(sum(d["score"] for d in details)/len(details),1) if details else 0.0
29
+ grade = "A" if total>=85 else "B" if total>=70 else "C" if total>=55 else "D"
30
+ return {"total_score": total, "grade": grade, "details": details}
31
+
32
+ try:
33
+ from core.external_scoring import score_external # あなたの外部評価(定量化)
34
+ except Exception:
35
+ def score_external(df: pd.DataFrame) -> Dict[str,Any]:
36
+ return {"name":"外部評価(簡易)","external_total": 60.0, "items":[],"notes":"モジュール未検出のため簡易"}
37
+
38
+ try:
39
+ from core.ai_judgement import make_ai_memo # AI所見(中立)
40
+ except Exception:
41
+ def make_ai_memo(fin: Dict[str,Any], score: Dict[str,Any], ext: Dict[str,Any]) -> str:
42
+ return "(AI所見モジュール未検出のため簡易)\n- 財務の整合性と収益性を総合的に確認してください。"
43
+
44
+ from core.extract import parse_pdf # PDF→(fin, df, meta, log)
45
+
46
+ UNITS = [("自動",""), ("円","円"), ("千円","千円"), ("万円","万円"),
47
+ ("百万円","百万円"), ("千万円","千万円"), ("億円","億円")]
48
+
49
+ def _fmt_yen(x: Any) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  try:
51
+ f = float(x)
52
+ sign = "-" if f < 0 else ""
53
+ f = abs(f)
54
+ return f"{sign}{f:,.0f} 円"
 
 
 
 
 
 
55
  except Exception:
56
+ return "—"
 
 
 
57
 
58
+ def _radar(score: Dict[str, Any]) -> go.Figure:
59
+ labels = [d["metric"] for d in score.get("details",[])]
60
+ values = [d["score"] for d in score.get("details",[])]
61
+ if not labels:
62
+ labels, values = ["データ不足"], [0]
63
+ fig = go.Figure()
64
+ fig.add_trace(go.Scatterpolar(r=values + values[:1], theta=labels + labels[:1], fill="toself"))
65
+ fig.update_layout(polar=dict(radialaxis=dict(visible=True, range=[0, 100])),
66
+ showlegend=False, margin=dict(l=20, r=20, t=30, b=20), height=380,
67
+ title=f"総合スコア: {score.get('total_score',0)}(グレード: {score.get('grade','-')})")
68
+ return fig
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
 
70
+ def _summary_cards(fin: Dict[str,Any]) -> str:
71
+ bs = fin.get("balance_sheet",{}) or {}
72
+ is_ = fin.get("income_statement",{}) or {}
73
+ html = f"""
74
+ <style>
75
+ .cards {{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}}
76
+ .card {{border:1px solid #eee;border-radius:12px;padding:12px;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.04)}}
77
+ .k {{font-size:.8rem;color:#666}}
78
+ .v {{font-size:1.1rem;font-weight:700;margin-top:6px}}
79
+ @media (max-width: 980px) {{ .cards {{grid-template-columns:repeat(2,minmax(0,1fr));}}}}
80
+ </style>
81
+ <div class="cards">
82
+ <div class="card"><div class="k">総資産</div><div class="v">{_fmt_yen(bs.get('total_assets'))}</div></div>
83
+ <div class="card"><div class="k">売上高</div><div class="v">{_fmt_yen(is_.get('sales'))}</div></div>
84
+ <div class="card"><div class="k">営業利益</div><div class="v">{_fmt_yen(is_.get('operating_income'))}</div></div>
85
+ <div class="card"><div class="k">当期純利益</div><div class="v">{_fmt_yen(is_.get('net_income'))}</div></div>
86
+ </div>
87
+ """
88
+ return html
89
 
90
  def build_ui():
91
  with gr.Blocks(theme=gr.themes.Soft(primary_hue="indigo"), fill_height=True, analytics_enabled=False) as demo:
92
+ gr.Markdown("## 🧮 企業スコアリング(PDF解析 × 単位自動判定 × UI改善)")
93
+
94
+ state_fin = gr.State({})
95
+ state_df = gr.State(pd.DataFrame(columns=["category","item","value"]))
96
+ state_unit_detected = gr.State({"label":"円","scale":1.0})
97
+ state_unit_current = gr.State({"label":"円","scale":1.0})
98
 
99
  with gr.Row():
100
+ with gr.Column(scale=1, min_width=320):
101
  company = gr.Textbox(label="企業名(任意)", placeholder="例:株式会社OO")
102
+ use_vision = gr.Checkbox(value=True, label="OpenAI Visionで解析")
 
103
  files = gr.File(label="決算書PDF(複数可)", file_count="multiple", type="filepath")
104
+
105
+ with gr.Group():
106
+ unit_manual = gr.Dropdown(choices=[u for u,_ in UNITS], value="自動", label="単位の上書き(任意)")
107
+ apply_unit_btn = gr.Button("↻ 単位を反映(換算)")
108
+
109
+ run_btn = gr.Button("📄 PDFを解析", variant="primary")
110
+ dl_csv = gr.File(label="ダウンロード(CSV)", interactive=False)
111
+ dl_json = gr.File(label="ダウンロード(JSON)", interactive=False)
112
+
113
+ unit_info = gr.HTML()
114
+ debug_out = gr.Textbox(label="グ", lines=10, interactive=False)
115
+
116
+ with gr.Column(scale=2):
117
+ summary = gr.HTML()
118
+ with gr.Tabs():
119
+ with gr.Tab("編集可能テーブル"):
120
+ df_out = gr.Dataframe(headers=["category","item","value"], interactive=True, wrap=True)
121
+ recalc_btn = gr.Button("🔁 表の値で再計算")
122
+ with gr.Tab("スコア"):
123
+ score_json = gr.JSON(label="スコア(内部JSON)", visible=False)
124
+ chart = gr.Plot(label="スコアレーダー")
125
+ with gr.Tab("外部評価"):
126
+ ext_json = gr.JSON(label="外部評価(内部JSON)", visible=False)
127
+ ext_md = gr.Markdown()
128
+ with gr.Tab("AI所見"):
129
+ insight_md = gr.Markdown()
130
+
131
+ # -------- ハンドラ --------
132
+ def on_analyze(company: str, use_vision: bool, files: List[str]):
133
+ try:
134
+ fin, df, meta, log = parse_pdf(files, company, use_vision)
135
+ # 状態に反映
136
+ state_fin_v = fin
137
+ state_df_v = df
138
+ det = {"label": meta["unit_label"], "scale": meta["unit_scale"]}
139
+ cur = det.copy()
140
+
141
+ # スコア
142
+ sc = score_company(fin)
143
+ fig = _radar(sc)
144
+
145
+ # 外部評価(数値中心)
146
+ ext = score_external(df)
147
+ ext_md_text = f"**外部評価合計:** {ext.get('external_total','—')}\n\n" \
148
+ f"{ext.get('notes','')}"
149
+
150
+ # 所見
151
+ memo = make_ai_memo(fin, sc, ext)
152
+
153
+ # ダウンロード用ファイル作成
154
+ csv_path = "/tmp/result.csv"
155
+ json_path = "/tmp/result.json"
156
+ df.to_csv(csv_path, index=False)
157
+ with open(json_path, "w", encoding="utf-8") as f:
158
+ json.dump(fin, f, ensure_ascii=False, indent=2)
159
+
160
+ unit_badge = f"<b>単位(自動):</b> {det['label']} ×{det['scale']:,.0f}"
161
+ if meta.get("warnings"):
162
+ unit_badge += "<br>" + " / ".join(f"⚠️ {w}" for w in meta["warnings"])
163
+
164
+ return (
165
+ fin, # state_fin
166
+ df, # state_df
167
+ det, # detected
168
+ cur, # current
169
+ _summary_cards(fin),
170
+ df, fig, sc,
171
+ ext, ext_md_text,
172
+ memo,
173
+ unit_badge,
174
+ log,
175
+ csv_path,
176
+ json_path
177
+ )
178
+ except Exception as e:
179
+ import traceback
180
+ tb = traceback.format_exc(limit=4)
181
+ raise gr.Error(f"解析に失敗しました: {e}\n\n{tb}")
182
 
183
  run_btn.click(
184
+ on_analyze,
185
+ inputs=[company, use_vision, files],
186
+ outputs=[
187
+ state_fin, state_df, state_unit_detected, state_unit_current,
188
+ summary,
189
+ df_out, chart, score_json,
190
+ ext_json, ext_md,
191
+ insight_md,
192
+ unit_info,
193
+ debug_out,
194
+ dl_csv, dl_json
195
+ ],
196
  )
197
+
198
+ def on_apply_unit(unit_label: str, df_cur: pd.DataFrame,
199
+ det: Dict[str,Any], cur: Dict[str,Any], fin_cur: Dict[str,Any]):
200
+ from core.unit_utils import UNIT_SCALE, apply_unit_scale
201
+ # 新しい係数
202
+ if unit_label in ("", "自動"):
203
+ new = det
204
+ else:
205
+ new = {"label": unit_label, "scale": UNIT_SCALE.get(unit_label, 1.0)}
206
+ # 係数比で再換算(現在→新)
207
+ ratio = (new["scale"] / max(cur.get("scale",1.0), 1e-12))
208
+
209
+ def _scale_df(df: pd.DataFrame) -> pd.DataFrame:
210
+ df2 = df.copy()
211
+ for i in df2.index:
212
+ try:
213
+ v = df2.at[i,"value"]
214
+ df2.at[i,"value"] = float(v) * ratio if v not in (None,"") else v
215
+ except Exception:
216
+ pass
217
+ return df2
218
+
219
+ df2 = _scale_df(df_cur)
220
+ fin2 = apply_unit_scale(fin_cur, ratio) # 比率で再拡大/縮小
221
+
222
+ sc2 = score_company(fin2)
223
+ fig2 = _radar(sc2)
224
+ unit_badge = f"<b>単位(現在):</b> {new['label']} ×{new['scale']:,.0f}"
225
+ return fin2, df2, new, _summary_cards(fin2), df2, fig2, sc2, unit_badge
226
+
227
+ apply_unit_btn.click(
228
+ on_apply_unit,
229
+ inputs=[unit_manual, df_out, state_unit_detected, state_unit_current, state_fin],
230
+ outputs=[state_fin, state_df, state_unit_current, summary, df_out, chart, score_json, unit_info]
231
+ )
232
+
233
+ def on_recalc(df_cur: pd.DataFrame, fin_cur: Dict[str,Any]):
234
+ # ユーザー編集を反映して再計算
235
+ fin2 = {"balance_sheet":{}, "income_statement":{}, "cash_flows":{}}
236
+ for _, r in df_cur.iterrows():
237
+ cat, item, val = str(r["category"]), str(r["item"]), r["value"]
238
+ try: v = None if val in (None,"","null") else float(val)
239
+ except Exception: v = None
240
+ if cat in fin2: fin2[cat][item] = v
241
+ sc2 = score_company(fin2)
242
+ fig2 = _radar(sc2)
243
+ return fin2, sc2, fig2, _summary_cards(fin2)
244
+
245
  recalc_btn.click(
246
+ on_recalc,
247
+ inputs=[df_out, state_fin],
248
+ outputs=[state_fin, score_json, chart, summary]
 
249
  )
250
 
251
  return demo