feat(reports): save the report as one HTML page as well (#1419)

- a saved report also includes complete_report.html: the report as one self-contained page with its sections listed beside the text, readable in a browser, on a phone and in print
- answering the save question at the prompt also asks about the page and offers to open it in a local browser window; --no-html and save_reports(..., html=False) skip it
- the Tauric Research logo leads the header at the top left, and a line at the foot credits TradingAgents and Tauric Research
- the page loads nothing and runs nothing (inline styles, a content-security policy); report text is rendered without raw HTML or images
- markdown-it-py is declared as a dependency
This commit is contained in:
Yijia-Xiao
2026-10-03 21:27:43 +00:00
parent d53b076b7a
commit 56a98d9df7
11 changed files with 820 additions and 93 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ calls: list = []
@pytest.mark.unit
def test_no_arguments_still_runs_an_analysis(runner):
assert runner.invoke(m.app, []).exit_code == 0
no_flags = {"ticker": None, "date": None, "analysts": None, "save": None, "show": None}
no_flags = {"ticker": None, "date": None, "analysts": None, "save": None, "show": None, "html": None}
assert calls == [("analysis", {"checkpoint": None, "portfolio": None, "flags": no_flags})]
+147 -1
View File
@@ -87,12 +87,22 @@ def test_nothing_is_missing_when_flags_and_environment_answer_every_step(monkeyp
assert selections.unattended_gaps(FLAGS) == []
class _Browser:
def __init__(self):
self.opened = []
def open(self, url):
self.opened.append(url)
return True
@pytest.mark.unit
def test_save_and_show_answer_the_questions_after_the_run(monkeypatch, tmp_path):
def no_prompt(*a, **k):
raise AssertionError("prompted although --save/--show answered")
monkeypatch.setattr(run.typer, "prompt", no_prompt)
monkeypatch.setattr(run, "_graphical_browser", lambda: _Browser()) # even at a desktop
shown = []
monkeypatch.setattr(run, "display_complete_report", lambda state: shown.append(state))
from tradingagents.graph.trading_graph import TradingAgentsGraph
@@ -145,7 +155,7 @@ def test_the_command_passes_its_flags_to_the_run(monkeypatch):
"--analysts", "market,news", "--save", "--no-show"])
assert result.exit_code == 0, result.output
assert seen["flags"] == FLAGS
assert seen["flags"] == {**FLAGS, "html": None}
@pytest.mark.unit
@@ -220,6 +230,126 @@ def test_each_tier_provider_s_key_is_checked_before_the_run(monkeypatch):
assert checked == ["openai", "anthropic"]
def _report_graph(tmp_path):
from tradingagents.graph.trading_graph import TradingAgentsGraph
graph = object.__new__(TradingAgentsGraph)
graph.config = {"results_dir": str(tmp_path)}
graph.run_settings = lambda: {}
return graph
STATE = {"market_report": "M", "final_trade_decision": "**Rating**: Hold"}
@pytest.mark.unit
def test_a_saved_report_includes_the_html_page_unless_declined(monkeypatch, tmp_path):
monkeypatch.setattr(run, "display_complete_report", lambda state: None)
run._offer_reports(STATE, _report_graph(tmp_path / "a"), "NVDA", save=True, show=False)
run._offer_reports(STATE, _report_graph(tmp_path / "b"), "NVDA", save=True, show=False, html=False)
assert list((tmp_path / "a").glob("reports/NVDA_*/complete_report.html"))
assert list((tmp_path / "b").glob("reports/NVDA_*/complete_report.md"))
assert not list((tmp_path / "b").glob("reports/NVDA_*/complete_report.html"))
def _answer(monkeypatch, answers):
asked = []
def prompt(text, default=None, **k):
asked.append(text)
for question, answer in answers.items():
if question in text:
return default if answer is None else answer
raise AssertionError(f"unexpected question: {text}")
monkeypatch.setattr(run.typer, "prompt", prompt)
monkeypatch.setattr(run, "display_complete_report", lambda state: None)
return asked
@pytest.mark.unit
def test_an_interactive_run_asks_for_the_html_page_and_opens_it(monkeypatch, tmp_path):
asked = _answer(monkeypatch, {"Save report": "Y", "Save path": "rel/out", "HTML": "Y",
"browser": "Y", "Display full report": "N"})
browser = _Browser()
monkeypatch.setattr(run, "_graphical_browser", lambda: browser)
monkeypatch.chdir(tmp_path)
run._offer_reports(STATE, _report_graph(tmp_path), "NVDA")
page = tmp_path / "rel" / "out" / "complete_report.html"
assert page.exists() and browser.opened == [page.resolve().as_uri()]
assert [q for q in asked if "HTML" in q] and [q for q in asked if "browser" in q]
@pytest.mark.unit
def test_without_a_browser_window_the_page_is_not_offered_for_opening(monkeypatch, tmp_path):
asked = _answer(monkeypatch, {"Save report": "Y", "Save path": None, "HTML": "Y",
"Display full report": "N"})
monkeypatch.setattr(run, "_graphical_browser", lambda: None)
run._offer_reports(STATE, _report_graph(tmp_path), "NVDA")
assert not [q for q in asked if "browser" in q]
assert list(tmp_path.glob("reports/NVDA_*/complete_report.html"))
@pytest.mark.unit
def test_declining_the_html_page_saves_only_the_markdown(monkeypatch, tmp_path):
asked = _answer(monkeypatch, {"Save report": "Y", "Save path": None, "HTML": "N",
"Display full report": "N"})
monkeypatch.setattr(run, "_graphical_browser", lambda: _Browser())
run._offer_reports(STATE, _report_graph(tmp_path), "NVDA")
assert not list(tmp_path.glob("reports/NVDA_*/complete_report.html"))
assert not [q for q in asked if "browser" in q]
@pytest.mark.unit
@pytest.mark.parametrize("env, browser, offered", [
({}, "background", True),
({"SSH_CONNECTION": "10.0.0.1 22 10.0.0.2 22"}, "background", False), # the page is on the remote
({"SSH_TTY": "/dev/pts/0"}, "background", False),
({}, "terminal", False), # a text browser would take over the terminal
({}, "elinks", False),
({}, None, False), # no browser at all
])
def test_a_page_is_opened_only_in_a_browser_window_on_this_machine(monkeypatch, env, browser, offered):
import webbrowser
for name in ("SSH_CONNECTION", "SSH_TTY"):
monkeypatch.delenv(name, raising=False)
for name, value in env.items():
monkeypatch.setenv(name, value)
def get():
if browser is None:
raise webbrowser.Error("no runnable browser")
return {"background": lambda: webbrowser.BackgroundBrowser("firefox"),
"terminal": lambda: webbrowser.GenericBrowser("lynx"),
"elinks": lambda: webbrowser.Elinks("elinks")}[browser]()
monkeypatch.setattr(run.webbrowser, "get", get)
assert (run._graphical_browser() is not None) is offered
@pytest.mark.unit
@pytest.mark.parametrize("args, expected", [([], None), (["--no-html"], False), (["--html"], True)])
def test_the_html_flag_reaches_the_run(monkeypatch, args, expected):
from typer.testing import CliRunner
from cli import main as cli_main
seen = {}
monkeypatch.setattr(cli_main, "run_analysis", lambda **kw: seen.update(kw))
result = CliRunner().invoke(cli_main.app, ["--ticker", "NVDA", "--save", "--no-show", *args])
assert result.exit_code == 0 and seen["flags"]["html"] is expected
@pytest.mark.unit
def test_only_the_providers_the_tiers_use_need_a_key(monkeypatch):
"""Both tiers on a local Ollama: the main provider's key is not needed."""
@@ -231,3 +361,19 @@ def test_only_the_providers_the_tiers_use_need_a_key(monkeypatch):
selections._prompt_selections({}, FLAGS)
assert checked == ["ollama"]
@pytest.mark.unit
def test_a_page_the_browser_cannot_open_is_pointed_to(monkeypatch, tmp_path, capsys):
class Failing(_Browser):
def open(self, url):
return False
_answer(monkeypatch, {"Save report": "Y", "Save path": None, "HTML": "Y", "browser": "Y",
"Display full report": "N"})
monkeypatch.setattr(run, "_graphical_browser", lambda: Failing())
monkeypatch.setattr(run.console, "print", lambda *a, **k: print(*a))
run._offer_reports(STATE, _report_graph(tmp_path), "NVDA")
assert "complete_report.html" in capsys.readouterr().out.split("the page is at:")[-1]
+174
View File
@@ -0,0 +1,174 @@
"""An optional HTML copy of the report, for readers who do not open Markdown (#1419).
It is one self-contained file: no script, no remote asset, nothing a report's
text can inject; the Markdown report is unchanged.
"""
import re
import pytest
from tradingagents.reporting import write_report_tree
SETTINGS = {"version": "1.2.3", "llm_provider": "openai", "deep_think_llm": "gpt-6-sol",
"quick_think_llm": "gpt-6-luna", "analysts": ["market", "news"], "max_debate_rounds": 1,
"max_risk_discuss_rounds": 1, "data_vendors": {"core_stock_apis": "yfinance"}}
def _state(**overrides):
state = {"trade_date": "2026-09-26",
"market_report": "# Market view\n\nPrice | Level\n--- | ---\nClose | 181.2\n",
"news_report": "News text.",
"final_trade_decision": "**Rating**: Overweight\n\nAdd on weakness."}
state.update(overrides)
return state
def _html(tmp_path, **overrides):
write_report_tree(_state(**overrides), "NVDA", tmp_path, settings=SETTINGS, html=True)
return (tmp_path / "complete_report.html").read_text(encoding="utf-8")
@pytest.mark.unit
def test_the_html_page_is_written_unless_declined(tmp_path):
write_report_tree(_state(), "NVDA", tmp_path / "plain", settings=SETTINGS, html=False)
assert not (tmp_path / "plain" / "complete_report.html").exists()
write_report_tree(_state(), "NVDA", tmp_path / "default", settings=SETTINGS)
assert (tmp_path / "default" / "complete_report.html").exists()
page = _html(tmp_path / "html")
assert "<h1" in page and "NVDA" in page and "Overweight" in page and "2026-09-26" in page
assert '<table>' in page and "181.2" in page
@pytest.mark.unit
def test_the_markdown_report_is_the_same_with_or_without_html(tmp_path):
write_report_tree(_state(), "NVDA", tmp_path / "a", settings=SETTINGS, html=False)
write_report_tree(_state(), "NVDA", tmp_path / "b", settings=SETTINGS)
def undated(path):
return re.sub(r"- Generated: .*\n", "", path.read_text(encoding="utf-8"))
assert undated(tmp_path / "a" / "complete_report.md") == undated(tmp_path / "b" / "complete_report.md")
@pytest.mark.unit
def test_the_page_loads_nothing_and_runs_nothing(tmp_path):
page = _html(tmp_path)
assert "Content-Security-Policy" in page and "default-src 'none'" in page
assert "<script" not in page.lower()
assert not re.search(r'<(img|link|iframe|object|embed)\b', page, re.IGNORECASE)
@pytest.mark.unit
def test_report_text_cannot_inject_markup_or_fetch_anything(tmp_path):
hostile = ("<script>alert(1)</script> <img src=x onerror=alert(1)>\n\n"
"![pixel](https://tracker.example/p.png) [x](javascript:alert(1))")
page = _html(tmp_path, news_report=hostile)
assert "<script>alert" not in page and "<img" not in page.lower()
assert "tracker.example/p.png" not in re.findall(r'(?:src|href)="([^"]*)"', page)
assert 'href="javascript:' not in page
@pytest.mark.unit
def test_header_values_are_escaped(tmp_path):
write_report_tree(_state(), "<b>NV</b>", tmp_path, settings={**SETTINGS, "deep_think_llm": "<i>m</i>"}, html=True)
page = (tmp_path / "complete_report.html").read_text(encoding="utf-8")
assert "<b>NV</b>" not in page and "&lt;b&gt;NV&lt;/b&gt;" in page and "<i>m</i>" not in page
@pytest.mark.unit
def test_an_agent_s_own_headings_sit_below_its_heading(tmp_path):
page = _html(tmp_path)
assert page.count("<h1") == 1 # only the report's title
assert re.search(r"<h4[^>]*>Market view</h4>", page)
def _fields(fragment):
return re.findall(r'<span class="field">([^<]*)</span>', fragment)
@pytest.mark.unit
def test_run_details_show_each_value_as_its_own_field(tmp_path):
settings = {**SETTINGS, "deep_think_provider": "anthropic", "deep_think_llm": "claude-opus-5-5",
"data_vendors": {"core_stock_apis": "yfinance", "fundamental_data": "sec_edgar,yfinance"}}
write_report_tree(_state(), "NVDA", tmp_path, settings=settings, html=True)
page = (tmp_path / "complete_report.html").read_text(encoding="utf-8")
header = page[page.index("<header"):page.index("</header>")]
for value in ("anthropic", "claude-opus-5-5", "openai", "gpt-6-luna", "market", "news",
"sec_edgar", "yfinance"):
assert value in _fields(header)
# Vendors are named by what they serve, and a chain keeps its order.
assert "Prices" in header and "Fundamentals" in header and "fundamental_data" not in header
assert header.index(">sec_edgar<") < header.rindex(">yfinance<")
@pytest.mark.unit
def test_the_contents_name_each_section_once_and_list_its_agents_beneath(tmp_path):
page = _html(tmp_path)
nav = page[page.index('<nav class="contents"'):page.index("</nav>")]
assert "<ol" not in nav # no list numbers beside the sections' own I-V
assert re.search(r'<a href="#s1"><span class="num">I</span>\s*<span class="label">Analyst Team Reports</span></a>', nav)
assert re.search(r'<a href="#s1-1"><span class="bar"></span>\s*<span class="label">Market Analyst</span></a>', nav)
@pytest.mark.unit
def test_the_section_rail_opens_on_hover_and_keyboard_focus(tmp_path):
page = _html(tmp_path)
css = page[page.index("<style>"):page.index("</style>")]
# Wide screens: a rail centred on the left edge whose names are hidden from
# sight only (still read by screen readers) until it is pointed at or focused.
assert "top: 50%" in css and "translateY(-50%)" in css
assert ".contents:hover .label" in css and ".contents:focus-within .label" in css
assert "display: none" not in css.split(".contents .label", 1)[1].split("}", 1)[0]
@pytest.mark.unit
def test_the_sections_stay_within_reach(tmp_path):
page = _html(tmp_path)
nav = page[page.index('<nav class="contents"'):page.index("</nav>")]
assert "<h2" not in nav # the list needs no heading
assert 'id="top"' in page and 'href="#top"' in page # a way back on small screens
assert ".contents { position: fixed;" in page # beside the text on wide ones
@pytest.mark.unit
def test_the_page_heading_is_the_ticker_and_its_rating(tmp_path):
page = _html(tmp_path)
heading = re.search(r"<h1>(.*?)</h1>", page).group(1)
assert re.sub(r"<[^>]+>", "", heading).split() == ["NVDA", "Overweight"]
@pytest.mark.unit
def test_the_header_carries_the_official_logo_unaltered(tmp_path):
from importlib.resources import files
logo = files("tradingagents").joinpath("assets/tauric-logo.svg").read_text(encoding="utf-8").strip()
page = _html(tmp_path)
header = page[page.index("<header"):page.index("</header>")]
assert logo in header # drawn in the page, nothing fetched
assert 'href="https://tauric.ai"' in header and 'aria-label="Tauric Research"' in header
assert header.index('class="logo"') < header.index("<h1") # leads the header, above the title
@pytest.mark.unit
def test_the_page_credits_its_maker_in_a_line_of_text(tmp_path):
page = _html(tmp_path)
footer = page[page.index('<footer class="credit">'):page.index("</footer>")]
assert re.sub(r"<[^>]+>", "", footer).split() == ["TradingAgents", "powered", "by", "Tauric", "Research"]
# The logo already leads to tauric.ai; the credit leads to the project and the org.
assert 'href="https://github.com/TauricResearch/TradingAgents"' in footer
assert 'href="https://github.com/TauricResearch"' in footer and "tauric.ai" not in footer
assert "<svg" not in footer and 'class="brand"' in footer
assert page.index("</main>") < page.index('<footer class="credit">')
@pytest.mark.unit
def test_the_packaged_logo_stays_inert(tmp_path):
# The logo is drawn inside every report, so it may only ever hold shapes:
# no script, event handler, link, embedded document or external reference.
from importlib.resources import files
logo = files("tradingagents").joinpath("assets/tauric-logo.svg").read_text(encoding="utf-8")
tags = set(re.findall(r"<\s*([a-zA-Z][\w:-]*)", logo))
assert tags <= {"svg", "path", "rect", "circle", "g", "defs", "clipPath", "linearGradient", "stop"}
assert not re.search(r"\son\w+\s*=|href|url\(|@import|javascript:|<!entity|<!doctype", logo, re.IGNORECASE)