Skip to content

Commit 638355f

Browse files
yjwyjw
authored andcommitted
Fix report export routing and research sanitization
1 parent 76179d1 commit 638355f

10 files changed

Lines changed: 214 additions & 28 deletions

File tree

src/young_stock/artifacts.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@
1515
def market_session(now: datetime | None = None) -> str:
1616
now = now or datetime.now()
1717
minute = now.hour * 60 + now.minute
18-
if 9 * 60 <= minute < 9 * 60 + 30:
18+
if 9 * 60 <= minute < 11 * 60 + 30:
1919
return "早盘"
2020
if 11 * 60 + 30 <= minute < 13 * 60:
2121
return "午间"
22-
if 9 * 60 + 30 <= minute < 11 * 60 + 30 or 13 * 60 <= minute < 15 * 60:
22+
if 13 * 60 <= minute < 15 * 60:
2323
return "盘中"
2424
return "盘后"
2525

src/young_stock/cli.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,10 @@ def _print_first_use_guide() -> None:
7878
click.echo(f"配置会保存到: {profile_path()}")
7979

8080

81+
def _current_report_date() -> str:
82+
return datetime.now().strftime("%Y%m%d")
83+
84+
8185
@cli.command(help="A-share dashboard: indices, ZT/DT pool, verified A-share fund flow, boards.")
8286
@_date_opt
8387
@_refresh_opt
@@ -313,7 +317,7 @@ def _run_llm_replay(date_str: str, kind: str = "replay", symbol: str | None = No
313317
def replay(date: str | None, refresh: bool) -> None:
314318
if refresh:
315319
_core.NO_CACHE = True
316-
_run_llm_replay(date or _core.nearest_trade_date())
320+
_run_llm_replay(date or _current_report_date())
317321

318322

319323
@cli.command(help="Generate deep analysis for one stock using verified young-stock data.")
@@ -520,7 +524,7 @@ def report(date: str | None) -> None:
520524

521525
try:
522526
markdown_path, pdf_path = export_report_pdf(
523-
date or _core.nearest_trade_date(),
527+
date or _current_report_date(),
524528
core=_core,
525529
profile=load_profile(),
526530
)

src/young_stock/pdf.py

Lines changed: 71 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
from __future__ import annotations
44

55
import contextlib
6+
from datetime import datetime
67
import html
78
import io
9+
import json
810
import os
911
import re
1012
import sys
@@ -15,6 +17,7 @@
1517

1618
from .artifacts import ReportArtifacts, ReportIdentity, market_session
1719
from .local_store import load_store
20+
from .research_style import sanitize_public_report
1821

1922

2023
class PDFDependencyError(RuntimeError):
@@ -94,6 +97,63 @@ def _template_text() -> str:
9497
)
9598

9699

