diff --git a/.github/workflows/publish_advisory_site.yml b/.github/workflows/publish_advisory_site.yml index 7d98228..5356d50 100644 --- a/.github/workflows/publish_advisory_site.yml +++ b/.github/workflows/publish_advisory_site.yml @@ -173,6 +173,7 @@ jobs: env: TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} + NOTIFY_LANG: ${{ vars.NOTIFY_LANG || 'zh' }} SITE_URL: ${{ github.event.inputs.site_url || 'https://quantstrategylab.github.io/QuantAdvisorResearch' }} REPORT_PATH: ${{ steps.build_report.outputs.report_path }} run: | diff --git a/docs/notification_format.md b/docs/notification_format.md index cec1f37..b888465 100644 --- a/docs/notification_format.md +++ b/docs/notification_format.md @@ -11,8 +11,11 @@ repository secrets, `scripts/notify_advisory_telegram.py` sends a short summary after a successful Pages deployment. Missing secrets skip notification without failing the publish job. -- Locale: the public HTML/RSS/Telegram copy defaults to Simplified Chinese - (`zh-CN`). JSON contract keys remain in English for downstream stability. +- Locale: public HTML/RSS defaults to Simplified Chinese (`zh-CN`). Telegram + accepts `NOTIFY_LANG=zh|en` and defaults to Chinese. English Telegram keeps + stable decision facts in English and links to the source-language report, + rather than mixing untranslated natural-language rationale into the message. + JSON contract keys remain in English for downstream stability. ## Boundary diff --git a/docs/notification_format.zh-CN.md b/docs/notification_format.zh-CN.md index 0ff75a5..a47d2e7 100644 --- a/docs/notification_format.zh-CN.md +++ b/docs/notification_format.zh-CN.md @@ -147,5 +147,6 @@ Subject: 智慧投顾研究周度复盘 - 2026-05-30 - GitHub Pages:`.github/workflows/publish_advisory_site.yml` 发布 HTML、JSON、Markdown 和 RSS。 - RSS:`scripts/publish_advisory_site.py` 生成 `feed.xml`。 - Telegram:可选。如果仓库 secrets 配置了 `TELEGRAM_BOT_TOKEN` 和 `TELEGRAM_CHAT_ID`,`scripts/notify_advisory_telegram.py` 会在 Pages 部署成功后发送短摘要;如果缺少任一 secret,会跳过通知但不让发布失败。 +- 通知语言:Telegram 支持 `NOTIFY_LANG=zh|en`,默认中文。英文通知只展示可稳定翻译的决策事实并链接到原始报告,不会把尚未英文化的中文理由混进英文消息;公开 HTML/RSS 目前仍默认简体中文。 通知默认只展示最终推荐、股票背景、推荐理由、周期、综合分和完整报告链接;不能包含订单、目标仓位、目标股数、账户适当性或账户级配置建议。主题候选只保留在 JSON/Markdown 审计材料中,不进入默认通知摘要。 diff --git a/scripts/notify_advisory_telegram.py b/scripts/notify_advisory_telegram.py index 330a19f..2cecc42 100644 --- a/scripts/notify_advisory_telegram.py +++ b/scripts/notify_advisory_telegram.py @@ -17,11 +17,16 @@ def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description="Send a non-personalized advisory report summary to Telegram.") parser.add_argument("--report", required=True, help="Advisory report JSON path") parser.add_argument("--site-url", default="https://quantstrategylab.github.io/QuantAdvisorResearch") + parser.add_argument( + "--lang", + default=os.environ.get("NOTIFY_LANG", "zh"), + help="Notification language: zh or en. Defaults to NOTIFY_LANG or zh.", + ) parser.add_argument("--dry-run", action="store_true", help="Print the message instead of sending") args = parser.parse_args(argv) report = json.loads(Path(args.report).read_text(encoding="utf-8")) - message = format_telegram_message(report, site_url=args.site_url) + message = format_telegram_message(report, site_url=args.site_url, lang=args.lang) if args.dry_run: print(message) return 0 diff --git a/src/quant_advisor_research/notifications.py b/src/quant_advisor_research/notifications.py index 48cb1ae..d0626c7 100644 --- a/src/quant_advisor_research/notifications.py +++ b/src/quant_advisor_research/notifications.py @@ -9,6 +9,25 @@ from .publisher import cadence_label, report_filename +def notification_locale(value: object | None = None) -> str: + """Normalize the compact operator-notification locale.""" + + return "zh" if str(value or "zh").strip().lower().replace("_", "-").startswith("zh") else "en" + + +def _english_cadence_label(report: dict[str, Any]) -> str: + cadence = str(report.get("cadence", "")).strip().lower() + return {"weekly": "Weekly", "monthly": "Monthly"}.get(cadence, cadence.title() or "Advisory") + + +def _english_horizon_label(item: dict[str, Any]) -> str: + horizon = str(item.get("primary_horizon", "")).strip().lower() + return {"short": "Short term", "medium": "Medium term", "long": "Long term"}.get( + horizon, + horizon.replace("_", " ").title() or "Unspecified horizon", + ) + + def report_public_url(report: dict[str, Any], *, site_url: str) -> str: return f"{site_url.rstrip('/')}/{quote(report_filename(report))}" @@ -51,6 +70,40 @@ def _format_final_decisions(report: dict[str, Any]) -> list[str]: return lines +def _format_final_decisions_en(report: dict[str, Any]) -> list[str]: + """Render an English summary without embedding Chinese source prose. + + Recommendation background and rationale are produced as Chinese natural + language in the report artifact today. An English notification therefore + keeps only stable identifiers and numeric decision facts, then points the + reader to the full report instead of presenting a mixed-language summary. + """ + + decisions = report.get("final_decisions", {}) + picks = decisions.get("recommendations", []) + if not picks: + return ["Recommendations: none for this period"] + lines = ["Recommendations:"] + for item in picks: + lines.append( + "- {symbol} | {horizon} | Composite score={score}".format( + symbol=item.get("symbol", ""), + horizon=_english_horizon_label(item), + score=display_number(item.get("combined_score")), + ) + ) + buckets = decisions.get("horizon_buckets", {}) + lines.append( + "Horizons: short={short}; medium={medium}; long={long}".format( + short=", ".join(buckets.get("short", [])) or "none", + medium=", ".join(buckets.get("medium", [])) or "none", + long=", ".join(buckets.get("long", [])) or "none", + ) + ) + lines.append("Rationale and source-language background are available in the linked report.") + return lines + + def _format_theme_candidates(report: dict[str, Any], *, limit: int) -> list[str]: candidates = report.get("theme_first_candidates", [])[:limit] if not candidates: @@ -101,7 +154,19 @@ def format_telegram_message( site_url: str, max_recommendations: int = 8, max_themes: int = 5, + lang: str | None = None, ) -> str: + locale = notification_locale(lang) + if locale == "en": + lines = [ + f"Quant Advisor Research | {_english_cadence_label(report)} | {report.get('as_of', '')}", + "", + *_format_final_decisions_en(report), + "", + f"Full report: {report_public_url(report, site_url=site_url)}", + ] + return "\n".join(str(line) for line in lines if line is not None) + lines = [ f"智慧投顾研究系统 | {cadence_label(report)} | {report.get('as_of', '')}", "", diff --git a/tests/test_publisher.py b/tests/test_publisher.py index cca8d94..20015a6 100644 --- a/tests/test_publisher.py +++ b/tests/test_publisher.py @@ -2,6 +2,7 @@ from copy import deepcopy from pathlib import Path +import re from quant_advisor_research.advisory_report import build_advisory_report, write_json from quant_advisor_research.publisher import ( @@ -492,3 +493,34 @@ def test_format_telegram_message_is_direct_and_links_report() -> None: assert "不包含下单" not in message assert "MU" in message assert "https://example.com/advisor/2026-05-30-weekly-model-recommendations.html" in message + + +def test_format_telegram_message_can_render_an_english_summary_without_chinese_prose() -> None: + from quant_advisor_research.notifications import format_telegram_message + + report = build_sample_report() + report["final_decisions"] = { + "recommendations": [ + { + "symbol": "MU", + "primary_horizon": "medium", + "primary_horizon_label": "中线", + "combined_score": 0.82, + "business_summary": "科技 / HBM / 存储", + "prospect_summary": "中文理由不应混进英文通知。", + } + ], + "horizon_buckets": {"short": [], "medium": ["MU"], "long": []}, + } + + message = format_telegram_message( + report, + site_url="https://example.com/advisor", + lang="en-US", + ) + + assert "Quant Advisor Research | Weekly | 2026-05-30" in message + assert "MU | Medium term | Composite score=0.82" in message + assert "Rationale and source-language background" in message + assert "Full report: https://example.com/advisor/2026-05-30-weekly-model-recommendations.html" in message + assert not re.search(r"[\u4e00-\u9fff]", message)