Skip to content

Commit e91dce9

Browse files
yjwyjw
authored andcommitted
fix: harden chat time grounding for 0.2.8
1 parent 5a75bdd commit e91dce9

7 files changed

Lines changed: 340 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,18 @@ 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.6] - 2026-06-19
8+
## [0.2.8] - 2026-06-19
9+
10+
### Added
11+
- `young chat` now cross-checks Beijing time with both local clock and lightweight online HTTP Date sources, using whichever path is available as a safe fallback.
12+
13+
### Changed
14+
- Chat time grounding now refreshes with a five-minute verification cache so relative phrases like “今天”“当前”“最新” stay anchored to current Beijing time without adding heavy network overhead.
15+
- Chat now auto-invokes the existing `young reach` bridge only for explicit search/latest-news/company-info requests, then feeds the result back into the LLM as evidence instead of claiming it cannot search.
16+
17+
### Fixed
18+
- Fixed chat answers that could hallucinate stale absolute dates when users asked for the current date or time.
19+
- Fixed interactive input deletion issues by switching the chat prompt loop away from `Rich Prompt.ask()` to a simpler console input path with native line-edit support.
920

1021
## [0.2.7] - 2026-06-19
1122

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.7"
7+
version = "0.2.8"
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.7"
3+
__version__ = "0.2.8"

src/young_stock/chat.py

Lines changed: 237 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,26 @@
55
import re
66
import shlex
77
from dataclasses import dataclass, field
8+
from datetime import datetime, timedelta, timezone
9+
from email.utils import parsedate_to_datetime
10+
from statistics import median
811
from typing import Callable
12+
from urllib.error import URLError
13+
from urllib.request import Request, urlopen
914

1015
from click.testing import CliRunner
1116
from rich.console import Console
1217
from rich.markdown import Markdown
13-
from rich.prompt import Prompt
18+
19+
try: # ponytail: best-effort line editing on local terminals; falls back harmlessly if unavailable.
20+
import readline # noqa: F401
21+
except Exception: # pragma: no cover - platform dependent
22+
readline = None
23+
24+
try: # pragma: no cover - import path differs across Python builds
25+
from zoneinfo import ZoneInfo
26+
except ImportError: # pragma: no cover - Python < 3.9 fallback safety
27+
ZoneInfo = None # type: ignore[assignment]
1428