100+
def _parse_identity_from_path(path: Path) -> ReportIdentity | None:
101+
match = re.match(r"^(?P<trade_date>\d{8})-(?P<session>早盘|午间|盘中|盘后)-(?P<topic>.+)\.md$", path.name)
102+
if not match:
103+
return None
104+
return ReportIdentity(match.group("trade_date"), match.group("session"), match.group("topic"))
105+
106+
107+
def _report_title(markdown: str, trade_date: str) -> str:
108+
title_match = re.search(r"^#\s+(.+)$", markdown, flags=re.MULTILINE)
109+
return title_match.group(1).strip() if title_match else f"{trade_date} 投资复盘报告"
110+
111+
112+
def _report_topic(markdown: str, fallback: str = "A股投资日报") -> str:
113+
title = re.sub(r"\s+", "", _report_title(markdown, "")).strip("::-—")
114+
if title in {"", "复盘", "深度复盘"}:
115+
return "A股深度复盘"
116+
if "日报" in title:
117+
return "A股投资日报"
118+
return title or fallback
119+
120+
121+
def _load_related_evidence(markdown_path: Path) -> dict[str, Any] | None:
122+
candidates = []
123+
if markdown_path.stem in {"replay", "report"}:
124+
candidates.append(markdown_path.with_name("evidence.json"))
125+
candidates.append(markdown_path.with_name(f"{markdown_path.stem}-evidence.json"))
126+
for candidate in candidates:
127+
if not candidate.exists():
128+
continue
129+
try:
130+
return json.loads(candidate.read_text(encoding="utf-8"))
131+
except json.JSONDecodeError:
132+
return None
133+
return None
134+
135+
136+
def _canonicalize_markdown(
137+
artifacts: ReportArtifacts,
138+
trade_date: str,
139+
markdown_path: Path,
140+
markdown: str,
141+
*,
142+
now: datetime | None = None,
143+
) -> tuple[Path, str]:
144+
identity = _parse_identity_from_path(markdown_path)
145+
evidence = _load_related_evidence(markdown_path)
146+
cleaned = sanitize_public_report(markdown, evidence)
147+
session = identity.session if identity else market_session(now)
148+
topic = identity.topic if identity else _report_topic(cleaned)
149+
canonical = artifacts.write_report_markdown(ReportIdentity(trade_date, session, topic), cleaned)
150+
return canonical, cleaned
151+
152+
153+
def _clean_document_html(document: str) -> str:
154+
return re.sub(r"Kami-compatible editorial layout\s*·?\s*", "", document, flags=re.IGNORECASE)
155+
156+
97157
def _load_weasyprint() -> Any:
98158
os.environ.setdefault("XDG_CACHE_HOME", str(Path(tempfile.gettempdir()) / "young-stock-weasy-cache"))
99159
if sys.platform == "darwin":
@@ -116,9 +176,9 @@ def _default_render(html_path: Path, pdf_path: Path) -> None:
116176
renderer = _load_weasyprint()
117177
if renderer is None:
118178
raise PDFDependencyError(
119-
"未安装 PDF 可选依赖。uv tool 用户请运行 "
120-
"`uv tool install --force 'young-stock-cli[pdf]'`"
121-
"普通 Python 环境请运行 `python3 -m pip install \"young-stock-cli[pdf]\"`。"
179+
"当前环境未检测到 PDF 渲染能力。请先运行 `young init` 检查安装状态;"
180+
"若仍缺少依赖,uv tool 用户请执行 `uv tool install --force 'young-stock-cli[pdf]'`"
181+
"普通 Python 环境请执行 `python3 -m pip install \"young-stock-cli[pdf]\"`。"
122182
)
123183
renderer(filename=str(html_path), base_url=str(html_path.parent)).write_pdf(str(pdf_path))
124184

