Update ui/ui_app.py
Browse files- ui/ui_app.py +205 -273
ui/ui_app.py
CHANGED
|
@@ -1,309 +1,241 @@
|
|
| 1 |
# ui/ui_app.py
|
| 2 |
from __future__ import annotations
|
| 3 |
-
import
|
| 4 |
-
from typing import Any, Dict, List
|
|
|
|
| 5 |
import gradio as gr
|
| 6 |
import pandas as pd
|
| 7 |
import plotly.graph_objects as go
|
| 8 |
|
| 9 |
-
|
| 10 |
-
from core.extract import
|
| 11 |
-
from core.scoring import score_company
|
| 12 |
-
from core.
|
| 13 |
-
|
| 14 |
-
)
|
| 15 |
-
from core.units import detect_unit, unit_factor, scale_financials_yen
|
| 16 |
-
from core.openai_client import VISION_MODEL, TEXT_MODEL, get_client
|
| 17 |
-
from core.llm_quant import extract_market_product_signals
|
| 18 |
-
|
| 19 |
-
# ===== helpers =====
|
| 20 |
-
def fin_to_df(fin: Dict[str, Any]) -> pd.DataFrame:
|
| 21 |
-
rows = []
|
| 22 |
-
def add(cat, d):
|
| 23 |
-
for k, v in (d or {}).items():
|
| 24 |
-
rows.append({"category": cat, "item": k, "value": v})
|
| 25 |
-
add("balance_sheet", fin.get("balance_sheet"))
|
| 26 |
-
add("income_statement", fin.get("income_statement"))
|
| 27 |
-
add("cash_flows", fin.get("cash_flows"))
|
| 28 |
-
return pd.DataFrame(rows, columns=["category", "item", "value"])
|
| 29 |
-
|
| 30 |
-
def df_to_fin(df: pd.DataFrame) -> Dict[str, Any]:
|
| 31 |
-
out = {"balance_sheet": {}, "income_statement": {}, "cash_flows": {}}
|
| 32 |
-
for _, r in df.iterrows():
|
| 33 |
-
cat, item, val = str(r["category"]), str(r["item"]), r["value"]
|
| 34 |
-
try:
|
| 35 |
-
parsed = None if val in (None, "", "null") else float(val)
|
| 36 |
-
except Exception:
|
| 37 |
-
parsed = None
|
| 38 |
-
if cat in out:
|
| 39 |
-
out[cat][item] = parsed
|
| 40 |
-
return out
|
| 41 |
-
|
| 42 |
-
def _fmt_yen(n: float) -> str:
|
| 43 |
-
if n is None: return "—"
|
| 44 |
-
try:
|
| 45 |
-
n = float(n)
|
| 46 |
-
except Exception:
|
| 47 |
-
return "—"
|
| 48 |
-
# 視認性のため 兆/億/万円 に自動スケール
|
| 49 |
-
absn = abs(n)
|
| 50 |
-
if absn >= 1e12:
|
| 51 |
-
return f"{n/1e12:.2f} 兆円"
|
| 52 |
-
if absn >= 1e8:
|
| 53 |
-
return f"{n/1e8:.2f} 億円"
|
| 54 |
-
if absn >= 1e4:
|
| 55 |
-
return f"{n/1e4:.1f} 万円"
|
| 56 |
-
return f"{int(n):,} 円"
|
| 57 |
|
| 58 |
-
|
| 59 |
-
|
|
|
|
|
|
|
| 60 |
try:
|
| 61 |
-
return
|
| 62 |
except Exception:
|
| 63 |
-
return
|
| 64 |
-
|
| 65 |
-
def
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
html = f"""
|
| 110 |
-
<div style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px">
|
| 111 |
-
<div class="card" style="padding:12px;border:1px solid #eee;border-radius:12px">
|
| 112 |
-
<div>総資産</div><div style="font-size:1.2rem;font-weight:700">{_fmt_yen(assets)}</div>
|
| 113 |
-
</div>
|
| 114 |
-
<div class="card" style="padding:12px;border:1px solid #eee;border-radius:12px">
|
| 115 |
-
<div>売上高</div><div style="font-size:1.2rem;font-weight:700">{_fmt_yen(sales)}</div>
|
| 116 |
-
</div>
|
| 117 |
-
<div class="card" style="padding:12px;border:1px solid #eee;border-radius:12px">
|
| 118 |
-
<div>営業利益</div><div style="font-size:1.2rem;font-weight:700">{_fmt_yen(op)}</div>
|
| 119 |
-
</div>
|
| 120 |
-
<div class="card" style="padding:12px;border:1px solid #eee;border-radius:12px">
|
| 121 |
-
<div>自己資本比率</div><div style="font-size:1.1rem;font-weight:700">{_fmt_pct(equity_ratio)} {badge(equity_ratio)}</div>
|
| 122 |
-
</div>
|
| 123 |
-
<div class="card" style="padding:12px;border:1px solid #eee;border-radius:12px">
|
| 124 |
-
<div>流動比率</div><div style="font-size:1.1rem;font-weight:700">{_fmt_pct(current_ratio)} {badge(current_ratio)}</div>
|
| 125 |
-
</div>
|
| 126 |
-
<div class="card" style="padding:12px;border:1px solid #eee;border-radius:12px">
|
| 127 |
-
<div>ROA</div><div style="font-size:1.1rem;font-weight:700">{_fmt_pct(roa)} {badge(roa)}</div>
|
| 128 |
-
</div>
|
| 129 |
-
</div>
|
| 130 |
-
<div style="margin-top:8px;color:#64748b">社内スコア: <b>{score['total_score']}</b> (グレード {score['grade']})</div>
|
| 131 |
-
"""
|
| 132 |
return html
|
| 133 |
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
# 2) 抽出(Vision→失敗時Text)
|
| 146 |
-
try:
|
| 147 |
-
imgs: List[bytes] = []
|
| 148 |
-
for p in files:
|
| 149 |
-
imgs += pdf_to_images(p, dpi=200, max_pages=6)
|
| 150 |
-
fin_raw = extract_financials(imgs if use_vision else None, None, company or "")
|
| 151 |
-
except Exception:
|
| 152 |
-
text_blob = ""
|
| 153 |
-
for p in files:
|
| 154 |
-
text_blob += pdf_to_text(p) + "\n\n"
|
| 155 |
-
fin_raw = extract_financials(None, text_blob, company or "")
|
| 156 |
-
|
| 157 |
-
# 3) 円換算
|
| 158 |
-
fin_yen = scale_financials_yen(fin_raw, factor=factor)
|
| 159 |
-
|
| 160 |
-
# 4) 社内スコア
|
| 161 |
-
df = fin_to_df(fin_yen)
|
| 162 |
-
score = score_company(fin_yen)
|
| 163 |
-
fig = radar(score)
|
| 164 |
-
kpi_html = kpi_cards_html(fin_yen, score)
|
| 165 |
-
|
| 166 |
-
# 5) 外部評価に使う LLM 定量シグナル(市場/製品)
|
| 167 |
-
signals = None
|
| 168 |
-
try:
|
| 169 |
-
ext_text = ""
|
| 170 |
-
if use_pdf_for_ext:
|
| 171 |
-
# 追加でテキストを多めに取得(セクション説明文など)
|
| 172 |
-
for p in files:
|
| 173 |
-
ext_text += pdf_to_text(p, pages=8) + "\n\n"
|
| 174 |
-
ext_text = (aux_text or "") + "\n\n" + ext_text
|
| 175 |
-
if len(ext_text.strip()) > 0:
|
| 176 |
-
signals = extract_market_product_signals(ext_text[:18000], company_hint=company or "")
|
| 177 |
-
except Exception:
|
| 178 |
-
signals = None
|
| 179 |
-
|
| 180 |
-
# 6) 外部評価テンプレを生成 → LLMで可能な項目を自動補完
|
| 181 |
-
df_ext = get_external_template_df()
|
| 182 |
-
df_ext = fill_missing_with_external(df_ext)
|
| 183 |
-
if signals:
|
| 184 |
-
df_ext = apply_llm_signals_to_df(df_ext, signals)
|
| 185 |
-
|
| 186 |
-
# 7) AI所見(中立・根拠明示)
|
| 187 |
-
try:
|
| 188 |
-
client = get_client()
|
| 189 |
-
prompt = f"""あなたは独立の財務アナリストです。主観や推測を避け、事実と“比率”を根拠に簡潔な箇条書きを作成。
|
| 190 |
-
- 断定・煽り禁止、将来予測禁止
|
| 191 |
-
- 各行の末尾に根拠(指標名=値)を括弧で併記
|
| 192 |
-
- 外部評価(市場/製品)は参考情報として最後に別見出しで一言添える
|
| 193 |
-
|
| 194 |
-
[財務データ(円換算)]
|
| 195 |
-
{json.dumps(fin_yen, ensure_ascii=False)}
|
| 196 |
-
|
| 197 |
-
[社内スコア(財務比率基準)]
|
| 198 |
-
{json.dumps(score, ensure_ascii=False)}
|
| 199 |
-
|
| 200 |
-
[外部シグナル(市場/製品;参考)]
|
| 201 |
-
{json.dumps(signals or {}, ensure_ascii=False)}
|
| 202 |
-
"""
|
| 203 |
-
resp = client.chat.completions.create(
|
| 204 |
-
model=TEXT_MODEL,
|
| 205 |
-
messages=[{"role":"system","content":"中立・簡潔・根拠明示。"},
|
| 206 |
-
{"role":"user","content": prompt}],
|
| 207 |
-
temperature=0.2,
|
| 208 |
-
)
|
| 209 |
-
insight = resp.choices[0].message.content
|
| 210 |
-
except Exception as e:
|
| 211 |
-
insight = f"AI所見の生成に失敗: {e}"
|
| 212 |
-
|
| 213 |
-
unit_info = f"PDF表記の単位: <b>{detected}</b> / 適用単位: <b>{unit_label}</b>(円換算係数={factor:g})"
|
| 214 |
-
signals_md = "—"
|
| 215 |
-
if signals:
|
| 216 |
-
ev = signals.get("market", {}).get("evidence") or []
|
| 217 |
-
cagr = signals.get("market", {}).get("cagr_pct")
|
| 218 |
-
pc = signals.get("products", {}).get("count")
|
| 219 |
-
pg = signals.get("products", {}).get("growing_count")
|
| 220 |
-
signals_md = f"- 市場CAGR: {cagr if cagr is not None else '—'}%\n- 主力商品数: {pc or '—'} / 成長中: {pg or '—'}\n- エビデンス: {(' / '.join(ev[:2])) if ev else '—'}"
|
| 221 |
-
|
| 222 |
-
return (
|
| 223 |
-
unit_info, # HTML
|
| 224 |
-
kpi_html, # HTML
|
| 225 |
-
json.dumps(fin_raw, ensure_ascii=False, indent=2), # 折りたたみ表示用
|
| 226 |
-
json.dumps(fin_yen, ensure_ascii=False, indent=2), # 折りたたみ表示用
|
| 227 |
-
df, # 編集表(円換算)
|
| 228 |
-
json.dumps(score, ensure_ascii=False, indent=2),
|
| 229 |
-
fig,
|
| 230 |
-
insight,
|
| 231 |
-
df_ext, # 外部テンプレ(LLM補完済み)
|
| 232 |
-
signals_md # LLM抽出の要点
|
| 233 |
-
)
|
| 234 |
-
|
| 235 |
-
def run_recalc(df: pd.DataFrame):
|
| 236 |
-
try:
|
| 237 |
-
fin = df_to_fin(df) # ここは既に円換算された表が編集される想定
|
| 238 |
-
score = score_company(fin)
|
| 239 |
-
fig = radar(score)
|
| 240 |
-
return (json.dumps(score, ensure_ascii=False, indent=2),
|
| 241 |
-
fig,
|
| 242 |
-
json.dumps(fin, ensure_ascii=False, indent=2))
|
| 243 |
-
except Exception as e:
|
| 244 |
-
tb = traceback.format_exc(limit=6)
|
| 245 |
-
raise gr.Error(f"再計算に失敗しました: {e}\n\n<pre style='white-space:pre-wrap'>{tb}</pre>")
|
| 246 |
|
| 247 |
-
def
|
| 248 |
-
|
| 249 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
|
| 251 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 252 |
def build_ui() -> gr.Blocks:
|
| 253 |
-
with gr.Blocks(theme=gr.themes.Soft(primary_hue="indigo"),
|
| 254 |
-
|
| 255 |
-
|
| 256 |
with gr.Row():
|
| 257 |
with gr.Column(scale=1):
|
| 258 |
company = gr.Textbox(label="企業名(任意)", placeholder="例:株式会社OO")
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
value="自動推定", label="金額単位(PDF記載)")
|
| 262 |
-
use_pdf_for_ext = gr.Checkbox(value=True, label="PDFから市場/製品情報も抽出(外部評価用)")
|
| 263 |
-
aux_text = gr.Textbox(label="補助テキスト・Web抜粋(任意)", lines=4,
|
| 264 |
-
placeholder="業界説明/IRの抜粋などを貼ると、市場CAGRや主力商品数を自動推定します。")
|
| 265 |
-
files = gr.File(label="決算書PDF(複数可)", file_count="multiple", type="filepath")
|
| 266 |
run_btn = gr.Button("📄 解析して反映", variant="primary")
|
| 267 |
-
recalc_btn = gr.Button("🔁
|
| 268 |
-
health_btn = gr.Button("🩺 環境チェック")
|
| 269 |
-
health_out = gr.HTML()
|
| 270 |
-
|
| 271 |
with gr.Column(scale=1):
|
| 272 |
-
|
| 273 |
-
kpi_html = gr.HTML(label="主要KPI")
|
| 274 |
|
| 275 |
with gr.Tabs():
|
| 276 |
with gr.Tab("概要"):
|
| 277 |
-
|
|
|
|
| 278 |
with gr.Tab("抽出結果(編集可)"):
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
fin_json_yen = gr.Code(label="抽出JSON(円換算)", language="json")
|
| 282 |
-
df_out = gr.Dataframe(headers=["category", "item", "value"], interactive=True, wrap=True)
|
| 283 |
with gr.Tab("スコア(社内ルール)"):
|
| 284 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 285 |
with gr.Tab("AI所見(中立)"):
|
| 286 |
-
|
|
|
|
| 287 |
with gr.Tab("外部評価(定量化)"):
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
|
| 293 |
run_btn.click(
|
| 294 |
-
|
| 295 |
-
inputs=[
|
| 296 |
-
outputs=[
|
| 297 |
concurrency_limit=1
|
| 298 |
)
|
| 299 |
|
| 300 |
recalc_btn.click(
|
| 301 |
-
|
|
|
|
|
|
|
| 302 |
concurrency_limit=1
|
| 303 |
)
|
| 304 |
|
| 305 |
-
calc_t.click(calc_external, inputs=[df_ext], outputs=[ext_json], concurrency_limit=1)
|
| 306 |
-
|
| 307 |
-
health_btn.click(health, outputs=health_out, concurrency_limit=1)
|
| 308 |
-
|
| 309 |
return demo
|
|
|
|
| 1 |
# ui/ui_app.py
|
| 2 |
from __future__ import annotations
|
| 3 |
+
import os, io, json, base64, traceback
|
| 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 |
+
from core.extract import parse_pdf # あなたの既存実装をインポート(PDF→fin(dict), df)
|
| 12 |
+
from core.scoring import score_company # 社内ルールのスコア(dict)
|
| 13 |
+
from core.external_scoring import score_external # 外部評価(定量化)(dict)
|
| 14 |
+
from core.ai_judgement import make_ai_memo # AI所見(中立)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
+
# ============ 小さなUIヘルパ ============
|
| 17 |
+
def _safe_json_loads(s: str|Dict[str,Any]|None) -> Dict[str,Any]:
|
| 18 |
+
if s is None: return {}
|
| 19 |
+
if isinstance(s, dict): return s
|
| 20 |
try:
|
| 21 |
+
return json.loads(s)
|
| 22 |
except Exception:
|
| 23 |
+
return {}
|
| 24 |
+
|
| 25 |
+
def _kpi_cards_html(score: Dict[str,Any], ext: Dict[str,Any]) -> str:
|
| 26 |
+
total = score.get("total_score", None)
|
| 27 |
+
grade = score.get("grade", "-")
|
| 28 |
+
ext_total = ext.get("external_total", None)
|
| 29 |
+
|
| 30 |
+
gap = None
|
| 31 |
+
if isinstance(total, (int, float)) and isinstance(ext_total, (int, float)):
|
| 32 |
+
gap = round(total - ext_total, 1)
|
| 33 |
+
|
| 34 |
+
def kpi(label: str, value: str, sub: str = "") -> str:
|
| 35 |
+
return f"""
|
| 36 |
+
<div class="kpi">
|
| 37 |
+
<div class="kpi-label">{label}</div>
|
| 38 |
+
<div class="kpi-value">{value}</div>
|
| 39 |
+
<div class="kpi-sub">{sub}</div>
|
| 40 |
+
</div>"""
|
| 41 |
+
|
| 42 |
+
html = """
|
| 43 |
+
<style>
|
| 44 |
+
.kpi-wrap {display:grid;grid-template-columns:repeat(4,minmax(180px,1fr));gap:12px;margin-top:4px;}
|
| 45 |
+
.kpi{background:#fff;border:1px solid #e9eef5;border-radius:14px;padding:14px;box-shadow:0 2px 6px rgba(0,0,0,0.04);}
|
| 46 |
+
.kpi-label{color:#64748b;font-size:12px;margin-bottom:6px;}
|
| 47 |
+
.kpi-value{font-size:26px;font-weight:700;}
|
| 48 |
+
.kpi-sub{color:#94a3b8;font-size:11px;margin-top:2px;}
|
| 49 |
+
@media (max-width: 860px){.kpi-wrap{grid-template-columns:repeat(2,1fr);} }
|
| 50 |
+
</style>
|
| 51 |
+
<div class="kpi-wrap">
|
| 52 |
+
"""
|
| 53 |
+
|
| 54 |
+
html += kpi("総合スコア(社内)", f"{total if total is not None else '—'}", f"グレード: {grade}")
|
| 55 |
+
html += kpi("外部評価(定量)", f"{ext_total if ext_total is not None else '—'}", "100点満点")
|
| 56 |
+
html += kpi("ギャップ(社内−外部)", f"{gap if gap is not None else '—'}", "+は社内>外部")
|
| 57 |
+
# 代表的な指標(上位1・下位1)をサマリに
|
| 58 |
+
det = score.get("details", []) or []
|
| 59 |
+
if det:
|
| 60 |
+
best = max(det, key=lambda x: x.get("score", 0))
|
| 61 |
+
worst = min(det, key=lambda x: x.get("score", 0))
|
| 62 |
+
html += kpi("強み指標", f"{best.get('metric','—')}", f"スコア {round(best.get('score',0),1)}")
|
| 63 |
+
# 4つ目で既に埋まっているので、下位はサブテキストで表示
|
| 64 |
+
html = html.replace("</div></div>", f"<div class='kpi-sub'>弱み指標: {worst.get('metric','—')} / {round(worst.get('score',0),1)}</div></div>", 1)
|
| 65 |
+
else:
|
| 66 |
+
html += kpi("指標ハイライト", "—", "データ不足")
|
| 67 |
+
|
| 68 |
+
html += "</div>"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
return html
|
| 70 |
|
| 71 |
+
def _score_bar(score: Dict[str,Any]) -> go.Figure:
|
| 72 |
+
det = score.get("details", []) or []
|
| 73 |
+
labels = [d.get("metric","") for d in det]
|
| 74 |
+
vals = [float(d.get("score",0)) for d in det]
|
| 75 |
+
# ソート(高→低)
|
| 76 |
+
pairs = sorted(zip(vals, labels), reverse=True)
|
| 77 |
+
vals, labels = zip(*pairs) if pairs else ([],[])
|
| 78 |
+
fig = go.Figure(go.Bar(x=list(vals), y=list(labels), orientation="h", text=[f"{v:.1f}" for v in vals], textposition="auto"))
|
| 79 |
+
fig.update_layout(height=460, margin=dict(l=10,r=10,t=30,b=10), title="指標別スコア(社内ルール)", xaxis=dict(range=[0,100]))
|
| 80 |
+
return fig
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
|
| 82 |
+
def _external_bar(ext: Dict[str,Any]) -> go.Figure:
|
| 83 |
+
items = ext.get("items", []) or []
|
| 84 |
+
labels = [f"{i.get('category','')}: {i.get('name','')}" for i in items]
|
| 85 |
+
vals = [float(i.get("score",0)) for i in items]
|
| 86 |
+
pairs = sorted(zip(vals, labels))
|
| 87 |
+
vals, labels = zip(*pairs) if pairs else ([],[])
|
| 88 |
+
fig = go.Figure(go.Bar(x=list(vals), y=list(labels), orientation="h", text=[f"{v:.1f}" for v in vals], textposition="auto"))
|
| 89 |
+
fig.update_layout(height=520, margin=dict(l=10,r=10,t=30,b=10), title="外部評価(定量)項目スコア", xaxis=dict(range=[0,100]))
|
| 90 |
+
return fig
|
| 91 |
|
| 92 |
+
def _strengths_md(score: Dict[str,Any]) -> str:
|
| 93 |
+
det = score.get("details", []) or []
|
| 94 |
+
if not det: return "強み・弱みの抽出に必要な明細が見つかりませんでした。"
|
| 95 |
+
det_sorted = sorted(det, key=lambda x: x.get("score",0), reverse=True)
|
| 96 |
+
top3 = det_sorted[:3]
|
| 97 |
+
bottom3 = det_sorted[-3:][::-1]
|
| 98 |
+
def fmt(items):
|
| 99 |
+
return "\n".join([f"- **{i.get('metric','')}**:{i.get('score',0):.1f} 点(値: {i.get('value','—')}{i.get('unit','')})" for i in items])
|
| 100 |
+
return f"### 強み(上位3)\n{fmt(top3)}\n\n### 改善余地(下位3)\n{fmt(bottom3)}"
|
| 101 |
+
|
| 102 |
+
# ============ アプリ本体 ============
|
| 103 |
def build_ui() -> gr.Blocks:
|
| 104 |
+
with gr.Blocks(theme=gr.themes.Soft(primary_hue="indigo"), fill_height=True, analytics_enabled=False) as demo:
|
| 105 |
+
gr.Markdown("## 🧮 企業スコアリング(PDF解析 × 定量評価 × AI所見)")
|
| 106 |
+
|
| 107 |
with gr.Row():
|
| 108 |
with gr.Column(scale=1):
|
| 109 |
company = gr.Textbox(label="企業名(任意)", placeholder="例:株式会社OO")
|
| 110 |
+
files = gr.File(label="決算書PDF(複数可)", file_count="multiple", type="filepath")
|
| 111 |
+
force_ocr = gr.Checkbox(False, label="OCRを強制(スキャンPDF向け / やや遅い)")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
run_btn = gr.Button("📄 解析して反映", variant="primary")
|
| 113 |
+
recalc_btn = gr.Button("🔁 この表の値で再計算")
|
|
|
|
|
|
|
|
|
|
| 114 |
with gr.Column(scale=1):
|
| 115 |
+
kpi_html = gr.HTML(label="ハイライトKPI")
|
|
|
|
| 116 |
|
| 117 |
with gr.Tabs():
|
| 118 |
with gr.Tab("概要"):
|
| 119 |
+
strengths_md = gr.Markdown()
|
| 120 |
+
|
| 121 |
with gr.Tab("抽出結果(編集可)"):
|
| 122 |
+
df_out = gr.Dataframe(headers=["category","item","value"], interactive=True, wrap=True)
|
| 123 |
+
|
|
|
|
|
|
|
| 124 |
with gr.Tab("スコア(社内ルール)"):
|
| 125 |
+
score_bar = gr.Plot()
|
| 126 |
+
radar_plot = gr.Plot()
|
| 127 |
+
with gr.Accordion("🔎 原データ(JSON)", open=False):
|
| 128 |
+
score_json_view = gr.JSON(value={})
|
| 129 |
+
|
| 130 |
with gr.Tab("AI所見(中立)"):
|
| 131 |
+
ai_md = gr.Markdown()
|
| 132 |
+
|
| 133 |
with gr.Tab("外部評価(定量化)"):
|
| 134 |
+
ext_bar = gr.Plot()
|
| 135 |
+
with gr.Accordion("🔎 原データ(JSON)", open=False):
|
| 136 |
+
ext_json_view = gr.JSON(value={})
|
| 137 |
+
|
| 138 |
+
# ---------- ハンドラ ----------
|
| 139 |
+
def _run(files: List[str], company: str, force_ocr: bool):
|
| 140 |
+
"""
|
| 141 |
+
1) PDF→抽出 2) 社内スコア 3) 外部評価(定量) 4) AI所見 5) 可視化
|
| 142 |
+
"""
|
| 143 |
+
if not files:
|
| 144 |
+
raise gr.Error("PDF をアップロードしてください。")
|
| 145 |
+
|
| 146 |
+
# --- 1) 抽出 ---
|
| 147 |
+
fin_dict, fin_df = parse_pdf(files, company=company or "", force_ocr=force_ocr)
|
| 148 |
+
|
| 149 |
+
# --- 2) 社内スコア ---
|
| 150 |
+
score = score_company(fin_dict)
|
| 151 |
+
|
| 152 |
+
# --- 3) 外部評価(定量) ---
|
| 153 |
+
ext = score_external(company=company or "", fin=fin_dict, df=fin_df)
|
| 154 |
+
|
| 155 |
+
# --- 4) AI所見(中立・簡潔) ---
|
| 156 |
+
ai = make_ai_memo(company=company or "", fin=fin_dict, score=score, ext=ext)
|
| 157 |
+
|
| 158 |
+
# --- 5) 可視化 ---
|
| 159 |
+
kpi = _kpi_cards_html(score, ext)
|
| 160 |
+
sbar = _score_bar(score)
|
| 161 |
+
ebar = _external_bar(ext)
|
| 162 |
+
strengths = _strengths_md(score)
|
| 163 |
+
|
| 164 |
+
return (
|
| 165 |
+
kpi,
|
| 166 |
+
strengths,
|
| 167 |
+
fin_df,
|
| 168 |
+
sbar,
|
| 169 |
+
# 既存のレーダー(社内ルール可視化)。score側で作っているなら差し替えてOK
|
| 170 |
+
go.Figure(
|
| 171 |
+
data=[go.Scatterpolar(
|
| 172 |
+
r=[d["score"] for d in score.get("details", [])] + ([score.get("details", [])[0]["score"]] if score.get("details") else []),
|
| 173 |
+
theta=[d["metric"] for d in score.get("details", [])] + ([score.get("details", [])[0]["metric"]] if score.get("details") else []),
|
| 174 |
+
fill="toself"
|
| 175 |
+
)],
|
| 176 |
+
layout=go.Layout(
|
| 177 |
+
title="スコア・レーダー(社内)",
|
| 178 |
+
polar=dict(radialaxis=dict(visible=True, range=[0,100])),
|
| 179 |
+
showlegend=False, height=380, margin=dict(l=20,r=20,t=30,b=20)
|
| 180 |
+
)
|
| 181 |
+
),
|
| 182 |
+
score, # gr.JSON 用
|
| 183 |
+
ai,
|
| 184 |
+
ebar,
|
| 185 |
+
ext # gr.JSON 用
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
def _recalc(df: pd.DataFrame, last_company: str):
|
| 189 |
+
"""
|
| 190 |
+
テーブル編集後の再計算。抽出は行わず、DF→fin辞書へ戻して再スコア。
|
| 191 |
+
"""
|
| 192 |
+
# df -> fin 辞書(あなたの既存関数に合わせて調整)
|
| 193 |
+
fin = {"balance_sheet":{}, "income_statement":{}, "cash_flows":{}}
|
| 194 |
+
for _, r in df.iterrows():
|
| 195 |
+
cat = str(r.get("category",""))
|
| 196 |
+
item= str(r.get("item",""))
|
| 197 |
+
val = r.get("value", None)
|
| 198 |
+
try:
|
| 199 |
+
parsed = None if val in (None,"","null") else float(val)
|
| 200 |
+
except Exception:
|
| 201 |
+
parsed = None
|
| 202 |
+
if cat in fin: fin[cat][item] = parsed
|
| 203 |
+
|
| 204 |
+
score = score_company(fin)
|
| 205 |
+
ext = score_external(company=last_company or "", fin=fin, df=df)
|
| 206 |
+
kpi = _kpi_cards_html(score, ext)
|
| 207 |
+
sbar = _score_bar(score)
|
| 208 |
+
ebar = _external_bar(ext)
|
| 209 |
+
strengths = _strengths_md(score)
|
| 210 |
+
return (
|
| 211 |
+
kpi, strengths, sbar,
|
| 212 |
+
go.Figure(
|
| 213 |
+
data=[go.Scatterpolar(
|
| 214 |
+
r=[d["score"] for d in score.get("details", [])] + ([score.get("details", [])[0]["score"]] if score.get("details") else []),
|
| 215 |
+
theta=[d["metric"] for d in score.get("details", [])] + ([score.get("details", [])[0]["metric"]] if score.get("details") else []),
|
| 216 |
+
fill="toself"
|
| 217 |
+
)],
|
| 218 |
+
layout=go.Layout(
|
| 219 |
+
title="スコア・レーダー(社内)",
|
| 220 |
+
polar=dict(radialaxis=dict(visible=True, range=[0,100])),
|
| 221 |
+
showlegend=False, height=380, margin=dict(l=20,r=20,t=30,b=20)
|
| 222 |
+
)
|
| 223 |
+
),
|
| 224 |
+
score, ebar, ext
|
| 225 |
+
)
|
| 226 |
|
| 227 |
run_btn.click(
|
| 228 |
+
_run,
|
| 229 |
+
inputs=[files, company, force_ocr],
|
| 230 |
+
outputs=[kpi_html, strengths_md, df_out, score_bar, radar_plot, score_json_view, ai_md, ext_bar, ext_json_view],
|
| 231 |
concurrency_limit=1
|
| 232 |
)
|
| 233 |
|
| 234 |
recalc_btn.click(
|
| 235 |
+
_recalc,
|
| 236 |
+
inputs=[df_out, company],
|
| 237 |
+
outputs=[kpi_html, strengths_md, score_bar, radar_plot, score_json_view, ext_bar, ext_json_view],
|
| 238 |
concurrency_limit=1
|
| 239 |
)
|
| 240 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
return demo
|