1529
from .config import load_config, save_config
1630
from .llm import LLMClient, LLMError
@@ -127,6 +141,193 @@
127141
"格雷厄姆",
128142
"达利欧",
129143
)
144+
_BEIJING_TZ = ZoneInfo("Asia/Shanghai") if ZoneInfo else timezone(timedelta(hours=8))
145+
_TIME_QUERY_HINTS = (
146+
"现在几点",
147+
"现在几号",
148+
"今天几号",
149+
"今天几月几号",
150+
"当前时间",
151+
"当前日期",
152+
"北京时间",
153+
"日期",
154+
"时间",
155+
"星期几",
156+
)
157+
_SEARCH_INTENT_HINTS = (
158+
"搜索",
159+
"搜一下",
160+
"搜搜",
161+
"查一下",
162+
"查查",
163+
"查一查",
164+
"帮我查",
165+
"帮我搜",
166+
"lookup",
167+
"search",
168+
)
169+
_SEARCH_TOPIC_HINTS = (
170+
"新闻",
171+
"资讯",
172+
"公告",
173+
"财报",
174+
"盈利",
175+
"业绩",
176+
"研报",
177+
"公司情况",
178+
"公司信息",
179+
"最新",
180+
)
181+
_NETWORK_TIME_URLS = (
182+
"https://www.baidu.com",
183+
"https://www.qq.com",
184+
"https://www.gov.cn",
185+
)
186+
_TIME_VERIFICATION_CACHE: dict[str, object] = {
187+
"verified_at": None,
188+
"network_now": None,
189+
"local_now": None,
190+
}
191+
_TIME_VERIFICATION_TTL = timedelta(minutes=5)
192+
193+
194+
def beijing_now() -> datetime:
195+
return current_time_snapshot()["current"]
196+
197+
198+
def _local_beijing_now() -> datetime:
199+
return datetime.now(_BEIJING_TZ)
200+
201+
202+
def _http_date_now(url: str, timeout: float = 2.0) -> datetime | None:
203+
try:
204+
request = Request(url, method="HEAD", headers={"User-Agent": "young-stock-cli/0.2"})
205+
with urlopen(request, timeout=timeout) as response:
206+
date_header = response.headers.get("Date")
207+
except (URLError, TimeoutError, ValueError):
208+
return None
209+
if not date_header:
210+
return None
211+
try:
212+
parsed = parsedate_to_datetime(date_header)
213+
except (TypeError, ValueError, IndexError):
214+
return None
215+
if parsed.tzinfo is None:
216+
parsed = parsed.replace(tzinfo=timezone.utc)
217+
return parsed.astimezone(_BEIJING_TZ)
218+
219+
220+
def _median_network_time() -> datetime | None:
221+
samples = [sample for sample in (_http_date_now(url) for url in _NETWORK_TIME_URLS) if sample is not None]
222+
if not samples:
223+
return None
224+
if len(samples) == 1:
225+
return samples[0]
226+
timestamps = [sample.timestamp() for sample in samples]
227+
return datetime.fromtimestamp(median(timestamps), tz=_BEIJING_TZ)
228+
229+
230+
def current_time_snapshot() -> dict[str, object]:
231+
local_now = _local_beijing_now()
232+
verified_at = _TIME_VERIFICATION_CACHE.get("verified_at")
233+
if isinstance(verified_at, datetime) and local_now - verified_at <= _TIME_VERIFICATION_TTL:
234+
network_now = _TIME_VERIFICATION_CACHE.get("network_now")
235+
cached_local = _TIME_VERIFICATION_CACHE.get("local_now")
236+
if isinstance(network_now, datetime) and isinstance(cached_local, datetime):
237+
adjusted = network_now + (local_now - cached_local)
238+
diff_seconds = int(abs((adjusted - local_now).total_seconds()))
239+
return {
240+
"current": adjusted,
241+
"source": "network+local",
242+
"local": local_now,
243+
"network": adjusted,
244+
"diff_seconds": diff_seconds,
245+
}
246+
network_now = _median_network_time()
247+
_TIME_VERIFICATION_CACHE["verified_at"] = local_now
248+
_TIME_VERIFICATION_CACHE["local_now"] = local_now
249+
_TIME_VERIFICATION_CACHE["network_now"] = network_now
250+
if network_now is None:
251+
return {
252+
"current": local_now,
253+
"source": "local-only",
254+
"local": local_now,
255+
"network": None,
256+
"diff_seconds": None,
257+
}
258+
diff_seconds = int(abs((network_now - local_now).total_seconds()))
259+
return {
260+
"current": network_now,
261+
"source": "network+local",
262+
"local": local_now,
263+
"network": network_now,
264+
"diff_seconds": diff_seconds,
265+
}
266+
267+
268+
def _weekday_label(dt: datetime) -> str:
269+
return "一二三四五六日"[dt.weekday()]
270+
271+
272+
def _current_time_system_note() -> str:
273+
snapshot = current_time_snapshot()
274+
now = snapshot["current"]
275+
assert isinstance(now, datetime)
276+
source = "已用联网时钟与本地时钟交叉校验" if snapshot["source"] == "network+local" else "当前仅使用本地时钟"
277+
diff_seconds = snapshot.get("diff_seconds")
278+
verification = f"{source}。" if diff_seconds in (None, 0) else f"{source} 两者偏差约 {diff_seconds} 秒。"
279+
return (
280+
f"当前系统时间(北京时间,UTC+8)是 {now:%Y-%m-%d %H:%M:%S},星期{_weekday_label(now)}。"
281+
f"{verification}"
282+
"涉及“今天”“当前”“最近”“最新”这类相对时间时,必须以这个北京时间为准,不要自行猜测日期。"
283+
)
284+
285+
286+
def _is_time_query(text: str) -> bool:
287+
stripped = text.strip()
288+
if not stripped or stripped.startswith("/"):
289+
return False
290+
if any(hint in stripped for hint in _INVESTMENT_HINTS):
291+
return False
292+
return any(hint in stripped.lower() for hint in _TIME_QUERY_HINTS)
293+
294+
295+
def _format_time_answer(text: str) -> str:
296+
snapshot = current_time_snapshot()
297+
now = snapshot["current"]
298+
assert isinstance(now, datetime)
299+
compact = re.sub(r"\s+", "", text)
300+
date_label = f"{now.year}{now.month}{now.day} 日"
301+
wants_weekday = "星期" in compact or "周几" in compact
302+
wants_time = any(token in compact for token in ("几点", "时间", "几点了", "现在"))
303+
wants_date = any(token in compact for token in ("几号", "日期", "几月几号", "哪天", "今天"))
304+
if wants_date and not wants_time:
305+
base = f"当前北京时间日期是 {date_label}"
306+
elif wants_time and not wants_date:
307+
base = f"当前北京时间是 {date_label} {now:%H:%M:%S}"
308+
else:
309+
base = f"当前北京时间是 {date_label} {now:%H:%M:%S}"
310+
if wants_weekday or wants_date:
311+
base += f",星期{_weekday_label(now)}"
312+
if snapshot["source"] == "network+local":
313+
diff_seconds = snapshot.get("diff_seconds")
314+
suffix = "(已用联网时钟与本地时钟交叉校验)"
315+
if isinstance(diff_seconds, int) and diff_seconds > 0:
316+
suffix = f"(已用联网时钟与本地时钟交叉校验,偏差约 {diff_seconds} 秒)"
317+
return base + suffix + "。"
318+
return base + "(当前仅使用本地时钟)。"
319+
320+
321+
def _should_auto_reach(text: str) -> bool:
322+
stripped = text.strip()
323+
if not stripped or stripped.startswith("/"):
324+
return False
325+
lowered = stripped.lower()
326+
explicit_search = any(hint in lowered for hint in _SEARCH_INTENT_HINTS)
327+
topical_latest = any(hint in stripped for hint in _SEARCH_TOPIC_HINTS) and any(
328+
token in stripped for token in ("最新", "今天", "近期", "公司", "新闻", "公告", "盈利", "财报", "业绩")
329+
)
330+
return explicit_search or topical_latest
130331

