Skip to content

Commit fa3ff6a

Browse files
yjwyjw
authored andcommitted
fix: harden llm reports and model discovery
1 parent 316b02f commit fa3ff6a

20 files changed

Lines changed: 727 additions & 34 deletions

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,19 @@ 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.1] - 2026-06-18
9+
10+
### Added
11+
- Added `young config models` for provider-neutral model discovery across Ark and other OpenAI-compatible services, Kimi/Moonshot, DeepSeek, Qwen, Anthropic, and Ollama endpoints.
12+
- Added report-time stock-analysis specification checks with safe text-only caching; remote code is never executed.
13+
14+
### Fixed
15+
- Translated internal Evidence Pack fields into research language before LLM synthesis and sanitized engineering terminology from final reports.
16+
- Allowed verified fund-flow data to keep the sector/fund-flow module available when board rankings are temporarily absent.
17+
- Unified `young a` and LLM evidence board routing so both try the lightweight source and then the configured Camofox browser path.
18+
- Converted Camofox board snapshots into the same structured row format used by normal board rankings.
19+
- Expanded uv tool installation and PDF dependency guidance in README and command errors.
20+
821
## [0.2.0] - 2026-06-18
922

1023
### Added

README.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@ Born out of a real workflow: every trading day after close I wanted the same fiv
1717

1818
## Install
1919

20+
Recommended for CLI isolation:
21+
22+
```bash
23+
uv tool install young-stock-cli
24+
```
25+
26+
Or install into the active Python environment:
27+
2028
```bash
2129
python3 -m pip install young-stock-cli
2230
```
@@ -121,6 +129,61 @@ young config show # secrets are masked
121129
young config path
122130
```
123131

132+
Model IDs are provider-specific and change over time. Do not rely on a console display name such as
133+
`Kimi-K2.6` or `ark-code-latest`; query the endpoint and copy an exact returned ID:
134+
135+
```bash
136+
# Use saved provider/api_base/api_key_env settings
137+
young config models
138+
139+
# Ark / Volcano Engine (OpenAI-compatible)
140+
export ARK_API_KEY="..."
141+
young config models \
142+
--provider ark \
143+
--api-key-env ARK_API_KEY
144+
145+
# Kimi / Moonshot
146+
export MOONSHOT_API_KEY="..."
147+
young config models \
148+
--provider kimi \
149+
--api-key-env MOONSHOT_API_KEY
150+
151+
# DeepSeek and Qwen can use their built-in API bases
152+
young config models --provider deepseek --api-key-env DEEPSEEK_API_KEY
153+
young config models --provider qwen --api-key-env DASHSCOPE_API_KEY
154+
155+
# Local Ollama
156+
young config models --provider ollama --api-base http://localhost:11434/v1
157+
```
158+
159+
For endpoint-level troubleshooting, the equivalent OpenAI-compatible request is:
160+
161+
```bash
162+
curl -sS "$API_BASE/models" \
163+
-H "Authorization: Bearer $MODEL_API_KEY" |
164+
python3 -c 'import json,sys; print("\n".join(x["id"] for x in json.load(sys.stdin).get("data", [])))'
165+
166+
# Ark exact example
167+
curl -sS https://ark.cn-beijing.volces.com/api/v3/models \
168+
-H "Authorization: Bearer $ARK_API_KEY" |
169+
python3 -c 'import json,sys; print("\n".join(x["id"] for x in json.load(sys.stdin).get("data", [])))'
170+
171+
# Kimi / Moonshot exact example
172+
curl -sS https://api.moonshot.cn/v1/models \
173+
-H "Authorization: Bearer $MOONSHOT_API_KEY" |
174+
python3 -c 'import json,sys; print("\n".join(x["id"] for x in json.load(sys.stdin).get("data", [])))'
175+
```
176+
177+
For Ark, set `API_BASE=https://ark.cn-beijing.volces.com/api/v3`; for Kimi/Moonshot, set
178+
`API_BASE=https://api.moonshot.cn/v1`. After selecting an ID, pass the same API base to `young config llm`.
179+
Some coding-plan names and web-console aliases are not Chat Completions model IDs.
180+
181+
```bash
182+
# Configure the exact ID returned above
183+
young config llm --provider ark --model "<ark-model-or-endpoint-id>" --api-key-env ARK_API_KEY
184+
young config llm --provider kimi --model "<moonshot-model-id>" --api-key-env MOONSHOT_API_KEY
185+
```
186+
124187
Start the interactive mode with `young chat`. Slash commands reuse the same Click command tree, so traditional CLI
125188
and chat behavior stay aligned:
126189

