Skip to content

Commit 316b02f

Browse files
feat: add llm chat reports and delivery (#6)
Adds optional LLM configuration, Rich chat/slash commands, evidence-driven replay and stock analysis, Kami-compatible PDF export, and Feishu delivery.
1 parent 4b32196 commit 316b02f

25 files changed

Lines changed: 2048 additions & 7 deletions

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.2.0] - 2026-06-18
9+
10+
### Added
11+
- Added versioned LLM and delivery-channel configuration in `~/.young_stock/config.json`, including masked display and environment-based API key lookup.
12+
- Added OpenAI-compatible providers (OpenAI, DeepSeek, Qwen, Ollama) plus Anthropic message support with timeouts, retries, and safe errors.
13+
- Added `young chat`, a Rich interactive REPL whose slash commands reuse the existing Click command tree.
14+
- Added evidence-driven `young replay`, `young daily --llm`, and `young analyze <symbol>` workflows based on the stock-analysis M1-M6 methodology and quality-score degradation.
15+
- Added persistent report/evidence artifacts under `~/.young_stock/reports/YYYYMMDD/`.
16+
- Added `young report` with automatic deterministic-report fallback and optional WeasyPrint PDF export using a Kami-compatible Equity Report layout.
17+
- Added modular `young send` delivery with Feishu webhook preview mode and Feishu App Markdown/PDF attachment mode.
18+
19+
### Changed
20+
- Extended `young diagnose --json` with non-secret LLM, PDF, report, and configured-channel readiness.
21+
- Version advanced to 0.2.0 for the new optional AI/reporting surface; all existing commands remain available without LLM or PDF dependencies.
22+
823
## [0.1.22] - 2026-06-15
924

1025
### Changed