131332

132333
def _empty_long_term_memory() -> dict[str, list[dict[str, str]]]:
@@ -365,7 +566,7 @@ def capture_long_term_memory(self, text: str) -> int:
365566
save_long_term_memory(self.long_term_memory)
366567
return updates
367568

368-
def _build_messages(self) -> list[dict[str, str]]:
569+
def _build_messages(self, extra_system_messages: list[str] | None = None) -> list[dict[str, str]]:
369570
messages = [
370571
{
371572
"role": "system",
@@ -381,7 +582,11 @@ def _build_messages(self) -> list[dict[str, str]]:
381582
),
382583
}
383584
]
585+
messages.append({"role": "system", "content": _current_time_system_note()})
384586
messages.append({"role": "system", "content": _build_style_prompt(self.style_name)})
587+
for item in extra_system_messages or []:
588+
if item.strip():
589+
messages.append({"role": "system", "content": item})
385590
long_term = _format_long_term_memory_for_prompt(self.long_term_memory)
386591
if long_term:
387592
messages.append({"role": "system", "content": long_term})
@@ -477,24 +682,49 @@ def handle_slash(self, text: str) -> bool:
477682
self.remember("assistant", command_output)
478683
return False
479684

480-
def _invoke_click(self, args: list[str]) -> str:
685+
def _invoke_click(self, args: list[str], *, echo: bool = True) -> str:
481686
from .cli import cli
482687

483688
result = CliRunner().invoke(cli, args, color=False)
484689
text_output = result.output.rstrip()
485-
if text_output:
690+
if text_output and echo:
486691
self.output(text_output)
487-
if result.exception and not text_output:
692+
if result.exception and not text_output and echo:
488693
self.output(str(result.exception))
694+
if result.exception and not text_output:
489695
return str(result.exception)
490696
return text_output
491697

698+
def _maybe_collect_reach_context(self, text: str) -> str | None:
699+
if not _should_auto_reach(text):
700+
return None
701+
reach_output = self._invoke_click(["reach", text], echo=False).strip()
702+
if not reach_output:
703+
return None
704+
if any(hint in reach_output for hint in ("未检测到 `mcporter`", "未检测到 `agent-reach`", "请先安装")):
705+
return None
706+
truncated = reach_output[:6000]
707+
return (
708+
"以下内容来自本轮自动执行的 young reach 外部搜索结果。"
709+
"只能基于其中可见信息做总结,不要补造未出现的事实;若证据不足请明确说明。\n"
710+
f"{truncated}"
711+
)
712+
492713
def handle_message(self, text: str) -> None:
493714
self.capture_long_term_memory(text)
494715
self.remember("user", text)
716+
if _is_time_query(text):
717+
answer = _format_time_answer(text)
718+
self.remember("assistant", answer)
719+
self.output(answer)
720+
return
495721
config = load_config(strict=False).get("llm", {})
722+
extra_system_messages = []
723+
reach_context = self._maybe_collect_reach_context(text)
724+
if reach_context:
725+
extra_system_messages.append(reach_context)
496726
try:
497-
response = LLMClient(config).chat(self._build_messages())
727+
response = LLMClient(config).chat(self._build_messages(extra_system_messages))
498728
except LLMError as exc:
499729
self.output(str(exc))
500730
return
@@ -515,7 +745,7 @@ def run_chat() -> None:
515745
)
516746
while True:
517747
try:
518-
text = Prompt.ask("[bold cyan]young[/]").strip()
748+
text = console.input("[bold cyan]young[/] ").strip()
519749
except (EOFError, KeyboardInterrupt):
520750
console.print("\n再见。")
521751
return

0 commit comments

Comments
 (0)