@@ -156,6 +219,11 @@ The report follows the six-module method from
156219
5. M5 market/portfolio style
157220
6. M6 resilient directions
158221

222+
Before each LLM replay, young checks the remote `stock-analysis` `SKILL.md`. A newer text specification is cached
223+
under `~/.young_stock/methodologies/stock-analysis/` and used for report structure and writing discipline. Remote
224+
code is never executed; if the check is unavailable, the most recent cached specification or the bundled 4.2.0
225+
guidance is used.
226+
159227
Each module has an evidence score. Missing fields remain missing rather than being rendered as zero. Low-quality
160228
evidence automatically produces a shorter report limited to verified indices, holdings, risks, and next-session
161229
checks. Markdown, metadata, and `evidence.json` are retained under:
@@ -168,6 +236,12 @@ checks. Markdown, metadata, and `evidence.json` are retained under:
168236

169237
Install the optional renderer:
170238

239+
```bash
240+
uv tool install --force 'young-stock-cli[pdf]'
241+
```
242+
243+
If `young` was installed into the active Python environment instead:
244+
171245
```bash
172246
python3 -m pip install "young-stock-cli[pdf]"
173247
```

pyproject.toml

Lines changed: 1 addition & 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.2.0"
7+
version = "0.2.1"
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"

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.2.0"
3+
__version__ = "0.2.1"

src/young_stock/_core.py

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2169,6 +2169,17 @@ def fetch_eastmoney_board_list(board_type: str, date_str: str, limit: int = 100)
21692169
return result
21702170

21712171