README.md

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,12 @@ young profile clear-funds # clear all saved funds only
7070
young daily --format summary # concise personalized daily report
7171
young daily --format key-points # short report with trend/risk points
7272
young daily --format full # full personalized daily report
73+
young daily --llm # evidence-driven deep replay with your configured LLM
74+
young replay # deep M1-M6 market replay
75+
young analyze 600519 # deep single-stock analysis
76+
young chat # Rich chat mode with slash commands
77+
young report # export latest Markdown as a Kami-style PDF
78+
young send # send latest Markdown + PDF to configured channels
7379
young daily --only 基金,A股 --quick
7480
young news 3690.HK # multi-source news only
7581
young diagnose # network/source diagnostic
@@ -92,6 +98,131 @@ young uninstall # uninstall from the current Python env
9298
young --help
9399
```
94100

101+
### LLM configuration and chat
102+
103+
LLM features are optional. Existing market commands do not require a model or API key. Configuration is stored in
104+
`~/.young_stock/config.json` (or `$YOUNG_STOCK_HOME/config.json`).
105+
106+
```bash
107+
# Prefer an environment variable so the key is not stored in config.json
108+
export DEEPSEEK_API_KEY="..."
109+
young config llm \
110+
--provider deepseek \
111+
--model deepseek-chat \
112+
--api-key-env DEEPSEEK_API_KEY
113+
114+
# Other supported providers
115+
young config llm --provider openai --model gpt-4.1 --api-key-env OPENAI_API_KEY
116+
young config llm --provider qwen --model qwen-plus --api-key-env DASHSCOPE_API_KEY
117+
young config llm --provider anthropic --model claude-sonnet-4-20250514 --api-key-env ANTHROPIC_API_KEY
118+
young config llm --provider ollama --model qwen2.5:14b --api-base http://localhost:11434/v1
119+
120+
young config show # secrets are masked
121+
young config path
122+
```
123+
124+
Start the interactive mode with `young chat`. Slash commands reuse the same Click command tree, so traditional CLI
125+
and chat behavior stay aligned:
126+
127+
```text
128+
/a
129+
/stock 600519
130+
/fund 161725
131+
/daily --format summary
132+
/daily --llm
133+
/daily-llm
134+
/replay
135+
/analyze 600519
136+
/profile list
137+
/report
138+
/send
139+
```
140+
141+
The chat keeps the five most recent user/assistant turns. If no LLM is configured, enhanced commands fail with a
142+
configuration hint while `/a`, `/stock`, `/daily`, and every other deterministic command continue working.
143+
144+
### Evidence-driven deep replay
145+
146+
`young replay`, `young daily --llm`, and `/replay` build an Evidence Pack entirely from the structured values returned
147+
by `young_stock._core`. The model is used for synthesis, not data collection.
148+
149+
The report follows the six-module method from
150+
[`AdvancingTitans/stock-analysis`](https://github.com/AdvancingTitans/stock-analysis):
151+
152+
1. M1 index overview
153+
2. M2 sector and fund flow
154+
3. M3 upside/limit-up effect
155+
4. M4 downside and blow-up risk
156+
5. M5 market/portfolio style
157+
6. M6 resilient directions
158+
159+
Each module has an evidence score. Missing fields remain missing rather than being rendered as zero. Low-quality
160+
evidence automatically produces a shorter report limited to verified indices, holdings, risks, and next-session
161+
checks. Markdown, metadata, and `evidence.json` are retained under:
162+
163+
```text
164+
~/.young_stock/reports/YYYYMMDD/
165+
```
166+
167+
### Professional PDF reports
168+
169+
Install the optional renderer:
170+
171+
```bash
172+
python3 -m pip install "young-stock-cli[pdf]"
173+
```
174+
175+
Then export the latest report:
176+
177+
```bash
178+
young report
179+
young report --date 20260618
180+
```
181+
182+
If no Markdown report exists for the selected date, `young report` first reuses a saved diary entry when available,
183+
otherwise it automatically generates a deterministic full daily report. It keeps `daily.md`/`replay.md`,
184+
`report.html`, and `report.pdf` together.
185+
186+
The bundled Equity Report layout follows the
187+
[`tw93/Kami`](https://github.com/tw93/Kami) editorial language: parchment `#f5f4ed`, ink blue `#1B365D`,
188+
serif-led hierarchy, compact rhythm, and print-ready A4 output. To use a locally installed Kami-derived template,
189+
set `YOUNG_STOCK_KAMI_TEMPLATE` to an HTML file containing `{{TITLE}}`, `{{DATE}}`, and `{{BODY}}`.
190+
191+
### Feishu delivery
192+
193+
Webhook mode sends a text preview. Feishu incoming webhooks cannot upload arbitrary PDF attachments:
194+
195+
```bash
196+
young config channel add feishu work \
197+
--webhook "https://open.feishu.cn/open-apis/bot/v2/hook/..."
198+
```
199+
200+
App mode uploads and sends both Markdown and PDF:
201+
202+
```bash
203+
young config channel add feishu research-app \
204+
--app-id "$FEISHU_APP_ID" \
205+
--app-secret "$FEISHU_APP_SECRET" \
206+
--receive-id "$FEISHU_CHAT_ID"
207+
208+
young config channel list
209+
young config channel remove feishu work
210+
```
211+
212+
One-click workflow:
213+
214+
```bash
215+
young replay
216+
young report
217+
young send
218+
219+
# Or target one date/channel
220+
young send --date 20260618 --channel research-app
221+
```
222+
223+
Requests use timeouts and bounded retries. Channel errors are reported independently, and secrets are masked in
224+
configuration and diagnostic output.
225+
95226
### Example output (`young indices`)
96227

