55import re
66import shlex
77from dataclasses import dataclass , field
8+ from datetime import datetime , timedelta , timezone
9+ from email .utils import parsedate_to_datetime
10+ from statistics import median
811from typing import Callable
12+ from urllib .error import URLError
13+ from urllib .request import Request , urlopen
914
1015from click .testing import CliRunner
1116from rich .console import Console
1217from 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
1529from .config import load_config , save_config
1630from .llm import LLMClient , LLMError
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
132333def _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