2172+
def get_board_list(board_type: str, date_str: str, limit: int = 100) -> dict[str, Any]:
2173+
"""Return board rankings through the stock-analysis source order."""
2174+
result = fetch_eastmoney_board_list(board_type, date_str, limit=limit)
2175+
if result.get("rows"):
2176+
return result
2177+
browser_result = camofox_board_list(board_type)
2178+
if browser_result.get("rows"):
2179+
return browser_result
2180+
return result
2181+
2182+
21722183
@retry_on_recoverable(max_retries=MAX_RETRIES, initial_delay=INITIAL_BACKOFF)
21732184
def eastmoney_datacenter(
21742185
report_name: str,
@@ -2709,6 +2720,37 @@ def rank_symbols_by_news_heat(
27092720
# 板块榜(camofox 降级层)
27102721
# ------------------------------------------------------------------
27112722

2723+
def _parse_camofox_board_snapshot(markdown: str) -> list[dict[str, Any]]:
2724+
rows = []
2725+
for line in markdown.splitlines():
2726+
line = line.strip()
2727+
if not line.startswith('row "'):
2728+
continue
2729+
content = line[5:].rstrip('"')
2730+
parts = re.split(r"\s{2,}", content)
2731+
if len(parts) < 4:
2732+
parts = content.split()
2733+
if len(parts) < 3:
2734+
continue
2735+
rank = _safe_int(parts[0])
2736+
if rank is None:
2737+
continue
2738+
rows.append(
2739+
{
2740+
"rank": rank,
2741+
"name": parts[1],
2742+
"change_pct": _safe_float(str(parts[2]).rstrip("%")),
2743+
"up_count": _safe_int(parts[3]) if len(parts) > 3 else None,
2744+
"down_count": _safe_int(parts[4]) if len(parts) > 4 else None,
2745+
"leader": parts[5] if len(parts) > 5 else "",
2746+
"leader_change_pct": (
2747+
_safe_float(str(parts[6]).rstrip("%")) if len(parts) > 6 else None
2748+
),
2749+
}
2750+
)
2751+
return rows
2752+
2753+
27122754
def camofox_board_list(board_type: str = "industry") -> dict[str, Any]:
27132755
base = os.environ.get("CAMOFOX_URL", "http://localhost:9377")
27142756
user_id = os.environ.get("CAMOFOX_USER_ID", "")
@@ -2741,14 +2783,7 @@ def camofox_board_list(board_type: str = "industry") -> dict[str, Any]:
27412783
with urllib.request.urlopen(urllib.request.Request(snap_url, method="GET"), timeout=15) as resp:
27422784
md = resp.read().decode("utf-8", errors="ignore")
27432785

2744-
rows = []
2745-
for line in md.splitlines():
2746-
line = line.strip()
2747-
if line.startswith('row "'):
2748-
content = line[5:].rstrip('"')
2749-
parts = re.split(r"\s{2,}", content)
2750-
if len(parts) >= 4:
2751-
rows.append(parts)
2786+
rows = _parse_camofox_board_snapshot(md)
27522787
return {"board_type": board_type, "rows": rows, "count": len(rows)}
27532788
except Exception as e:
27542789
diag(f"camofox board {board_type}: {e}")
@@ -3927,12 +3962,8 @@ def run_a_share(date_str: str, include_news: bool = True) -> None:
39273962
if include_news:
39283963
print_a_share_news(date_str, zt)
39293964

3930-
industry = fetch_eastmoney_board_list("industry", date_str)
3931-
if not industry.get("rows"):
3932-
industry = camofox_board_list("industry")
3933-
concept = fetch_eastmoney_board_list("concept", date_str)
3934-
if not concept.get("rows"):
3935-
concept = camofox_board_list("concept")
3965+
industry = get_board_list("industry", date_str)
3966+
concept = get_board_list("concept", date_str)
39363967
print_boards(industry, "行业板块涨幅")
39373968
print_boards(concept, "概念板块涨幅")
39383969

src/young_stock/cli.py

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from .evidence import build_daily_evidence, build_stock_evidence
2323
from .llm import LLMClient, LLMError
2424
from .local_store import load_store, now_label, save_store, young_home
25+
from .methodology import sync_stock_analysis_methodology
2526
from .profile import (
2627
add_group,
2728
add_group_item,
@@ -125,7 +126,9 @@ def update(pre: bool, user_install: bool) -> None:
125126
"update failed with exit code "
126127
f"{result.returncode}. young-stock-cli requires Python 3.9+. "
127128
"Check `python3 --version`, then retry with "
128-
"`python3 -m pip install --upgrade young-stock-cli`."
129+
"`python3 -m pip install --upgrade young-stock-cli`;"
130+
"如果 `which young` 指向 uv tool 环境,请运行 "
131+
"`uv tool install --upgrade young-stock-cli`。"
129132
)
130133

131134

@@ -278,10 +281,17 @@ def _run_llm_replay(date_str: str, kind: str = "replay", symbol: str | None = No
278281
artifacts = ReportArtifacts(date_str)
279282
artifacts.write_json("evidence" if not symbol else f"{symbol}-evidence", evidence.to_dict())
280283
config = load_config(strict=False).get("llm", {})
284+
methodology = sync_stock_analysis_methodology()
281285
try:
282-
markdown, metadata = generate_llm_daily_report(evidence.to_dict(), LLMClient(config))
286+
markdown, metadata = generate_llm_daily_report(
287+
evidence.to_dict(),
288+
LLMClient(config),
289+
methodology=methodology.text,
290+
)
283291
except LLMError as exc:
284292
raise click.ClickException(str(exc)) from exc
293+
metadata["stock_analysis_version"] = methodology.version
294+
metadata["stock_analysis_updated"] = methodology.updated
285295
name = f"analyze-{symbol}" if symbol else kind
286296
path = artifacts.write_markdown(name, markdown)
287297
artifacts.write_metadata({"kind": name, **metadata})
@@ -334,7 +344,11 @@ def config_show() -> None:
334344

335345

336346
@config.command("llm", help="Configure the LLM provider and model.")
337-
@click.option("--provider", required=True, type=click.Choice(["openai", "deepseek", "qwen", "ollama", "anthropic"]))
347+
@click.option(
348+
"--provider",
349+
required=True,
350+
type=click.Choice(["openai", "ark", "kimi", "moonshot", "deepseek", "qwen", "ollama", "anthropic"]),
351+
)
338352
@click.option("--model", required=True)
339353
@click.option("--api-key", default=None, hide_input=True)
340354
@click.option("--api-key-env", default=None, help="Environment variable containing the API key.")
@@ -362,6 +376,43 @@ def config_llm(
362376
click.echo(f"LLM configured: provider={provider}; model={model}; config={config_path()}")
363377

364378

379+
@config.command("models", help="List model IDs exposed by an OpenAI-compatible, Anthropic, or Ollama endpoint.")
380+
@click.option(
381+
"--provider",
382+
default=None,
383+
type=click.Choice(["openai", "ark", "kimi", "moonshot", "deepseek", "qwen", "ollama", "anthropic"]),
384+
)
385+
@click.option("--api-key", default=None, hide_input=True)
386+
@click.option("--api-key-env", default=None, help="Environment variable containing the API key.")
387+
@click.option("--api-base", default=None, help="Provider API base, for example https://api.moonshot.cn/v1.")
388+
@click.option("--timeout", default=30, show_default=True, type=float)
389+
def config_models(
390+
provider: str | None,
391+
api_key: str | None,
392+
api_key_env: str | None,
393+
api_base: str | None,
394+
timeout: float,
395+
) -> None:
396+
saved = dict(load_config(strict=False).get("llm") or {})
397+
query = {
398+
**saved,
399+
"provider": provider or saved.get("provider"),
400+
"api_key": api_key if api_key is not None else saved.get("api_key"),
401+
"api_key_env": api_key_env if api_key_env is not None else saved.get("api_key_env"),
402+
"api_base": api_base or saved.get("api_base"),
403+
"timeout": timeout,
404+
}
405+
try:
406+
models = LLMClient(query).list_models()
407+
except LLMError as exc:
408+
raise click.ClickException(str(exc)) from exc
409+
if not models:
410+
click.echo("该服务当前未返回可用模型 ID。")
411+
return
412+
for model_id in models:
413+
click.echo(model_id)
414+
415+
365416
@config.group("channel", help="Manage notification channels.")
366417
def config_channel() -> None:
367418
pass

src/young_stock/evidence.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,26 @@ def _pool(data: dict[str, Any]) -> tuple[int | None, list[dict[str, Any]]]:
6262
return payload.get("tc", len(rows)), rows
6363

6464

65+
def _board_list(core: Any, board_type: str, trade_date: str) -> dict[str, Any]:
66+
if hasattr(core, "get_board_list"):
67+
return _call({}, core.get_board_list, board_type, trade_date, limit=100)
68+
result = _call({}, core.fetch_eastmoney_board_list, board_type, trade_date, limit=100)
69+
if result.get("rows") or not hasattr(core, "camofox_board_list"):
70+
return result
71+
browser_result = _call({}, core.camofox_board_list, board_type)
72+
return browser_result if browser_result.get("rows") else result
73+
74+
75+
def _fund_flow_available(data: dict[str, Any]) -> bool:
76+
if not isinstance(data, dict) or data.get("_unavailable") or data.get("_error"):
77+
return False
78+
return any(
79+
isinstance(value, (int, float)) or bool(value)
80+
for key, value in data.items()
81+
if not str(key).startswith("_") and key != "date"
82+
)
83+
84+
6585
def build_daily_evidence(core: Any, trade_date: str, profile: dict[str, Any] | None = None) -> EvidenceBundle:
6686
profile = profile or {}
6787
a_indices = [_index_dict(item) for item in _call([], core.get_index, trade_date)]
@@ -78,9 +98,9 @@ def build_daily_evidence(core: Any, trade_date: str, profile: dict[str, Any] | N
7898
trade_date,
7999
)
80100
northbound = _call({}, core.fetch_northbound_flow_snapshot, trade_date)
81-
fund_flow = _call({}, core.get_fund_flow, trade_date, strict_date=False)
82-
industry = _call({}, core.fetch_eastmoney_board_list, "industry", trade_date, limit=100)
83-
concept = _call({}, core.fetch_eastmoney_board_list, "concept", trade_date, limit=100)
101+
fund_flow = _call({}, core.get_fund_flow, trade_date, strict_date=True)
102+
industry = _board_list(core, "industry", trade_date)
103+
concept = _board_list(core, "concept", trade_date)
84104
zt = _call({}, core.get_zt_pool, trade_date)
85105
dt = _call({}, core.get_dt_pool, trade_date)
86106
zb = _call({}, core.get_zb_pool, trade_date)
@@ -113,7 +133,7 @@ def build_daily_evidence(core: Any, trade_date: str, profile: dict[str, Any] | N
113133
},
114134
},
115135
"M2": {
116-
"available": bool(board_rows),
136+
"available": bool(board_rows) or _fund_flow_available(fund_flow),
117137
"industry": industry.get("rows") or [],
118138
"concept": concept.get("rows") or [],
119139
"fund_flow": fund_flow,

0 commit comments

Comments
 (0)