97228
```

pyproject.toml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "young-stock-cli"
7-
version = "0.1.22"
7+
version = "0.2.0"
88
description = "A-share (China stock market) after-hours CLI — no login, no scraping tricks, just data."
99
readme = "README.md"
1010
requires-python = ">=3.9"
@@ -41,6 +41,9 @@ Changelog = "https://github.com/AdvancingTitans/young-stock-cli/blob/main/CHANGE
4141
young = "young_stock.cli:cli"
4242

4343
[project.optional-dependencies]
44+
pdf = [
45+
"weasyprint>=62",
46+
]
4447
dev = [
4548
"pytest>=7",
4649
"pytest-cov>=4",
@@ -50,6 +53,12 @@ dev = [
5053
[tool.hatch.build.targets.wheel]
5154
packages = ["src/young_stock"]
5255

56+
[tool.hatch.build]
57+
include = [
58+
"src/young_stock/**/*.py",
59+
"src/young_stock/templates/*.html",
60+
]
61+
5362
[tool.ruff]
5463
line-length = 110
5564
target-version = "py39"

src/young_stock/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""young-stock-cli: A-share after-hours CLI."""
22

3-
__version__ = "0.1.22"
3+
__version__ = "0.2.0"

src/young_stock/artifacts.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""Report artifact paths and persistence."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
from dataclasses import dataclass
7+
from datetime import datetime
8+
from pathlib import Path
9+
from typing import Any
10+
11+
from .local_store import young_home
12+
13+
14+
@dataclass
15+
class ReportArtifacts:
16+
trade_date: str
17+
18+
def __post_init__(self) -> None:
19+
if len(self.trade_date) != 8 or not self.trade_date.isdigit():
20+
raise ValueError("trade_date must use YYYYMMDD")
21+
22+
@property
23+
def directory(self) -> Path:
24+
path = young_home() / "reports" / self.trade_date
25+
path.mkdir(parents=True, exist_ok=True)
26+
return path
27+
28+
def path(self, name: str, suffix: str) -> Path:
29+
safe = "".join(character for character in name if character.isalnum() or character in "-_")
30+
if not safe:
31+
raise ValueError("artifact name is empty")
32+
return self.directory / f"{safe}.{suffix.lstrip('.')}"
33+
34+
def write_markdown(self, name: str, content: str) -> Path:
35+
path = self.path(name, "md")
36+
path.write_text(content.rstrip() + "\n", encoding="utf-8")
37+
return path
38+
39+
def write_json(self, name: str, data: dict[str, Any]) -> Path:
40+
path = self.path(name, "json")
41+
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
42+
return path
43+
44+
@classmethod
45+
def latest_markdown(cls, trade_date: str | None = None) -> Path | None:
46+
reports = young_home() / "reports"
47+
if trade_date:
48+
candidates = list((reports / trade_date).glob("*.md"))
49+
else:
50+
candidates = list(reports.glob("*/*.md"))
51+
return max(candidates, key=lambda path: path.stat().st_mtime) if candidates else None
52+
53+
@classmethod
54+
def latest_date(cls) -> str | None:
55+
reports = young_home() / "reports"
56+
dates = [
57+
path.name
58+
for path in reports.iterdir()
59+
if path.is_dir() and len(path.name) == 8 and path.name.isdigit()
60+
] if reports.exists() else []
61+
return max(dates) if dates else None
62+
63+
def write_metadata(self, data: dict[str, Any]) -> Path:
64+
payload = {"created_at": datetime.now().isoformat(timespec="seconds"), **data}
65+
return self.write_json("metadata", payload)
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""Delivery channel registry."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
7+
from ..artifacts import ReportArtifacts
8+
from ..config import load_config
9+
from .base import DeliveryResult
10+
from .feishu import FeishuChannel
11+
12+
13+
def send_report(trade_date: str | None, *, channel_name: str | None = None) -> list[DeliveryResult]:
14+
if not trade_date:
15+
raise ValueError("没有报告日期;请先运行 `young report`。")
16+
artifacts = ReportArtifacts(trade_date)
17+
markdown = ReportArtifacts.latest_markdown(trade_date)
18+
pdf = artifacts.path("report", "pdf")
19+
if markdown is None or not pdf.exists():
20+
raise ValueError(f"{trade_date} 缺少 Markdown/PDF;请先运行 `young report --date {trade_date}`。")
21+
configs = load_config(strict=False).get("channels", {}).get("feishu", {})
22+
selected = {
23+
name: config
24+
for name, config in configs.items()
25+
if channel_name is None or name == channel_name
26+
}
27+
if not selected:
28+
raise ValueError("未配置匹配的发送渠道;请运行 `young config channel add feishu --help`。")
29+
return [FeishuChannel(name, config).send(Path(markdown), pdf) for name, config in selected.items()]
30+
31+
32+
__all__ = ["DeliveryResult", "send_report"]

src/young_stock/channels/base.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""Channel adapter primitives."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass
6+
7+
8+
@dataclass
9+
class DeliveryResult:
10+
channel: str
11+
target: str
12+
ok: bool
13+
detail: str

0 commit comments

Comments
 (0)