Spaces:
Running
Running
| """ | |
| LINE Flex Message builder — extracted from routers/line_webhook.py. | |
| Builds rich Flex Message bubbles for stock prediction results. | |
| """ | |
| import logging | |
| from typing import Optional | |
| logger = logging.getLogger(__name__) | |
| # Flex Message imports — wrapped so the module still loads on older SDK builds | |
| try: | |
| from linebot.v3.messaging import ( | |
| FlexMessage, | |
| FlexBubble, | |
| FlexBubbleStyles, | |
| FlexBlockStyle, | |
| FlexBox, | |
| FlexText, | |
| FlexSeparator, | |
| FlexButton, | |
| MessageAction, | |
| QuickReply, | |
| QuickReplyItem, | |
| TextMessage, | |
| ) | |
| FLEX_AVAILABLE = True | |
| except ImportError: | |
| FLEX_AVAILABLE = False | |
| def prob_bar(prob: float, width: int = 10) -> str: | |
| """Return a text-based probability bar, e.g. '█████░░░░░ 72%'.""" | |
| filled = round(prob * width) | |
| return "█" * filled + "░" * (width - filled) + f" {prob:.0%}" | |
| def build_flex_prediction( | |
| name: str, | |
| stock_no: str, | |
| exchange: str, | |
| price_str: str, | |
| signal: str, | |
| confidence: str, | |
| buy_prob: float, | |
| sell_prob: float, | |
| change_pct: float, | |
| today_change_pct: Optional[float] = None, | |
| degraded: bool = False, | |
| ): | |
| """ | |
| Build a LINE Flex Message bubble for a stock prediction result. | |
| Raises ImportError if FLEX_AVAILABLE is False — caller must handle. | |
| """ | |
| if not FLEX_AVAILABLE: | |
| raise ImportError("Flex Message classes not available in this SDK version") | |
| # Header colour driven by signal | |
| header_colours = {"BUY": "#27ACB2", "SELL": "#FF6B35", "HOLD": "#888888"} | |
| header_bg = header_colours.get(signal, "#888888") | |
| signal_labels = {"BUY": "📈 買進", "SELL": "📉 賣出", "HOLD": "⏸ 觀望"} | |
| conf_labels = {"HIGH": "高 ★★★", "MEDIUM": "中 ★★☆", "LOW": "低 ★☆☆"} | |
| signal_label = signal_labels.get(signal, signal) | |
| conf_label = conf_labels.get(confidence, confidence) | |
| exch_str = f"({exchange})" if exchange else "" | |
| bar_buy = prob_bar(buy_prob) | |
| bar_sell = prob_bar(sell_prob) | |
| change_str = f"{change_pct:+.2f}%" | |
| # -- Header -- | |
| header = FlexBox( | |
| layout="vertical", | |
| background_color=header_bg, | |
| padding_all="lg", | |
| contents=[ | |
| FlexText( | |
| text=f"{name} {stock_no}{exch_str}", | |
| weight="bold", | |
| color="#FFFFFF", | |
| size="lg", | |
| wrap=True, | |
| ), | |
| FlexText( | |
| text="台股預測", | |
| color="#FFFFFFCC", | |
| size="xs", | |
| ), | |
| ], | |
| ) | |
| # -- Body -- | |
| today_change_row = [] | |
| if today_change_pct is not None: | |
| tc_str = f"{today_change_pct:+.2f}%" | |
| tc_color = "#27ACB2" if today_change_pct >= 0 else "#FF6B35" | |
| today_change_row = [ | |
| FlexBox( | |
| layout="horizontal", | |
| contents=[ | |
| FlexText(text="今日漲跌", color="#666666", size="sm", flex=2), | |
| FlexText(text=tc_str, weight="bold", size="sm", flex=3, align="end", color=tc_color), | |
| ], | |
| ), | |
| ] | |
| body = FlexBox( | |
| layout="vertical", | |
| spacing="md", | |
| padding_all="lg", | |
| contents=[ | |
| FlexBox( | |
| layout="horizontal", | |
| contents=[ | |
| FlexText(text="現價", color="#666666", size="sm", flex=2), | |
| FlexText(text=price_str, weight="bold", size="sm", flex=3, align="end"), | |
| ], | |
| ), | |
| *today_change_row, | |
| FlexSeparator(), | |
| FlexBox( | |
| layout="horizontal", | |
| contents=[ | |
| FlexText(text="訊號", color="#666666", size="sm", flex=2), | |
| FlexText(text=signal_label, weight="bold", size="sm", flex=3, align="end"), | |
| ], | |
| ), | |
| FlexBox( | |
| layout="horizontal", | |
| contents=[ | |
| FlexText(text="信心度", color="#666666", size="sm", flex=2), | |
| FlexText(text=conf_label, size="sm", flex=3, align="end"), | |
| ], | |
| ), | |
| FlexSeparator(), | |
| FlexBox( | |
| layout="horizontal", | |
| contents=[ | |
| FlexText(text="買進", color="#27ACB2", size="sm", flex=2), | |
| FlexText(text=bar_buy, size="sm", flex=5, align="end"), | |
| ], | |
| ), | |
| FlexBox( | |
| layout="horizontal", | |
| contents=[ | |
| FlexText(text="賣出", color="#FF6B35", size="sm", flex=2), | |
| FlexText(text=bar_sell, size="sm", flex=5, align="end"), | |
| ], | |
| ), | |
| FlexSeparator(), | |
| FlexBox( | |
| layout="horizontal", | |
| contents=[ | |
| FlexText(text="預測5日漲跌", color="#666666", size="sm", flex=3), | |
| FlexText( | |
| text=change_str, | |
| weight="bold", | |
| size="sm", | |
| flex=2, | |
| align="end", | |
| color="#27ACB2" if change_pct >= 0 else "#FF6B35", | |
| ), | |
| ], | |
| ), | |
| FlexSeparator(), | |
| *( | |
| [FlexText( | |
| text="⚠️ 資料較少,預測準確度可能較低", | |
| color="#FF6B35", | |
| size="xs", | |
| wrap=True, | |
| )] | |
| if degraded else [] | |
| ), | |
| FlexText( | |
| text="⚠️ 僅供參考,非投資建議", | |
| color="#AAAAAA", | |
| size="xxs", | |
| wrap=True, | |
| ), | |
| ], | |
| ) | |
| # -- Footer -- | |
| footer = FlexBox( | |
| layout="vertical", | |
| contents=[ | |
| FlexButton( | |
| action=MessageAction(label="再查一支", text=stock_no), | |
| style="link", | |
| color="#27ACB2", | |
| height="sm", | |
| ), | |
| ], | |
| ) | |
| bubble = FlexBubble( | |
| header=header, | |
| body=body, | |
| footer=footer, | |
| styles=FlexBubbleStyles( | |
| header=FlexBlockStyle(background_color=header_bg), | |
| ), | |
| ) | |
| return FlexMessage(alt_text=f"{name} {stock_no} 預測結果", contents=bubble) | |
| def build_quick_reply_menu(results: list[dict]): | |
| """Build a Quick Reply menu from search results (max 13 items).""" | |
| if not FLEX_AVAILABLE: | |
| raise ImportError("Quick Reply classes not available in this SDK version") | |
| items = [] | |
| for r in results[:13]: | |
| label = f"{r['stock_no']} {r['name']}"[:20] # LINE limit: 20 chars | |
| items.append(QuickReplyItem(action=MessageAction(label=label, text=r['stock_no']))) | |
| msg = TextMessage(text="找到以下股票,請選擇:") | |
| msg.quick_reply = QuickReply(items=items) | |
| return msg | |
| def build_plain_text_prediction( | |
| name: str, | |
| stock_no: str, | |
| exchange: str, | |
| price_str: str, | |
| signal: str, | |
| confidence: str, | |
| buy_prob: float, | |
| sell_prob: float, | |
| change_pct: float, | |
| today_change_pct: Optional[float] = None, | |
| degraded: bool = False, | |
| ) -> str: | |
| """Build a plain-text fallback prediction reply.""" | |
| signal_emoji = {"BUY": "📈 買進", "SELL": "📉 賣出", "HOLD": "⏸ 觀望"} | |
| conf_label = {"HIGH": "高 ★★★", "MEDIUM": "中 ★★☆", "LOW": "低 ★☆☆"} | |
| exch_str = f"({exchange})" if exchange else "" | |
| today_line = f"今日漲跌:{today_change_pct:+.2f}%\n" if today_change_pct is not None else "" | |
| return ( | |
| f"📊 {name} {stock_no}{exch_str}\n" | |
| f"現價:{price_str}\n" | |
| f"{today_line}" | |
| f"─────────────\n" | |
| f"訊號:{signal_emoji.get(signal, signal)}\n" | |
| f"信心度:{conf_label.get(confidence, confidence)}\n" | |
| f"買進機率:{buy_prob:.0%} 賣出:{sell_prob:.0%}\n" | |
| f"預測5日漲跌:{change_pct:+.2f}%\n" | |
| f"─────────────\n" | |
| + ("⚠️ 資料較少,預測準確度可能較低\n" if degraded else "") | |
| + f"⚠️ 僅供參考,非投資建議" | |
| ) | |