@@ -145,6 +205,7 @@ def export_report_pdf(
145205
profile: dict[str, Any] | None = None,
146206
daily_markdown_factory: Callable[[], str] | None = None,
147207
render: Callable[[Path, Path], None] | None = None,
208+
now: datetime | None = None,
148209
) -> tuple[Path, Path]:
149210
artifacts = ReportArtifacts(trade_date)
150211
markdown_path = ReportArtifacts.latest_markdown(trade_date)
@@ -159,13 +220,15 @@ def export_report_pdf(
159220
markdown = _capture_daily(core, trade_date, profile)
160221
else:
161222
raise ValueError("没有可用报告;请先运行 `young daily` 或提供日报生成器。")
162-
identity = ReportIdentity(trade_date, market_session(), "A股投资日报")
163-
markdown_path = artifacts.write_report_markdown(identity, markdown)
223+
markdown_path = artifacts.write_report_markdown(
224+
ReportIdentity(trade_date, market_session(now), "A股投资日报"),
225+
sanitize_public_report(markdown),
226+
)
164227
markdown = markdown_path.read_text(encoding="utf-8")
228+
markdown_path, markdown = _canonicalize_markdown(artifacts, trade_date, markdown_path, markdown, now=now)
165229
body = markdown_to_html(markdown)
166-
title_match = re.search(r"^#\s+(.+)$", markdown, flags=re.MULTILINE)
167-
title = title_match.group(1).strip() if title_match else f"{trade_date} 投资复盘报告"
168-
document = (
230+
title = _report_title(markdown, trade_date)
231+
document = _clean_document_html(
169232
_template_text()
170233
.replace("{{TITLE}}", html.escape(title))
171234
.replace("{{DATE}}", trade_date)

src/young_stock/reports.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
)
2323

2424
LLM_REPORT_PROMPT_VERSION = "stock-analysis-research-language-v2"
25-
LLM_REPORT_SYSTEM_PROMPT = """你是一名资深A股交易员、量化研究员和风险控制专家
25+
LLM_REPORT_SYSTEM_PROMPT = """请基于用户提供的研报证据,撰写正式 A 股投资研究报告
2626
你只能使用用户提供的研报证据,不得补写或外推任何缺失数字、日期、来源或持仓。
2727
按以下顺序输出 Markdown:大盘指数概览、持仓分析、六模块深度复盘、综合持仓建议与风险提示。
2828
每个有证据的模块给出关键判断、证据、风险/确认条件;建议必须是条件化触发器,不给无条件买卖指令。

src/young_stock/research_style.py

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@
5858
r"\bM[1-6]\s*(?:降级|缺失|不可用)",
5959
)
6060

61+
EDITORIAL_NOISE_PATTERNS = (
62+
r"^\s*(?:好的[,,、]?)?.*资深A股交易员.*$",
63+
r"Kami-compatible editorial layout",
64+
)
65+
6166

6267
class ResearchStyleError(RuntimeError):
6368
"""Report still contains publication-unsafe internal language."""
@@ -154,23 +159,49 @@ def _unsafe(line: str) -> bool:
154159
return any(re.search(pattern, line, flags=re.IGNORECASE) for pattern in FORBIDDEN_PATTERNS)
155160

156161

157-
def review_research_report(markdown: str, evidence: dict[str, Any]) -> str:
158-
"""Remove unsafe sentences, add evidence-backed research wording, and validate."""
159-
internal_fields = re.findall(r"\bmodules\.([A-Za-z0-9_]+)\.([A-Za-z0-9_.]+)", markdown)
160-
known_modules = set((evidence.get("modules") or {}).keys())
161-
if any(module not in known_modules for module, _field in internal_fields):
162-
raise ResearchStyleError("正式研报包含无法转换的内部字段。")
162+
def _editorial_noise(line: str) -> bool:
163+
return any(re.search(pattern, line, flags=re.IGNORECASE) for pattern in EDITORIAL_NOISE_PATTERNS)
164+
165+
166+
def _normalized_line(line: str) -> str:
167+
return line.replace("`", "")
168+
169+
170+
def sanitize_public_report(markdown: str, evidence: dict[str, Any] | None = None, *, strict: bool = False) -> str:
171+
evidence = evidence or {}
172+
normalized_markdown = _normalized_line(markdown)
173+
if strict:
174+
internal_fields = re.findall(r"\bmodules\.([A-Za-z0-9_]+)\.([A-Za-z0-9_.]+)", normalized_markdown)
175+
known_modules = set((evidence.get("modules") or {}).keys())
176+
if any(module not in known_modules for module, _field in internal_fields):
177+
raise ResearchStyleError("正式研报包含无法转换的内部字段。")
178+
163179
safe_lines = []
164-
removed = False
180+
removed_unsafe = False
181+
in_code_block = False
165182
for line in markdown.splitlines():
166-
if _unsafe(line):
167-
removed = True
183+
stripped = line.strip()
184+
if stripped.startswith("```"):
185+
in_code_block = not in_code_block
186+
removed_unsafe = True
187+
continue
188+
normalized = _normalized_line(line)
189+
if in_code_block or _editorial_noise(normalized):
190+
continue
191+
if _unsafe(normalized):
192+
removed_unsafe = True
168193
continue
169194
safe_lines.append(line)
170-
if removed:
195+
if removed_unsafe:
171196
insert_at = 1 if safe_lines and safe_lines[0].startswith("#") else 0
172197
safe_lines[insert_at:insert_at] = ["", *_replacement_lines(evidence), ""]
173198
reviewed = "\n".join(safe_lines).strip()
174-
if _unsafe(reviewed):
199+
validation_lines = [_normalized_line(line) for line in reviewed.splitlines()]
200+
if any(_unsafe(line) or _editorial_noise(line) for line in validation_lines):
175201
raise ResearchStyleError("正式研报未通过研究语言审校。")
176202
return reviewed
203+
204+
205+
def review_research_report(markdown: str, evidence: dict[str, Any]) -> str:
206+
"""Remove unsafe sentences, add evidence-backed research wording, and validate."""
207+
return sanitize_public_report(markdown, evidence, strict=True)

tests/test_artifacts.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,10 @@ def test_generate_llm_daily_report_uses_evidence_and_returns_metadata():
4747

4848
def test_market_session_labels():
4949
assert market_session(datetime(2026, 6, 18, 9, 10)) == "早盘"
50-
assert market_session(datetime(2026, 6, 18, 10, 30)) == "盘中"
50+
assert market_session(datetime(2026, 6, 18, 9, 41)) == "早盘"
51+
assert market_session(datetime(2026, 6, 18, 10, 30)) == "早盘"
5152
assert market_session(datetime(2026, 6, 18, 12, 0)) == "午间"
53+
assert market_session(datetime(2026, 6, 18, 14, 10)) == "盘中"
5254
assert market_session(datetime(2026, 6, 18, 15, 10)) == "盘后"
5355

5456

@@ -70,3 +72,14 @@ def test_same_session_topic_overwrites_and_cross_session_is_retained(monkeypatch
7072
assert first == second
7173
assert second.read_text(encoding="utf-8") == "# new\n"
7274
assert other.exists()
75+
76+
77+
def test_latest_markdown_prefers_identity_path_over_newer_legacy_file(monkeypatch, tmp_path):
78+
monkeypatch.setenv("YOUNG_STOCK_HOME", str(tmp_path))
79+
artifacts = ReportArtifacts("20260619")
80+
legacy = artifacts.write_markdown("replay", "# legacy\n")
81+
identified = artifacts.write_report_markdown(ReportIdentity("20260619", "早盘", "A股深度复盘"), "# identity\n")
82+
83+
legacy.touch()
84+
85+
assert ReportArtifacts.latest_markdown("20260619") == identified

tests/test_llm_reports.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,19 @@ def test_llm_report_output_removes_engineering_language():
7474
assert "备用路径" not in markdown
7575

7676

77+
def test_llm_report_output_strips_fixed_preamble():
78+
client = RecordingClient(
79+
"# 复盘\n\n"
80+
"好的,作为资深A股交易员,以下是今天的复盘。\n"
81+
"据公开市场数据,板块轮动加快。\n"
82+
)
83+
84+
markdown, _ = generate_llm_daily_report({"modules": {}, "_meta": {}}, client)
85+
86+
assert "资深A股交易员" not in markdown
87+
assert "板块轮动加快" in markdown
88+
89+
7790
def test_llm_methodology_context_is_research_only():
7891
client = RecordingClient("# 复盘\n\n据公开市场数据,市场震荡。")
7992

@@ -84,6 +97,7 @@ def test_llm_methodology_context_is_research_only():
8497
)
8598

8699
system_text = "\n".join(message["content"] for message in client.messages if message["role"] == "system")
100+
assert "资深A股交易员" not in system_text
87101
assert "报告固定顺序" in system_text
88102
assert "fallback" not in system_text
89103
assert "脚本" not in system_text

tests/test_pdf.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,10 @@ def fake_render(html_path: Path, pdf_path: Path):
2424

2525
markdown_path, pdf_path = export_report_pdf("20260618", render=fake_render)
2626

27-
assert markdown_path == source
27+
assert markdown_path != source
28+
assert markdown_path.name.startswith("20260618-")
2829
assert pdf_path.read_bytes().startswith(b"%PDF")
29-
assert calls[0][0].name == "replay.html"
30+
assert calls[0][0].name.startswith("20260618-")
3031

3132

3233
def test_export_report_auto_generates_daily_markdown(monkeypatch, tmp_path):
@@ -60,10 +61,27 @@ def test_export_report_reuses_saved_diary_text(monkeypatch, tmp_path):
6061
assert "Diary report" in markdown_path.read_text()
6162

6263

64+
def test_export_report_strips_layout_noise_and_fixed_preamble(monkeypatch, tmp_path):
65+
monkeypatch.setenv("YOUNG_STOCK_HOME", str(tmp_path))
66+
ReportArtifacts("20260618").write_markdown(
67+
"replay",
68+
"# 复盘\n\n好的,作为资深A股交易员,以下是今天的报告。\nKami-compatible editorial layout · 内容仅供复盘参考\n正文\n",
69+
)
70+
71+
markdown_path, pdf_path = export_report_pdf(
72+
"20260618",
73+
render=lambda html, pdf: pdf.write_bytes(b"%PDF-clean"),
74+
)
75+
76+
assert "资深A股交易员" not in markdown_path.read_text(encoding="utf-8")
77+
html_text = pdf_path.with_suffix(".html").read_text(encoding="utf-8")
78+
assert "Kami-compatible editorial layout" not in html_text
79+
80+
6381
def test_default_renderer_has_clear_optional_dependency_error(monkeypatch, tmp_path):
6482
monkeypatch.setenv("YOUNG_STOCK_HOME", str(tmp_path))
6583
ReportArtifacts("20260618").write_markdown("daily", "# 日报")
6684
monkeypatch.setattr("young_stock.pdf._load_weasyprint", lambda: None)
6785

68-
with pytest.raises(PDFDependencyError, match=r"uv tool install --force"):
86+
with pytest.raises(PDFDependencyError, match=r"young init"):
6987
export_report_pdf("20260618")

tests/test_report_routing.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from pathlib import Path
2+
3+
from click.testing import CliRunner
4+
5+
import young_stock.cli as cli_module
6+
from young_stock.cli import cli
7+
8+
9+
def test_replay_and_report_default_to_current_calendar_date(monkeypatch, tmp_path):
10+
monkeypatch.setenv("YOUNG_STOCK_HOME", str(tmp_path))
11+
monkeypatch.setattr(cli_module._core, "nearest_trade_date", lambda: "20260618")
12+
monkeypatch.setattr(cli_module, "_current_report_date", lambda: "20260619")
13+
14+
replay_calls = []
15+
monkeypatch.setattr(cli_module, "_run_llm_replay", lambda date_str, kind="replay", symbol=None: replay_calls.append((date_str, kind, symbol)))
16+
monkeypatch.setattr(
17+
"young_stock.pdf.export_report_pdf",
18+
lambda trade_date, **kwargs: (Path(f"/tmp/{trade_date}.md"), Path(f"/tmp/{trade_date}.pdf")),
19+
)
20+
21+
runner = CliRunner()
22+
replay_result = runner.invoke(cli, ["replay"])
23+
report_result = runner.invoke(cli, ["report"])
24+
25+
assert replay_result.exit_code == 0
26+
assert report_result.exit_code == 0
27+
assert replay_calls == [("20260619", "replay", None)]
28+
assert "/tmp/20260619.pdf" in report_result.output

tests/test_research_style.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,21 @@ def test_review_research_report_handles_markdown_wrapped_engineering_phrases():
7373
assert "科创板与创业板活跃样本数为 14 家" in reviewed
7474

7575

76+
def test_review_research_report_strips_fixed_preamble_and_layout_noise():
77+
markdown = (
78+
"# 复盘\n\n"
79+
"好的,作为资深A股交易员,以下是今天的正式报告。\n"
80+
"据公开市场数据,市场震荡整理。\n"
81+
"Kami-compatible editorial layout · 内容仅供复盘参考\n"
82+
)
83+
84+
reviewed = review_research_report(markdown, sample_evidence())
85+
86+
assert "资深A股交易员" not in reviewed
87+
assert "Kami-compatible editorial layout" not in reviewed
88+
assert "市场震荡整理" in reviewed
89+
90+
7691
def test_review_research_report_rejects_unrecognized_internal_field():
7792
with pytest.raises(ResearchStyleError):
7893
review_research_report("# 复盘\n\n`modules.UNKNOWN.secret` 为 true。", sample_evidence())

0 commit comments

Comments
 (0)