Sfoglia il codice sorgente

Merge pull request #604 from CC1227871/feature/StockInsightAgent

[毕业设计] StockInsightAgent - 智能股票分析助手
jjyaoao 1 mese fa
parent
commit
f5f3662c6d

+ 5 - 0
Co-creation-projects/CC1227871-StockInsightAgent/.env.example

@@ -0,0 +1,5 @@
+# LLM API 配置 (兼容 OpenAI 接口, 支持 DeepSeek / Qwen / GLM 等)
+LLM_MODEL_ID=deepseek-chat
+LLM_API_KEY=your-api-key-here
+LLM_BASE_URL=https://api.deepseek.com/v1
+LLM_TIMEOUT=60

+ 15 - 0
Co-creation-projects/CC1227871-StockInsightAgent/.gitignore

@@ -0,0 +1,15 @@
+# API 密钥
+.env
+
+# Python
+__pycache__/
+*.pyc
+
+# 个人数据
+memory/
+skills/
+tool-output/
+
+# IDE
+.idea/
+.vscode/

+ 160 - 0
Co-creation-projects/CC1227871-StockInsightAgent/README.md

@@ -0,0 +1,160 @@
+# StockInsightAgent — 智能股票分析助手
+
+> 融合多源实时数据与大模型推理,从技术面、基本面、消息面三维度综合分析 A 股,输出结构化投资分析报告。
+
+## 📝 项目简介
+
+传统股票分析依赖人工阅读财报、新闻和技术指标,效率低且易受主观情绪影响。StockInsightAgent 基于 Hello-Agents 教程构建,自动获取东方财富/新浪/腾讯的实时行情数据,结合大语言模型对信息进行综合解读,输出包含趋势判断、风险提示、支撑压力位和操作建议的完整分析报告。
+
+- **解决了什么**:信息碎片化、分析效率低、缺乏系统性视角
+- **特色**:三种 AI 范式切换(ReAct / PlanSolve / Reflection),记忆系统个性化,Gradio 前端流式交互
+- **适用场景**:个人投资研究辅助、量化分析入门学习、智能体开发教学参考
+
+## ✨ 核心功能
+
+- **实时行情** — 股价、涨跌幅、成交量、市盈率
+- **历史 K 线** — 日线/周线数据,前复权
+- **技术指标** — MA5/10/20/60、MACD(金叉死叉)、RSI(14)、布林带、支撑压力位
+- **财务报表** — 营收、净利润、ROE、毛利率、资产负债率、每股收益
+- **新闻舆情** — 近期公司及行业新闻
+- **关注列表** — 持久化关注股票,自动个性化分析
+- **分析历史** — 记录每次分析结论,支持跨会话回溯
+- **投资知识库** — 内置 PE/PB/PEG 估值体系、技术指标解读、仓位管理、止损原则、A 股规则
+- **对话上下文管理** — 长对话自动压缩,保持多轮分析连贯
+
+## 🛠️ 技术栈
+
+| 层面 | 技术 |
+|------|------|
+| Agent 框架 | Hello-Agents (ReActAgent / PlanSolveAgent / ReflectionAgent) |
+| 数据源 | akshare (东方财富 / 新浪 / 腾讯接口) |
+| LLM 接入 | OpenAI 兼容接口 (DeepSeek / GPT / 本地模型均可) |
+| 前端 | Gradio Chatbot (流式输出) |
+| 记忆系统 | JSON 持久化 (关注列表 / 历史 / 偏好) |
+| 知识库 | TF-IDF 检索 + 中文 2-gram 分词 |
+| 技术指标 | NumPy + Pandas 自主计算 (非调用外部库) |
+
+**智能体范式**:
+
+| 范式 | 来源 | 特点 |
+|------|------|------|
+| ReAct | 教程第 4 章 | Thought → Action → Observation 循环,工具驱动推理 |
+| Plan-and-Solve | 教程第 4 章 | 先规划分析维度再逐步执行,适合深度研究 |
+| Reflection | 教程第 4 章 | 分析→自我评审→改进,2 轮迭代逼近最优报告 |
+
+## 🚀 快速开始
+
+### 环境要求
+
+- Python 3.10+
+- 兼容 OpenAI 接口的 LLM API (DeepSeek / OpenAI / 其他)
+
+### 安装
+
+```bash
+pip install -r requirements.txt
+```
+
+### 配置
+
+```bash
+cp .env.example .env
+```
+
+编辑 `.env`,填入你的 API:
+
+```env
+LLM_MODEL_ID=deepseek-chat
+LLM_API_KEY=sk-xxxxxxxx
+LLM_BASE_URL=https://api.deepseek.com/v1
+```
+
+### 运行
+
+```bash
+python main.py               # 命令行交互模式
+python app.py                # Web 前端 → http://127.0.0.1:7861
+python main.py "茅台怎么样"   # 命令行快速分析
+```
+
+## 📖 使用示例
+
+```
+![alt text](image.png)
+运行app.py后,在浏览器打开http://127.0.0.1:7861便可进入交互界面
+![alt text](image-1.png)
+或者运行main.py后,便可直接在终端进行交互
+```
+
+### 命令行
+
+```
+Stock> 分析贵州茅台600519当前估值和风险
+Stock> 关注 300750 宁德时代
+Stock> 对比格力电器和美的集团的估值
+```
+
+### Python 调用
+
+```python
+from framework_agent import FrameworkStockAgent
+
+agent = FrameworkStockAgent()
+
+# 快速分析 (ReAct, ~30s)
+print(agent.react("分析比亚迪002594的技术面"))
+
+# 深度分析 (PlanSolve, ~2min)
+print(agent.plan_solve("全面评估中国平安601318的投资价值"))
+
+# 批判分析 (Reflection, ~3min)
+print(agent.reflect("宁德时代300750目前是否值得买入"))
+```
+
+### 工具与记忆
+
+```python
+from memory import memory_add_watchlist, memory_get_watchlist
+from rag import rag_search
+
+memory_add_watchlist("600519|贵州茅台")
+print(memory_get_watchlist())
+print(rag_search("PE估值方法"))
+```
+
+## 📁 项目结构
+
+```
+├── main.py               # CLI 菜单入口 (6 种运行模式)
+├── app.py                # Gradio Web 前端 (流式输出)
+├── framework_agent.py    # 框架版 Agent (15 个工具, 3 种范式)
+├── tools.py              # akshare 数据工具 (行情/K线/财务/指标/新闻)
+├── memory.py             # 记忆系统 (关注列表/分析历史/用户偏好)
+├── rag.py                # 投资知识库 (估值/风控/A股规则, TF-IDF 检索)
+├── context_manager.py    # 对话上下文管理 (自动压缩/Token 控制)
+├── agent.py              # 手写 ReAct 解析+循环
+├── plan_agent.py         # 手写 Planner + Executor
+├── reflection_agent.py   # 手写 Memory + Reflection 循环
+├── llm_client.py         # OpenAI 兼容 LLM 客户端
+└── .env.example          # API 配置模板
+```
+
+## 🎯 项目亮点
+
+1. **18 个可组合工具** — 5 数据 + 7 记忆 + 3 知识库 + 3 上下文,任意组合
+2. **真实数据零模拟** — 所有行情/财报/指标数据来自 akshare 实时接口,不依赖离线数据库
+3. **记忆与知识分离** — 个人关注/偏好持久化为 JSON,投资方法论内置在知识库
+4. **前端流式交互** — 分析过程实时展示工具调用和 LLM 推理,非黑盒等待
+
+## 📄 许可证
+
+MIT License
+
+## 👤 作者
+
+- GitHub: [@CC1227871](https://github.com/CC1227871)
+- Email: 2812624878@qq.com
+
+## 🙏 致谢
+
+感谢 Datawhale 社区和 Hello-Agents 项目!

+ 171 - 0
Co-creation-projects/CC1227871-StockInsightAgent/agent.py

@@ -0,0 +1,171 @@
+"""Step 2: StockInsightAgent — ReAct 范式智能股票分析助手"""
+import re
+from llm_client import HelloAgentsLLM
+from tools import (
+    ToolExecutor, get_realtime_quote, get_historical_data,
+    get_financial_data, calc_indicators, get_news
+)
+
+STOCK_AGENT_PROMPT = """
+你是一个专业的股票分析助手 StockInsightAgent。你可以获取A股实时行情、历史K线、
+财务报表、技术指标和新闻舆情,然后综合这些信息给出分析结论。
+
+可用工具如下:
+{tools}
+
+请严格按照以下格式进行回应:
+
+Thought: 你的思考过程,分析用户需求并规划下一步行动。
+Action: 你决定采取的行动,必须是以下格式之一:
+- `{{tool_name}}[{{tool_input}}]`:调用一个可用工具。
+  工具输入格式说明:
+  - 实时行情: 股票代码 或 股票简称,如 "600519" 或 "贵州茅台"
+  - 历史K线: "代码|周期|天数",如 "600519|daily|60"
+  - 财务数据: 股票代码,如 "600519"
+  - 技术指标: "代码|周期|天数",如 "600519|daily|120"
+  - 新闻舆情: 股票代码,如 "600519"
+- `Finish[最终分析报告]`:当你收集到足够的信息,能够输出完整分析报告时。
+
+分析报告的格式应该包含:
+1. 股票基本概况(最新价、涨跌幅、市值等)
+2. 技术面分析(趋势、均线、MACD、RSI、支撑压力位)
+3. 基本面分析(财务指标解读)
+4. 消息面(近期新闻舆情)
+5. 风险提示
+6. 综合小结
+
+重要:
+- 每次只调用一个工具
+- 如果用户只给名称没给代码,用该名称搜索实时行情就能找到代码
+- 收集到足够信息后输出完整的 Markdown 分析报告
+- 数据异常时如实说明,不要编造
+
+现在,请开始分析:
+Question: {question}
+History: {history}
+"""
+
+
+class StockInsightAgent:
+    """智能股票分析 Agent — ReAct 范式"""
+
+    def __init__(self, llm_client: HelloAgentsLLM, max_steps: int = 8):
+        self.llm_client = llm_client
+        self.tool_executor = ToolExecutor()
+        self.max_steps = max_steps
+        self.history = []
+
+        # 注册 5 个分析工具
+        print("注册工具:")
+        self.tool_executor.registerTool(
+            "GetRealtimeQuote",
+            "获取实时行情(最新价/涨跌幅/成交量/PE/市值)。输入: 股票代码或简称",
+            get_realtime_quote
+        )
+        self.tool_executor.registerTool(
+            "GetHistoricalData",
+            "获取历史K线(OHLCV)。输入格式: '代码|周期|天数',周期=daily/weekly/monthly",
+            get_historical_data
+        )
+        self.tool_executor.registerTool(
+            "GetFinancialData",
+            "获取财务指标(ROE/ROA/毛利率/营收增长等)。输入: 股票代码",
+            get_financial_data
+        )
+        self.tool_executor.registerTool(
+            "CalcIndicators",
+            "计算技术指标(MA/MACD/RSI/布林带/支撑压力位)。输入格式: '代码|周期|天数'",
+            calc_indicators
+        )
+        self.tool_executor.registerTool(
+            "GetNews",
+            "获取近期新闻舆情。输入: 股票代码",
+            get_news
+        )
+        print()
+
+    def run(self, question: str):
+        self.history = []
+        current_step = 0
+
+        print(f"\n{'='*60}")
+        print(f"  [用户]: {question}")
+        print(f"{'='*60}")
+
+        while current_step < self.max_steps:
+            current_step += 1
+            print(f"\n--- 第 {current_step}/{self.max_steps} 步 ---")
+
+            tools_desc = self.tool_executor.getAvailableTools()
+            history_str = "\n".join(self.history) if self.history else "(首次执行,无历史)"
+            prompt = STOCK_AGENT_PROMPT.format(
+                tools=tools_desc, question=question, history=history_str
+            )
+
+            messages = [{"role": "user", "content": prompt}]
+            response_text = self.llm_client.think(messages=messages)
+            if not response_text:
+                print("  LLM 未返回有效响应。")
+                break
+
+            thought, action = self._parse_output(response_text)
+            if thought:
+                print(f"  [思考] {thought}")
+            if not action:
+                print("  未能解析出 Action,流程终止。")
+                break
+
+            if action.startswith("Finish"):
+                final_answer = self._parse_action_input(action)
+                print(f"\n{'='*60}")
+                print(f"  [分析报告]")
+                print(f"{'='*60}")
+                print(final_answer)
+                return final_answer
+
+            tool_name, tool_input = self._parse_action(action)
+            if not tool_name:
+                self.history.append("Observation: Action 格式无效。")
+                continue
+
+            print(f"  [行动] {tool_name}[{tool_input[:60]}{'...' if len(tool_input)>60 else ''}]")
+            tool_func = self.tool_executor.getTool(tool_name)
+            observation = (
+                tool_func(tool_input) if tool_func
+                else f"错误:未找到工具 '{tool_name}'"
+            )
+            print(f"  [观察]\n{observation[:300]}{'...' if len(str(observation))>300 else ''}")
+
+            self.history.append(f"Action: {action}")
+            self.history.append(f"Observation: {observation}")
+
+        print(f"\n  已达到最大步数 ({self.max_steps}),流程终止。")
+        return None
+
+    def _parse_output(self, text: str):
+        # 支持 Thought: / **Thought:** / Thought: 等多种格式
+        thought_match = re.search(
+            r"(?:\*\*)?Thought(?:\*\*)?\s*[::]\s*(.*?)(?=\n(?:\*\*)?Action(?:\*\*)?\s*[::]|$)",
+            text, re.DOTALL | re.IGNORECASE
+        )
+        action_match = re.search(
+            r"(?:\*\*)?Action(?:\*\*)?\s*[::]\s*(.*?)$",
+            text, re.DOTALL | re.IGNORECASE
+        )
+        thought = thought_match.group(1).strip() if thought_match else None
+        action = action_match.group(1).strip() if action_match else None
+        # 清理 markdown 反引号
+        if action:
+            action = action.strip("`\"' \n\r")
+        return thought, action
+
+    def _parse_action(self, action_text: str):
+        # 清理反引号、markdown bold 等
+        clean = action_text.strip("`\"' \n\r*_")
+        match = re.match(r"(\w+)\[(.*)\]", clean, re.DOTALL)
+        return (match.group(1), match.group(2)) if match else (None, None)
+
+    def _parse_action_input(self, action_text: str):
+        clean = action_text.strip("`\"' \n\r*_")
+        match = re.match(r"\w+\[(.*)\]", clean, re.DOTALL)
+        return match.group(1) if match else ""

+ 467 - 0
Co-creation-projects/CC1227871-StockInsightAgent/app.py

@@ -0,0 +1,467 @@
+"""StockInsightAgent — Gradio 前端"""
+import threading
+import queue
+import gradio as gr
+from framework_agent import FrameworkStockAgent
+from memory import (
+    memory_get_watchlist, memory_add_watchlist, memory_remove_watchlist,
+    memory_get_history, memory_get_preferences,
+)
+from rag import rag_import, rag_stats
+
+_agent = None
+
+
+def get_agent():
+    global _agent
+    if _agent is None:
+        _agent = FrameworkStockAgent()
+    return _agent
+
+
+def _run_with_capture(q: queue.Queue, agent, mode: str, msg: str):
+    import io, sys
+    import contextlib
+    buffer = io.StringIO()
+
+    class QueueWriter:
+        def __init__(self, original_stdout):
+            self.original = original_stdout
+            # Ensure _local exists for threads
+            import threading
+            self._local = threading.local()
+
+        @property
+        def is_active(self):
+            return getattr(self._local, "active", False)
+
+        @is_active.setter
+        def is_active(self, value):
+            self._local.active = value
+
+        def write(self, s):
+            if self.is_active:
+                buffer.write(s)
+                q.put(s)
+            else:
+                self.original.write(s)
+
+        def flush(self):
+            if not self.is_active:
+                self.original.flush()
+
+    if not isinstance(sys.stdout, QueueWriter):
+        sys.stdout = QueueWriter(sys.stdout)
+
+    sys.stdout.is_active = True
+    try:
+        if mode == "深度分析 (PlanSolve)":
+            result = agent.plan_solve(msg)
+        elif mode == "批判分析 (Reflection)":
+            result = agent.reflect(msg)
+        else:
+            result = agent.react(msg)
+    except Exception as e:
+        result = f"分析出错: {e}"
+    finally:
+        sys.stdout.is_active = False
+    q.put(None)
+    q.result = result or ""
+
+
+def respond_stream(message: str, history: list, mode: str, agent=None):
+    if agent is None:
+        agent = get_agent()
+
+    if not message or not message.strip():
+        yield history, "", agent
+        return
+
+    msg = message.strip()
+    history = history or []
+
+    # ── 快捷命令 ──
+    quick = {
+        ("帮助", "help", "?"): lambda: HELP_TEXT,
+        ("列表", "关注列表"): memory_get_watchlist,
+        ("历史",): memory_get_history,
+        ("偏好",): memory_get_preferences,
+        ("知识库",): rag_stats,
+    }
+    for keys, handler in quick.items():
+        if msg in keys:
+            history.append({"role": "user", "content": msg})
+            history.append({"role": "assistant", "content": handler()})
+            yield history, "", agent
+            return
+
+    if msg.startswith("关注 "):
+        parts = msg[3:].strip().split()
+        c, n = parts[0], (parts[1] if len(parts) > 1 else "")
+        history.append({"role": "user", "content": msg})
+        history.append({"role": "assistant", "content": memory_add_watchlist(f"{c}|{n}")})
+        yield history, "", agent
+        return
+
+    if msg.startswith("移除 "):
+        history.append({"role": "user", "content": msg})
+        history.append({"role": "assistant", "content": memory_remove_watchlist(msg[3:].strip())})
+        yield history, "", agent
+        return
+
+    if msg.startswith("历史 "):
+        history.append({"role": "user", "content": msg})
+        history.append({"role": "assistant", "content": memory_get_history(msg[3:].strip())})
+        yield history, "", agent
+        return
+
+    if msg.startswith("导入 "):
+        history.append({"role": "user", "content": msg})
+        history.append({"role": "assistant", "content": rag_import(msg[3:].strip()) + "\n" + rag_stats()})
+        yield history, "", agent
+        return
+
+    # ── 流式分析 ──
+    history.append({"role": "user", "content": msg})
+    history.append({"role": "assistant", "content": "..."})
+
+    q = queue.Queue()
+    t = threading.Thread(target=_run_with_capture, args=(q, agent, mode, msg), daemon=True)
+    t.start()
+
+    collected = []
+    while True:
+        try:
+            chunk = q.get(timeout=0.3)
+        except queue.Empty:
+            if collected:
+                history[-1]["content"] = "".join(collected)
+            yield history, "", agent
+            continue
+        if chunk is None:
+            break
+        collected.append(chunk)
+        history[-1]["content"] = "".join(collected)
+        yield history, "", agent
+
+    final = getattr(q, 'result', '') or "".join(collected)
+    history[-1]["content"] = str(final)
+    yield history, "", agent
+
+
+HELP_TEXT = """## 使用指南
+
+### 股票分析
+直接输入:`分析贵州茅台600519的估值和风险`
+
+### 关注管理
+| 命令 | 说明 |
+|------|------|
+| `列表` | 查看关注列表 |
+| `关注 600519 茅台` | 添加关注 |
+| `移除 600519` | 移除关注 |
+
+### 数据查询
+| 命令 | 说明 |
+|------|------|
+| `历史` | 全部分析历史 |
+| `偏好` | 用户偏好设置 |
+| `知识库` | 知识库状态 |"""
+
+# ===== 自定义 CSS =====
+CUSTOM_CSS = """
+/* 全局 */
+.gradio-container {
+    max-width: 100% !important;
+    margin: 0 !important;
+    padding: 0 !important;
+    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif !important;
+}
+
+/* 隐藏默认 footer */
+footer { display: none !important; }
+
+/* Header */
+.header-wrap {
+    background: linear-gradient(135deg, #0f1729 0%, #1a2744 50%, #0d2137 100%);
+    border-bottom: 2px solid #2a5c8a;
+    padding: 16px 32px;
+}
+.header-wrap h1 {
+    font-size: 26px;
+    font-weight: 700;
+    color: #e8f0fe;
+    margin: 0;
+    letter-spacing: -0.5px;
+}
+.header-wrap .subtitle {
+    font-size: 13px;
+    color: #7b9bcb;
+    margin-top: 4px;
+}
+.header-wrap .status-row {
+    display: flex;
+    gap: 16px;
+    margin-top: 10px;
+    flex-wrap: wrap;
+}
+.status-badge {
+    display: inline-flex;
+    align-items: center;
+    gap: 6px;
+    font-size: 11px;
+    padding: 3px 10px;
+    border-radius: 12px;
+    font-weight: 500;
+}
+.status-badge.online {
+    background: rgba(34, 197, 94, 0.15);
+    color: #4ade80;
+}
+.status-badge.data {
+    background: rgba(59, 130, 246, 0.15);
+    color: #60a5fa;
+}
+
+/* 侧边栏卡片 */
+.sidebar-card {
+    background: rgba(255,255,255,0.04);
+    border: 1px solid rgba(255,255,255,0.08);
+    border-radius: 10px;
+    padding: 16px;
+    margin-bottom: 12px;
+}
+.sidebar-card h4 {
+    font-size: 12px;
+    font-weight: 600;
+    color: #7b9bcb;
+    text-transform: uppercase;
+    letter-spacing: 0.5px;
+    margin: 0 0 10px 0;
+}
+
+/* 主对话区域 */
+.main-chat {
+    border-radius: 12px !important;
+    border: 1px solid rgba(255,255,255,0.1) !important;
+    background: rgba(255,255,255,0.02) !important;
+}
+
+/* 输入框 */
+.input-box textarea {
+    border-radius: 10px !important;
+    border: 1px solid rgba(255,255,255,0.12) !important;
+    background: rgba(255,255,255,0.04) !important;
+    color: #e0e0e0 !important;
+    font-size: 14px !important;
+    padding: 12px 16px !important;
+}
+.input-box textarea::placeholder {
+    color: rgba(255,255,255,0.3) !important;
+}
+
+/* 按钮 */
+button.primary {
+    background: linear-gradient(135deg, #2563eb, #1d4ed8) !important;
+    border: none !important;
+    border-radius: 10px !important;
+    color: white !important;
+    font-weight: 600 !important;
+    padding: 12px 24px !important;
+    transition: all 0.2s !important;
+    height: 100% !important;
+    min-height: 44px !important;
+}
+button.primary:hover {
+    background: linear-gradient(135deg, #3b82f6, #2563eb) !important;
+    box-shadow: 0 4px 12px rgba(37,99,235,0.3) !important;
+}
+button.secondary {
+    background: rgba(255,255,255,0.04) !important;
+    border: 1px solid rgba(255,255,255,0.08) !important;
+    border-radius: 8px !important;
+    color: #6aaff7 !important;
+    font-size: 12px !important;
+    padding: 8px 14px !important;
+    transition: all 0.2s !important;
+    width: 100% !important;
+    text-align: left !important;
+}
+button.secondary:hover {
+    background: rgba(255,255,255,0.08) !important;
+    color: #c8d6e5 !important;
+    border-color: rgba(255,255,255,0.18) !important;
+}
+
+/* Radio 模式选择 */
+.mode-radio-wrap {
+    background: rgba(255,255,255,0.04);
+    border-radius: 10px;
+    padding: 14px 16px;
+    border: 1px solid rgba(255,255,255,0.08);
+}
+
+/* --- 高对比度修复 --- */
+
+/* Radio / Checkbox 标签 — 亮蓝色 */
+.radio-option label, .radio-option span,
+label:has(input[type="radio"]), .radio-label,
+fieldset label, .radio-wrap label {
+    color: #6aaff7 !important;
+}
+/* Radio hover — 亮灰色 */
+.radio-option:hover label, .radio-option:hover span,
+fieldset label:hover, .radio-wrap:hover label {
+    color: #c8d6e5 !important;
+}
+/* Radio 选中 — 加粗变白 */
+input[type="radio"]:checked + label,
+input[type="radio"]:checked ~ span {
+    color: #ffffff !important;
+    font-weight: 700 !important;
+}
+
+/* 输入框 */
+input[type="text"], textarea, .input-box textarea {
+    background: #1a2236 !important;
+    border: 1px solid #3a5078 !important;
+    color: #e8edf5 !important;
+    border-radius: 10px !important;
+    padding: 12px 16px !important;
+    font-size: 14px !important;
+}
+input[type="text"]:focus, textarea:focus {
+    border-color: #4a8cf7 !important;
+    box-shadow: 0 0 0 3px rgba(74, 140, 247, 0.15) !important;
+    outline: none !important;
+}
+input[type="text"]::placeholder, textarea::placeholder {
+    color: #5a7099 !important;
+}
+
+/* select 下拉 */
+select, .dropdown {
+    background: #1a2236 !important;
+    color: #d0d8e8 !important;
+    border: 1px solid #3a5078 !important;
+}
+
+/* 链接 */
+a, .examples a {
+    color: #7aabf7 !important;
+}
+a:hover {
+    color: #a0c4ff !important;
+}
+
+/* 聊天气泡内容 */
+.message-row .message {
+    color: #e4eaf5 !important;
+}
+
+/* 聊天气泡 */
+.bubble-wrap { border-radius: 12px !important; }
+
+/* 快捷图标 */
+.quick-icon {
+    font-size: 16px;
+    margin-right: 6px;
+}
+
+/* 滚动条 */
+::-webkit-scrollbar { width: 5px; }
+::-webkit-scrollbar-track { background: transparent; }
+::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 3px; }
+::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.2); }
+"""
+
+# ===== 界面 =====
+with gr.Blocks(title="StockInsightAgent") as app:
+
+    # ── Header ──
+    gr.HTML("""
+    <div class="header-wrap">
+        <h1>StockInsightAgent</h1>
+        <div class="subtitle">智能股票分析助手</div>
+        <div class="status-row">
+            <span class="status-badge online">&#9679; 系统就绪</span>
+            <span class="status-badge data">&#9679; 数据源: 东方财富 / Sina / 腾讯</span>
+        </div>
+    </div>
+    """)
+
+    with gr.Row(equal_height=True):
+        # ── 左侧栏 ──
+        with gr.Column(scale=1, min_width=200):
+            gr.HTML('<div class="sidebar-card"><h4>分析模式</h4></div>')
+            mode_radio = gr.Radio(
+                choices=["快速分析 (ReAct)", "深度分析 (PlanSolve)", "批判分析 (Reflection)"],
+                value="快速分析 (ReAct)",
+                label="",
+                interactive=True,
+            )
+
+            gr.HTML('<div class="sidebar-card"><h4>快捷操作</h4></div>')
+            btn_watchlist = gr.Button("关注列表", elem_classes="secondary")
+            btn_history = gr.Button("分析历史", elem_classes="secondary")
+            btn_kb = gr.Button("知识库状态", elem_classes="secondary")
+            btn_prefs = gr.Button("用户偏好", elem_classes="secondary")
+
+            gr.HTML("""
+            <div style="margin-top:16px; font-size:11px; color:#5a7a9a; line-height:1.6;">
+            输入 <b>帮助</b> 查看更多命令<br>
+            </div>
+            """)
+
+        # ── 主区域 ──
+        with gr.Column(scale=4):
+            agent_state = gr.State(None)
+
+            chatbot = gr.Chatbot(
+                label="",
+                height=520,
+                elem_classes="main-chat",
+                placeholder="<div style='text-align:center; color:#6a8aaa; padding-top:80px;'>"
+                             "<div style='font-size:48px; margin-bottom:16px;'>📊</div>"
+                             "<div style='font-size:16px; font-weight:600;'>开始分析你的投资组合</div>"
+                             "<div style='font-size:13px; margin-top:8px;'>输入股票代码或名称,获取全方位分析报告</div>"
+                             "</div>",
+            )
+
+            with gr.Row(equal_height=True):
+                msg_input = gr.Textbox(
+                    placeholder="输入分析问题...",
+                    label="",
+                    scale=6,
+                    elem_classes="input-box",
+                )
+                submit_btn = gr.Button("开始分析", variant="primary", elem_classes="primary", scale=1)
+
+    # ── 事件绑定 ──
+    msg_input.submit(
+        fn=respond_stream,
+        inputs=[msg_input, chatbot, mode_radio, agent_state],
+        outputs=[chatbot, msg_input, agent_state],
+    )
+    submit_btn.click(
+        fn=respond_stream,
+        inputs=[msg_input, chatbot, mode_radio, agent_state],
+        outputs=[chatbot, msg_input, agent_state],
+    )
+
+    def quick_action(action, history, agent):
+        for result in respond_stream(action, history, "快速分析 (ReAct)", agent):
+            pass
+        return result[0], result[2]
+
+    btn_watchlist.click(lambda h, a: quick_action("列表", h, a), [chatbot, agent_state], [chatbot, agent_state])
+    btn_history.click(lambda h, a: quick_action("历史", h, a), [chatbot, agent_state], [chatbot, agent_state])
+    btn_kb.click(lambda h, a: quick_action("知识库", h, a), [chatbot, agent_state], [chatbot, agent_state])
+    btn_prefs.click(lambda h, a: quick_action("偏好", h, a), [chatbot, agent_state], [chatbot, agent_state])
+
+if __name__ == "__main__":
+    app.launch(
+        server_name="127.0.0.1", server_port=7861, share=False,
+        css=CUSTOM_CSS,
+        theme=gr.themes.Soft(primary_hue="blue", neutral_hue="slate"),
+    )

+ 170 - 0
Co-creation-projects/CC1227871-StockInsightAgent/context_manager.py

@@ -0,0 +1,170 @@
+"""Step 8: 上下文工程 — 对话压缩、Token 管理、多轮连贯性"""
+import json
+import os
+from datetime import datetime
+from typing import List, Dict, Optional
+
+
+class ContextManager:
+    """对话上下文管理器:压缩历史、控制 Token 用量、保持连贯性"""
+
+    def __init__(self, max_tokens: int = 4000, summary_trigger: int = 3000):
+        self.max_tokens = max_tokens        # 上下文最大 token 数
+        self.summary_trigger = summary_trigger  # 触发压缩的阈值
+        self.turns: List[Dict] = []          # 对话轮次
+        self.summary: str = ""               # 压缩后的摘要
+        self.total_turns = 0
+
+    @staticmethod
+    def _estimate_tokens(text: str) -> int:
+        """简单 Token 估算:中文 ~1.5 字/token,英文 ~4 字/token"""
+        chinese = sum(1 for c in text if '一' <= c <= '鿿')
+        other = len(text) - chinese
+        return int(chinese / 1.5 + other / 4)
+
+    def add_turn(self, role: str, content: str):
+        """添加一轮对话"""
+        self.total_turns += 1
+        turn = {
+            "id": self.total_turns,
+            "role": role,
+            "content": content,
+            "tokens": self._estimate_tokens(content),
+            "time": datetime.now().strftime("%H:%M:%S"),
+        }
+        self.turns.append(turn)
+
+        # 检查是否需要压缩
+        total = sum(t["tokens"] for t in self.turns)
+        if total > self.summary_trigger:
+            self._compress()
+
+    def _compress(self):
+        """压缩早期对话为摘要"""
+        if len(self.turns) <= 4:
+            return  # 保留最近 4 轮
+
+        # 取最早的 60% 轮次进行压缩
+        split = max(1, int(len(self.turns) * 0.6))
+        old_turns = self.turns[:split]
+        recent = self.turns[split:]
+
+        # 生成摘要
+        lines = []
+        for t in old_turns:
+            role_label = "用户" if t["role"] == "user" else "助手"
+            snippet = t["content"][:200].replace("\n", " ")
+            lines.append(f"[{role_label}]: {snippet}")
+
+        new_summary = "对话历史摘要:\n" + "\n".join(lines)
+        if self.summary:
+            self.summary = self.summary[:500] + "\n...\n" + new_summary
+        else:
+            self.summary = new_summary
+
+        # 限制摘要长度
+        while self._estimate_tokens(self.summary) > 1500:
+            # Drop the earliest part of the summary string by splitting on lines
+            lines = self.summary.split('\n')
+            if len(lines) <= 2:
+                # If there are only a couple lines left, we must chop strings carefully or discard
+                self.summary = ""
+                break
+            else:
+                self.summary = "对话历史摘要:\n" + "\n".join(lines[2:])
+
+        self.turns = recent
+
+    def get_context(self, system_prompt: str = "",
+                    current_query: str = "") -> str:
+        """构建当前上下文字符串"""
+        parts = []
+
+        # 压缩摘要
+        if self.summary:
+            parts.append(f"## 历史对话摘要\n{self.summary[:2000]}")
+
+        # 最近对话
+        if self.turns:
+            parts.append("## 最近对话")
+            for t in self.turns[-8:]:  # 最近 8 轮
+                role_label = "用户" if t["role"] == "user" else "助手"
+                content = t["content"]
+                if self._estimate_tokens(content) > 500:
+                    content = content[:500] + "..."
+                parts.append(f"### {role_label}\n{content}")
+
+        return "\n\n".join(parts)
+
+    def get_stats(self) -> str:
+        """获取上下文使用统计"""
+        total = sum(t["tokens"] for t in self.turns)
+        summary_tokens = self._estimate_tokens(self.summary) if self.summary else 0
+        return (f"上下文: {len(self.turns)} 活跃轮次, "
+                f"约 {total} tokens 活跃 + {summary_tokens} tokens 摘要, "
+                f"总计 {self.total_turns} 轮对话")
+
+    def clear(self):
+        self.turns = []
+        self.summary = ""
+        self.total_turns = 0
+
+
+# ===== 上下文感知的 System Prompt 构建器 =====
+
+def build_context_aware_prompt(
+    ctx: ContextManager,
+    base_prompt: str,
+    user_query: str,
+    memory_context: str = "",
+    kb_context: str = "",
+) -> str:
+    """构建完整上下文感知的系统消息"""
+
+    parts = [base_prompt]
+
+    # 对话上下文
+    context_str = ctx.get_context()
+    if context_str:
+        parts.append(f"\n## 当前对话上下文\n{context_str}")
+
+    # 记忆上下文
+    if memory_context:
+        parts.append(f"\n## 用户记忆\n{memory_context}")
+
+    # 知识库上下文
+    if kb_context:
+        parts.append(f"\n## 相关知识\n{kb_context}")
+
+    return "\n".join(parts)
+
+
+# 全局单例
+_ctx_instance: Optional[ContextManager] = None
+
+
+def get_context() -> ContextManager:
+    global _ctx_instance
+    if _ctx_instance is None:
+        _ctx_instance = ContextManager()
+    return _ctx_instance
+
+
+# ===== 工具函数 =====
+
+def context_stats(query: str = "") -> str:
+    """查看当前上下文使用统计"""
+    return get_context().get_stats()
+
+
+def context_clear(query: str = "") -> str:
+    """清空上下文(开始新会话)"""
+    get_context().clear()
+    return "上下文已清空,开始新会话。"
+
+
+def context_summarize(query: str = "") -> str:
+    """手动触发上下文压缩"""
+    ctx = get_context()
+    ctx._compress()
+    return ctx.get_stats()

+ 216 - 0
Co-creation-projects/CC1227871-StockInsightAgent/framework_agent.py

@@ -0,0 +1,216 @@
+"""Step 5-8: HelloAgents 框架 StockInsightAgent + Memory + RAG + Context
+工具: 5数据 + 7记忆 + 3知识库 + 3上下文
+模式: ReActAgent / PlanSolveAgent / ReflectionAgent
+"""
+from dotenv import load_dotenv
+load_dotenv()
+
+from hello_agents import (
+    HelloAgentsLLM, ToolRegistry,
+    ReActAgent, PlanSolveAgent, ReflectionAgent
+)
+from tools import (
+    get_realtime_quote, get_historical_data,
+    get_financial_data, calc_indicators, get_news
+)
+from memory import (
+    memory_add_watchlist, memory_remove_watchlist, memory_get_watchlist,
+    memory_save_analysis, memory_get_history,
+    memory_set_preference, memory_get_preferences,
+)
+from rag import rag_search, rag_import, rag_stats
+from context_manager import get_context, context_stats, context_clear, context_summarize
+
+
+def build_tool_registry():
+    """注册全部工具: 5个数据 + 7个记忆"""
+    registry = ToolRegistry()
+
+    # 数据工具
+    registry.register_function(get_realtime_quote, "GetRealtimeQuote",
+        "获取A股实时行情(最新价/涨跌幅/成交量/PE/市值)。输入: 股票代码或简称")
+    registry.register_function(get_historical_data, "GetHistoricalData",
+        "获取历史K线数据(OHLCV)。输入格式: '代码|周期|天数',如'600519|daily|60'")
+    registry.register_function(get_financial_data, "GetFinancialData",
+        "获取核心财务指标(净利润/营收/毛利率/负债率/ROE等)。输入: 股票代码")
+    registry.register_function(calc_indicators, "CalcIndicators",
+        "计算技术指标(MA5/10/20/60, MACD, RSI14, 布林带, 支撑压力位)。输入格式: '代码|daily|天数'")
+    registry.register_function(get_news, "GetNews",
+        "获取近期新闻舆情。输入: 股票代码")
+
+    # 记忆工具
+    registry.register_function(memory_add_watchlist, "AddToWatchlist",
+        "添加股票到关注列表。输入: '代码|名称' 如 '600519|贵州茅台'")
+    registry.register_function(memory_remove_watchlist, "RemoveFromWatchlist",
+        "从关注列表移除股票。输入: 股票代码")
+    registry.register_function(memory_get_watchlist, "GetWatchlist",
+        "查看当前关注列表。无输入参数")
+    registry.register_function(memory_save_analysis, "SaveAnalysis",
+        "保存分析结果到历史记录。输入: '代码|问题|摘要'")
+    registry.register_function(memory_get_history, "GetHistory",
+        "查看分析历史记录。输入: 股票代码(可选)")
+    registry.register_function(memory_set_preference, "SetPreference",
+        "设置用户偏好。输入: 'key=value' 如 '风格=技术分析为主'")
+    registry.register_function(memory_get_preferences, "GetPreferences",
+        "查看用户偏好设置。无输入参数")
+
+    # RAG 知识库工具
+    registry.register_function(rag_search, "SearchKnowledge",
+        "搜索投资知识库(估值方法/技术指标解读/风控原则/A股规则)。输入: 查询关键词")
+    registry.register_function(rag_import, "ImportDocument",
+        "导入文档到知识库。输入: 文件路径(.txt/.md)")
+    registry.register_function(rag_stats, "KnowledgeStats",
+        "查看知识库统计信息")
+
+    # 上下文管理工具
+    registry.register_function(context_stats, "ContextStats",
+        "查看当前对话上下文使用情况(Token用量/轮次)")
+    registry.register_function(context_clear, "ContextClear",
+        "清空上下文开始新会话。无输入参数")
+    registry.register_function(context_summarize, "ContextSummarize",
+        "手动压缩对话上下文。无输入参数")
+
+    return registry
+
+
+STOCK_SYSTEM_PROMPT = """你是专业股票分析助手 StockInsightAgent,具备完整认知能力的智能分析系统。
+
+## 核心能力
+- 获取A股实时行情、历史K线、财务数据、技术指标和新闻舆情
+- 记住用户的关注列表、分析偏好和历史分析记录
+- 查询投资知识库(估值方法、技术指标解读、风控原则、A股交易规则)
+- 管理对话上下文(长对话自动压缩,保持会话连贯性)
+
+## 记忆功能
+- 当用户说"关注XX股票"时,使用 AddToWatchlist 添加到关注列表
+- 完成分析后,使用 SaveAnalysis 保存分析结果
+- 看到用户有明确的投资风格倾向时,使用 SetPreference 记住偏好
+- 在分析新股票前,先查看 GetWatchlist 和 GetPreferences 了解用户背景
+
+## 知识库功能
+- 估值分析时可查询 SearchKnowledge 获取 PE/PB/PEG/股息率 等方法论
+- 解读技术指标时可查询 SearchKnowledge 了解 MACD/RSI/布林带 等标准解读
+- 风险评估时可查询 SearchKnowledge 获取仓位管理和止损原则
+
+## 上下文管理
+- 多轮对话中自然引用之前分析过的结论
+- 用户切换分析对象时,关联此前对该股票的分析
+- 上下文过长时系统会自动压缩,保持关键信息不丢失
+
+## 分析报告格式
+1.基本概况 2.技术面分析 3.基本面与估值 4.消息面 5.风险提示 6.操作建议
+当数据不可用时请如实说明,不要编造数据。"""
+
+
+class FrameworkStockAgent:
+    """基于 HelloAgents 框架 + Memory + RAG + Context 的股票分析 Agent"""
+
+    def __init__(self):
+        self.llm = self._build_llm()
+        self.registry = build_tool_registry()
+        self.ctx = get_context()
+
+    def _build_llm(self):
+        """构建 LLM,兼容 DeepSeek thinking mode 的 reasoning_content"""
+        base = HelloAgentsLLM()
+        reasoning_entries = []  # 按序存储每轮 assistant 的 reasoning_content
+
+        try:
+            adapter = base._adapter
+            adapter._client = adapter.create_client()
+            original_create = adapter._client.chat.completions.create
+
+            def patched_create(*args, **kwargs):
+                messages = kwargs.get("messages", [])
+                # 注入 reasoning_content:给最近一条没有它的 assistant 消息
+                missing_idx = 0
+                fixed_msgs = []
+                for m in messages:
+                    m2 = dict(m)
+                    if m2.get("role") == "assistant" and not m2.get("reasoning_content"):
+                        if missing_idx < len(reasoning_entries):
+                            m2["reasoning_content"] = reasoning_entries[missing_idx]
+                        missing_idx += 1
+                    fixed_msgs.append(m2)
+                kwargs["messages"] = fixed_msgs
+                resp = original_create(*args, **kwargs)
+                # 保存新 reasoning_content
+                try:
+                    msg = resp.choices[0].message
+                    rc = getattr(msg, "reasoning_content", None)
+                    if rc:
+                        reasoning_entries.append(rc)
+                except Exception:
+                    pass
+                return resp
+
+            adapter._client.chat.completions.create = patched_create
+        except Exception as e:
+            print(f"Warning: _build_llm monkey patch failed, falling back to standard LLM: {e}")
+        return base
+
+    def _run_with_context(self, agent, question: str, mode: str):
+        """运行 Agent 并管理上下文"""
+        self.ctx.add_turn("user", question)
+        result = agent.run(question)
+        if result:
+            self.ctx.add_turn("assistant", result[:2000])  # 截取结果避免太长
+        print(f"\n  [{mode}] {self.ctx.get_stats()}")
+        return result
+
+    def react(self, question: str):
+        print(f"\n  [ReAct 框架模式] {question}")
+        agent = ReActAgent(
+            name="StockReAct", llm=self.llm,
+            tool_registry=self.registry,
+            system_prompt=STOCK_SYSTEM_PROMPT, max_steps=6,
+        )
+        return self._run_with_context(agent, question, "ReAct")
+
+    def plan_solve(self, question: str):
+        print(f"\n  [PlanSolve 框架模式] {question}")
+        agent = PlanSolveAgent(
+            name="StockPlanner", llm=self.llm,
+            tool_registry=self.registry,
+            system_prompt=STOCK_SYSTEM_PROMPT,
+            enable_tool_calling=True, max_tool_iterations=10,
+        )
+        return self._run_with_context(agent, question, "PlanSolve")
+
+    def reflect(self, question: str):
+        print(f"\n  [Reflection 框架模式] {question}")
+        agent = ReflectionAgent(
+            name="StockAnalyst", llm=self.llm,
+            max_iterations=2, tool_registry=self.registry,
+            enable_tool_calling=True, max_tool_iterations=8,
+        )
+        return self._run_with_context(agent, question, "Reflect")
+
+
+# ===== CLI =====
+if __name__ == "__main__":
+    import sys
+
+    if len(sys.argv) < 2:
+        print("用法:")
+        print("  python framework_agent.py react '问题'")
+        print("  python framework_agent.py plan '问题'")
+        print("  python framework_agent.py reflect '问题'")
+        sys.exit(1)
+
+    mode = sys.argv[1]
+    question = " ".join(sys.argv[2:]) if len(sys.argv) > 2 else "分析贵州茅台600519近期走势"
+
+    agent = FrameworkStockAgent()
+    if mode == "react":
+        result = agent.react(question)
+    elif mode == "plan":
+        result = agent.plan_solve(question)
+    elif mode == "reflect":
+        result = agent.reflect(question)
+    else:
+        print(f"未知模式: {mode}")
+        sys.exit(1)
+
+    print(f"\n{'='*60}")
+    print(result)

BIN
Co-creation-projects/CC1227871-StockInsightAgent/image-1.png


BIN
Co-creation-projects/CC1227871-StockInsightAgent/image.png


+ 57 - 0
Co-creation-projects/CC1227871-StockInsightAgent/llm_client.py

@@ -0,0 +1,57 @@
+"""Step 1: LLM 客户端 — 兼容 OpenAI 接口,支持流式响应"""
+import os
+from openai import OpenAI
+from dotenv import load_dotenv
+from typing import List, Dict
+
+load_dotenv()
+
+
+class HelloAgentsLLM:
+    def __init__(self, model: str = None, apiKey: str = None,
+                 baseUrl: str = None, timeout: int = None):
+        self.model = model or os.getenv("LLM_MODEL_ID")
+        apiKey = apiKey or os.getenv("LLM_API_KEY")
+        baseUrl = baseUrl or os.getenv("LLM_BASE_URL")
+        timeout = timeout or int(os.getenv("LLM_TIMEOUT", 60))
+
+        if not all([self.model, apiKey, baseUrl]):
+            raise ValueError("请在 .env 中配置 LLM_MODEL_ID, LLM_API_KEY, LLM_BASE_URL")
+
+        self.client = OpenAI(api_key=apiKey, base_url=baseUrl, timeout=timeout)
+
+    def think(self, messages: List[Dict[str, str]], temperature: float = 0) -> str:
+        print(f"\n[{self.model}] 思考中...")
+        try:
+            response = self.client.chat.completions.create(
+                model=self.model, messages=messages,
+                temperature=temperature, stream=True,
+            )
+            collected = []
+            for chunk in response:
+                if not chunk.choices:
+                    continue
+                content = chunk.choices[0].delta.content or ""
+                # 过滤无效代理字符 (surrogates)
+                clean = content.encode("utf-8", errors="surrogateescape").decode("utf-8", errors="replace")
+                print(clean, end="", flush=True)
+                collected.append(clean)
+            print()
+            result = "".join(collected)
+            return result
+        except Exception as e:
+            print(f"[ERR] LLM 调用失败: {e}")
+            # 尝试非流式重试
+            try:
+                print("  尝试非流式重试...")
+                response = self.client.chat.completions.create(
+                    model=self.model, messages=messages,
+                    temperature=temperature, stream=False,
+                )
+                content = response.choices[0].message.content or ""
+                clean = content.encode("utf-8", errors="surrogateescape").decode("utf-8", errors="replace")
+                print(clean)
+                return clean
+            except Exception as e2:
+                print(f"[ERR] 非流式也失败: {e2}")
+                raise RuntimeError(f"LLM调用完全失败: {e2}")

+ 180 - 0
Co-creation-projects/CC1227871-StockInsightAgent/main.ipynb

@@ -0,0 +1,180 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# StockInsightAgent — 智能股票分析助手\n",
+    "\n",
+    "基于 Hello-Agents 教程 + akshare 实时数据,从技术面、基本面、消息面三维度综合分析 A 股。"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## 1. 环境准备"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from framework_agent import FrameworkStockAgent\n",
+    "\n",
+    "agent = FrameworkStockAgent()\n",
+    "print('StockInsightAgent 已就绪')"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## 2. 快速分析 (ReAct)\n",
+    "\n",
+    "获取实时行情,约 30 秒完成。"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "result = agent.react(\"贵州茅台600519现在什么价格?\")\n",
+    "print(result)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## 3. 深度分析 (PlanSolve)\n",
+    "\n",
+    "从基本面、技术面、估值等多维度全面评估,约 2-3 分钟。"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "result = agent.plan_solve(\"全面分析中国平安601318的投资价值\")\n",
+    "print(result)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## 4. 批判分析 (Reflection)\n",
+    "\n",
+    "生成报告后再自我审查改进,约 3-5 分钟。"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "result = agent.reflect(\"宁德时代300750目前是否值得买入?\")\n",
+    "print(result)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## 5. 记忆系统\n",
+    "\n",
+    "持久化关注列表、分析历史和用户偏好。"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from memory import memory_add_watchlist, memory_get_watchlist, memory_get_history\n",
+    "\n",
+    "# 添加关注\n",
+    "memory_add_watchlist(\"600519|贵州茅台\")\n",
+    "memory_add_watchlist(\"000858|五粮液\")\n",
+    "\n",
+    "# 查看关注列表\n",
+    "print(memory_get_watchlist())\n",
+    "print()\n",
+    "print(memory_get_history())"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## 6. 知识库检索\n",
+    "\n",
+    "查询投资知识库中的估值方法和风控原则。"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from rag import rag_search, rag_stats\n",
+    "\n",
+    "print(rag_stats())\n",
+    "print()\n",
+    "print(rag_search(\"PE估值方法\"))"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## 7. 技术指标自主计算\n",
+    "\n",
+    "直接调用工具函数获取 MA/MACD/RSI/布林带。"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from tools import get_realtime_quote, calc_indicators, get_news\n",
+    "\n",
+    "print(\"=== 实时行情 ===\")\n",
+    "print(get_realtime_quote(\"600519\"))\n",
+    "\n",
+    "print()\n",
+    "print(\"=== 技术指标 ===\")\n",
+    "print(calc_indicators(\"600519|daily|60\"))\n",
+    "\n",
+    "print()\n",
+    "print(\"=== 最新新闻 ===\")\n",
+    "print(get_news(\"600519\"))"
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Robot",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "name": "python",
+   "version": "3.12.0"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 4
+}

+ 159 - 0
Co-creation-projects/CC1227871-StockInsightAgent/main.py

@@ -0,0 +1,159 @@
+"""StockInsightAgent — 智能股票分析助手
+
+用法:
+  python main.py             交互菜单
+  python main.py "问题"       快速分析 (框架 ReAct)
+  python main.py -d "问题"    深度分析 (框架 PlanSolve)
+  python main.py -r "问题"    批判分析 (框架 Reflection)
+"""
+import sys
+from llm_client import HelloAgentsLLM
+from agent import StockInsightAgent
+from plan_agent import PlanAndSolveStockAgent
+from reflection_agent import ReflectionStockAgent
+from framework_agent import FrameworkStockAgent
+from memory import memory_get_watchlist, memory_get_history
+from rag import rag_import, rag_stats
+
+
+def show_banner():
+    print()
+    print("  ╔═════════════════════════════════════════════════════════════╗")
+    print("  ║                                                             ║")
+    print("  ║         StockInsightAgent v2.0   智能股票分析助手             ║")
+    print("  ║                                                             ║")
+    print("  ║   数据: akshare 实时行情 + 财务 + 新闻                        ║")
+    print("  ║   记忆: 关注列表 | 分析历史 | 用户偏好                         ║")
+    print("  ║   知识: 估值体系 | 技术指标 | 风控原则                         ║")
+    print("  ║                                                             ║")
+    print("  ╚═════════════════════════════════════════════════════════════╝")
+    print()
+
+
+MENU = """
+  ┌────────────────────────────────────────────────────────────┐
+  │                                                            │
+  │  [1]  快速分析        框架 ReAct           ~30秒            │
+  │  [2]  深度分析        框架 PlanSolve       ~2分钟            │
+  │  [3]  批判分析        框架 Reflection      ~3分钟            │
+  │                                                            │
+  ├────────────────────────────────────────────────────────────┤
+  │                                                            │
+  │  [4]  ReAct           手写解析+循环                         │
+  │  [5]  PlanSolve       手写规划+执行                         │
+  │  [6]  Reflection      手写记忆+反思                         │
+  │                                                            │
+  ├────────────────────────────────────────────────────────────┤
+  │                                                            │
+  │  [w]  查看关注列表                                          │
+  │  [h]  查看分析历史                                          │
+  │  [k]  导入投资文档到知识库                                   │
+  │                                                            │
+  ├────────────────────────────────────────────────────────────┤
+  │                                                            │
+  │  [m]  重新显示菜单                                          │
+  │  [0]  退出                                                  │
+  │                                                            │
+  └────────────────────────────────────────────────────────────┘
+"""
+
+EXAMPLES = """
+  直接输入问题即可开始分析,例如:
+    Stock> 分析贵州茅台600519当前估值
+    Stock> 对比五粮液和茅台的估值
+
+  先选模式,再输入问题:
+    Stock> 2                (切换到深度分析)
+    Stock> 全面评估比亚迪002594
+"""
+
+
+def main():
+    # ── 命令行参数快捷模式 ──
+    if len(sys.argv) > 1:
+        a = FrameworkStockAgent()
+        if "-d" in sys.argv:
+            sys.argv.remove("-d")
+            q = " ".join(sys.argv[1:]) if len(sys.argv) > 1 else input("问题: ").strip()
+            print(a.plan_solve(q))
+        elif "-r" in sys.argv:
+            sys.argv.remove("-r")
+            q = " ".join(sys.argv[1:]) if len(sys.argv) > 1 else input("问题: ").strip()
+            print(a.reflect(q))
+        else:
+            q = " ".join(sys.argv[1:])
+            print(a.react(q))
+        return
+
+    # ── 交互菜单模式 ──
+    show_banner()
+    print(MENU)
+    print(EXAMPLES)
+
+    fw = FrameworkStockAgent()
+    mode = "react"  # 当前模式
+
+    while True:
+        try:
+            q = input("\nStock> ").strip()
+        except (EOFError, KeyboardInterrupt):
+            print("\n再见")
+            break
+
+        if not q:
+            continue
+
+        # 菜单选择
+        if q in ("1", "2", "3", "4", "5", "6", "w", "h", "k", "m", "0"):
+            if q == "1":
+                mode = "react"
+                print("  >> 切换到 [快速分析] 模式。输入你的问题:")
+            elif q == "2":
+                mode = "plan"
+                print("  >> 切换到 [深度分析] 模式。输入你的问题:")
+            elif q == "3":
+                mode = "reflect"
+                print("  >> 切换到 [批判分析] 模式。输入你的问题:")
+            elif q == "4":
+                mode = "raw-react"
+                print("  >> 切换到 [教学版 ReAct] 模式。输入你的问题:")
+            elif q == "5":
+                mode = "raw-plan"
+                print("  >> 切换到 [教学版 PlanSolve] 模式。输入你的问题:")
+            elif q == "6":
+                mode = "raw-reflect"
+                print("  >> 切换到 [教学版 Reflection] 模式。输入你的问题:")
+            elif q == "w":
+                print(memory_get_watchlist())
+            elif q == "h":
+                code = input("  股票代码 (回车看全部): ").strip()
+                print(memory_get_history(code))
+            elif q == "k":
+                path = input("  文档路径: ").strip()
+                print(rag_import(path))
+                print(rag_stats())
+            elif q == "m":
+                print(MENU)
+            elif q == "0":
+                print("再见")
+                break
+            continue
+
+        # 执行分析
+        print()
+        if mode == "react":
+            print(fw.react(q))
+        elif mode == "plan":
+            print(fw.plan_solve(q))
+        elif mode == "reflect":
+            print(fw.reflect(q))
+        elif mode == "raw-react":
+            StockInsightAgent(HelloAgentsLLM(), max_steps=6).run(q)
+        elif mode == "raw-plan":
+            PlanAndSolveStockAgent(HelloAgentsLLM()).run(q)
+        elif mode == "raw-reflect":
+            ReflectionStockAgent(HelloAgentsLLM(), max_iterations=2).run(q)
+
+
+if __name__ == "__main__":
+    main()

+ 182 - 0
Co-creation-projects/CC1227871-StockInsightAgent/memory.py

@@ -0,0 +1,182 @@
+"""Step 6: 股票分析记忆系统
+持久化存储: 关注列表、分析历史、用户偏好
+"""
+import json
+import os
+from datetime import datetime
+from typing import Optional
+
+
+class StockMemory:
+    """股票分析记忆 — JSON 文件持久化"""
+
+    def __init__(self, path: str = "memory/stock_memory.json"):
+        import threading
+        self.path = path
+        self.data = self._load()
+        self._lock = threading.Lock()
+
+    def _load(self) -> dict:
+        if os.path.exists(self.path):
+            try:
+                with open(self.path, "r", encoding="utf-8") as f:
+                    return json.load(f)
+            except (json.JSONDecodeError, IOError):
+                pass
+        return {"watchlist": {}, "history": [], "preferences": {}}
+
+    def _save(self):
+        import tempfile
+
+        with self._lock:
+            os.makedirs(os.path.dirname(self.path), exist_ok=True)
+            fd, temp_path = tempfile.mkstemp(dir=os.path.dirname(self.path))
+            try:
+                with os.fdopen(fd, "w", encoding="utf-8") as f:
+                    json.dump(self.data, f, ensure_ascii=False, indent=2)
+                os.replace(temp_path, self.path)
+            except Exception:
+                os.remove(temp_path)
+                raise
+
+    # ===== 关注列表 =====
+    def add_watchlist(self, code: str, name: str = "", notes: str = "") -> str:
+        self.data["watchlist"][code] = {
+            "name": name or code,
+            "notes": notes,
+            "added": datetime.now().strftime("%Y-%m-%d %H:%M"),
+        }
+        self._save()
+        return f"已添加 {name or code}({code}) 到关注列表"
+
+    def remove_watchlist(self, code: str) -> str:
+        if code in self.data["watchlist"]:
+            name = self.data["watchlist"][code]["name"]
+            del self.data["watchlist"][code]
+            self._save()
+            return f"已从关注列表移除 {name}({code})"
+        return f"关注列表中未找到 {code}"
+
+    def get_watchlist(self, query: str = "") -> str:
+        wl = self.data["watchlist"]
+        if not wl:
+            return "关注列表为空。说'关注 600519'来添加。"
+        lines = [f"关注列表 ({len(wl)} 只):"]
+        for code, info in wl.items():
+            lines.append(f"  {info['name']}({code})  [{info['added']}]")
+            if info.get("notes"):
+                lines.append(f"    备注: {info['notes']}")
+        return "\n".join(lines)
+
+    # ===== 分析历史 =====
+    def save_analysis(self, code: str, question: str, summary: str) -> str:
+        record = {
+            "code": code,
+            "question": question,
+            "summary": summary[:500],  # 截取前500字作为摘要
+            "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
+        }
+        self.data["history"].append(record)
+        # 只保留最近100条
+        if len(self.data["history"]) > 100:
+            self.data["history"] = self.data["history"][-100:]
+        self._save()
+        return f"分析记录已保存 ({len(self.data['history'])} 条历史)"
+
+    def get_history(self, query: str = "") -> str:
+        code = query.strip() if query else ""
+        records = self.data["history"]
+        if code:
+            records = [r for r in records if r["code"] == code]
+        if not records:
+            return f"暂无{' ' + code + ' 的' if code else ''}分析历史"
+        lines = [f"分析历史 (最近{len(records)}条):"]
+        for r in records[-10:]:  # 最近10条
+            lines.append(f"  [{r['timestamp']}] {r['code']}: {r['question'][:60]}")
+        return "\n".join(lines)
+
+    def get_last_analysis(self, code: str = "") -> Optional[str]:
+        records = self.data["history"]
+        if code:
+            records = [r for r in records if r["code"] == code]
+        if records:
+            return records[-1].get("summary", "")
+        return None
+
+    # ===== 用户偏好 =====
+    def set_preference(self, key: str, value: str) -> str:
+        self.data["preferences"][key] = value
+        self._save()
+        return f"偏好已设置: {key} = {value}"
+
+    def get_preferences(self, query: str = "") -> str:
+        prefs = self.data["preferences"]
+        if not prefs:
+            return "暂无保存的偏好。可以设置如: '偏好 分析风格=深度价值投资'"
+        lines = ["用户偏好:"]
+        for k, v in prefs.items():
+            lines.append(f"  {k}: {v}")
+        return "\n".join(lines)
+
+    def clear(self) -> str:
+        self.data = {"watchlist": {}, "history": [], "preferences": {}}
+        self._save()
+        return "记忆已清空"
+
+
+# 全局单例
+_memory_instance = None
+
+
+def get_memory() -> StockMemory:
+    global _memory_instance
+    if _memory_instance is None:
+        _memory_instance = StockMemory()
+    return _memory_instance
+
+
+# ===== 工具函数(可直接注册到 ToolRegistry)=====
+
+def memory_add_watchlist(query: str) -> str:
+    """添加股票到关注列表。输入: '代码|名称' 如 '600519|贵州茅台'"""
+    parts = query.strip().split("|")
+    code = parts[0].strip()
+    name = parts[1].strip() if len(parts) > 1 else ""
+    return get_memory().add_watchlist(code, name)
+
+
+def memory_remove_watchlist(code: str) -> str:
+    """从关注列表移除股票。输入: 股票代码"""
+    return get_memory().remove_watchlist(code.strip())
+
+
+def memory_get_watchlist(query: str = "") -> str:
+    """查看关注列表"""
+    return get_memory().get_watchlist(query)
+
+
+def memory_save_analysis(query: str) -> str:
+    """保存分析结果。输入: '代码|问题|摘要' """
+    parts = query.strip().split("|")
+    code = parts[0].strip() if len(parts) > 0 else ""
+    question = parts[1].strip() if len(parts) > 1 else ""
+    summary = parts[2].strip() if len(parts) > 2 else ""
+    return get_memory().save_analysis(code, question, summary)
+
+
+def memory_get_history(query: str = "") -> str:
+    """查看分析历史。输入: 股票代码(可选,留空看全部)"""
+    return get_memory().get_history(query)
+
+
+def memory_set_preference(query: str) -> str:
+    """设置用户偏好。输入: 'key=value' 如 '风格=技术分析为主'"""
+    if "=" in query:
+        k, v = query.split("=", 1)
+        return get_memory().set_preference(k.strip(), v.strip())
+    return "格式: key=value"
+
+
+def memory_get_preferences(query: str = "") -> str:
+    """查看用户偏好"""
+    return get_memory().get_preferences()

+ 179 - 0
Co-creation-projects/CC1227871-StockInsightAgent/plan_agent.py

@@ -0,0 +1,179 @@
+"""Step 3: Plan-and-Solve 股票多维度分析
+来自 hello-agents 教程第4章 Plan-and-Solve 范式:
+  Planner: 将复杂分析问题分解为有序步骤
+  Executor: 逐步执行,积累上下文,最终综合生成报告
+"""
+import ast
+from llm_client import HelloAgentsLLM
+from tools import (
+    get_realtime_quote, get_historical_data, get_financial_data,
+    calc_indicators, get_news
+)
+
+PLANNER_PROMPT = """你是一个顶级的股票分析规划专家。用户会提出一个股票分析请求,你的任务是将它分解成一个由多个独立步骤组成的分析计划。
+
+每个步骤应该聚焦一个分析维度,按从数据收集到综合分析的逻辑顺序排列。
+可用数据维度: 实时行情、历史K线、技术指标(MA/MACD/RSI/布林带)、财务数据、新闻舆情。
+
+问题: {question}
+
+请严格按照以下格式输出计划,```python与```作为前后缀是必要的:
+```python
+["步骤1: 具体行动描述", "步骤2: 具体行动描述", ...]
+```
+
+示例:
+```python
+["获取600519的实时行情和60天历史K线", "计算技术指标评估趋势和动能", "获取财务数据评估估值", "获取新闻舆情评估市场情绪", "综合所有数据输出完整分析报告"]
+```
+"""
+
+EXECUTOR_PROMPT = """你是一位专业的股票分析师。你正在按预定计划逐步分析一只股票。
+
+## 完整计划:
+{plan}
+
+## 已完成步骤的结果:
+{history}
+
+## 当前步骤:
+{current_step}
+
+## 可用工具
+- GetRealtimeQuote: 获取实时行情。输入: 股票代码
+- GetHistoricalData: 获取历史K线。输入格式: "代码|daily|天数"
+- CalcIndicators: 计算技术指标。输入格式: "代码|daily|天数"
+- GetFinancialData: 获取财务数据。输入: 股票代码
+- GetNews: 获取新闻舆情。输入: 股票代码
+
+请执行当前步骤。如果需要获取数据,请在回复中明确指定要调用的工具和参数,格式为:
+[[TOOL:工具名:参数]]
+
+示例:
+[[TOOL:GetRealtimeQuote:600519]]
+[[TOOL:GetHistoricalData:600519|daily|60]]
+
+如果当前步骤是综合分析(不需要获取新数据),请直接基于已有结果给出分析。
+如果这是最后一步,请输出完整的综合分析报告,包含: 基本概况、技术面、基本面、消息面、风险提示、投资建议。
+
+现在请执行当前步骤。"""
+
+
+class Planner:
+    def __init__(self, llm_client: HelloAgentsLLM):
+        self.llm_client = llm_client
+
+    def plan(self, question: str) -> list:
+        prompt = PLANNER_PROMPT.format(question=question)
+        messages = [{"role": "user", "content": prompt}]
+
+        print("\n  [规划中...]")
+        response = self.llm_client.think(messages=messages) or ""
+
+        try:
+            plan_str = response.split("```python")[1].split("```")[0].strip()
+            plan = ast.literal_eval(plan_str)
+            if isinstance(plan, list) and len(plan) > 0:
+                return plan
+        except (ValueError, SyntaxError, IndexError) as e:
+            print(f"  [规划解析失败: {e}]")
+
+        # 回退:默认分析计划
+        return [
+            "获取实时行情和60天历史K线数据",
+            "计算技术指标(MACD/RSI/布林带/均线)",
+            "获取财务数据评估基本面和估值",
+            "获取新闻舆情了解市场情绪",
+            "综合所有数据生成完整分析报告"
+        ]
+
+
+class Executor:
+    def __init__(self, llm_client: HelloAgentsLLM):
+        self.llm_client = llm_client
+        # 工具映射
+        self.tools = {
+            "GetRealtimeQuote": get_realtime_quote,
+            "GetHistoricalData": get_historical_data,
+            "CalcIndicators": calc_indicators,
+            "GetFinancialData": get_financial_data,
+            "GetNews": get_news,
+        }
+
+    def execute(self, question: str, plan: list) -> str:
+        import re
+        history = ""
+        final_result = ""
+
+        print(f"\n  [计划共 {len(plan)} 步]")
+        for i, step in enumerate(plan, 1):
+            print(f"\n{'='*50}")
+            print(f"  步骤 {i}/{len(plan)}: {step}")
+            print(f"{'='*50}")
+
+            prompt = EXECUTOR_PROMPT.format(
+                plan="\n".join([f"{j}. {s}" for j, s in enumerate(plan, 1)]),
+                history=history if history else "(尚无已完成步骤)",
+                current_step=step,
+            )
+            messages = [{"role": "user", "content": prompt}]
+            response = self.llm_client.think(messages=messages) or ""
+            print(f"  [LLM 响应]\n{response[:500]}{'...' if len(response)>500 else ''}")
+
+            # 解析工具调用 [[TOOL:Name:args]]
+            tool_pattern = re.findall(r"\[\[TOOL:(\w+):(.*?)\]\]", response)
+            tool_results = []
+
+            for tool_name, tool_args in tool_pattern:
+                func = self.tools.get(tool_name)
+                if func:
+                    result = func(tool_args.strip())
+                    tool_results.append(f"[{tool_name}结果]\n{result}")
+                    print(f"  [工具] {tool_name} 执行完成")
+
+            # 如果有工具调用,让 LLM 基于工具结果再回答一次
+            if tool_results:
+                followup = f"工具执行结果:\n\n" + "\n\n".join(tool_results)
+                followup += f"\n\n请基于以上数据完成当前步骤: {step}"
+                messages.append({"role": "assistant", "content": response})
+                messages.append({"role": "user", "content": followup})
+                final_response = self.llm_client.think(messages=messages) or ""
+                step_result = final_response
+            else:
+                step_result = response
+
+            print(f"  [步骤 {i} 结果]\n{step_result[:300]}{'...' if len(step_result)>300 else ''}")
+
+            history += f"\n--- 步骤{i}: {step} ---\n{step_result}\n"
+            final_result = step_result
+
+        return final_result
+
+
+class PlanAndSolveStockAgent:
+    """Plan-and-Solve 股票分析 Agent"""
+
+    def __init__(self, llm_client: HelloAgentsLLM):
+        self.llm_client = llm_client
+        self.planner = Planner(llm_client)
+        self.executor = Executor(llm_client)
+
+    def run(self, question: str):
+        print(f"\n{'='*60}")
+        print(f"  Plan-and-Solve 模式")
+        print(f"  问题: {question}")
+        print(f"{'='*60}")
+
+        # 1. 规划
+        plan = self.planner.plan(question)
+        print(f"\n  分析计划:")
+        for i, step in enumerate(plan, 1):
+            print(f"    {i}. {step}")
+
+        # 2. 执行
+        final_answer = self.executor.execute(question, plan)
+
+        print(f"\n{'='*60}")
+        print(f"  分析完成")
+        print(f"{'='*60}")
+        return final_answer

+ 287 - 0
Co-creation-projects/CC1227871-StockInsightAgent/rag.py

@@ -0,0 +1,287 @@
+"""Step 7: RAG 投资知识库 — 文档分块 + TF-IDF 检索"""
+import os
+import re
+import json
+import math
+from collections import Counter
+from typing import List, Dict, Tuple
+
+
+class InvestmentKnowledgeBase:
+    """轻量投资知识库 — 无需外部嵌入 API,TF-IDF 检索"""
+
+    def __init__(self, path: str = "memory/knowledge_base.json"):
+        self.path = path
+        self.chunks: List[Dict] = []
+        self._load()
+
+    def _load(self):
+        if os.path.exists(self.path):
+            try:
+                with open(self.path, "r", encoding="utf-8") as f:
+                    self.chunks = json.load(f)
+            except (json.JSONDecodeError, IOError):
+                self.chunks = []
+
+    def _save(self):
+        os.makedirs(os.path.dirname(self.path), exist_ok=True)
+        with open(self.path, "w", encoding="utf-8") as f:
+            json.dump(self.chunks, f, ensure_ascii=False, indent=2)
+
+    # ===== 文档导入 =====
+    def add_text(self, text: str, title: str = "", source: str = "") -> str:
+        """导入文本,自动分块"""
+        chunks = self._chunk_text(text, title, source)
+        self.chunks.extend(chunks)
+        self._save()
+        return f"已导入 '{title}',共 {len(chunks)} 个知识块 (总计 {len(self.chunks)} 块)"
+
+    def add_file(self, filepath: str) -> str:
+        """导入文件 (支持 .txt .md)"""
+        if not os.path.exists(filepath):
+            return f"文件不存在: {filepath}"
+        try:
+            try:
+                with open(filepath, "r", encoding="utf-8") as f:
+                    text = f.read()
+            except UnicodeDecodeError:
+                with open(filepath, "r", encoding="gbk") as f:
+                    text = f.read()
+        except Exception as e:
+            return f"读取文件失败: {e}"
+        title = os.path.basename(filepath)
+        return self.add_text(text, title, filepath)
+
+    def _chunk_text(self, text: str, title: str, source: str,
+                    chunk_size: int = 300, overlap: int = 50) -> List[Dict]:
+        """按段落+句子边界智能分块"""
+        # 先按段落分割
+        paragraphs = re.split(r"\n\s*\n", text)
+        chunks = []
+        current = ""
+        for para in paragraphs:
+            para = para.strip()
+            if not para:
+                continue
+            if len(current) + len(para) < chunk_size:
+                current += ("\n" if current else "") + para
+            else:
+                if current:
+                    chunks.append(current)
+                # 如果段落太长,按句子分
+                if len(para) > chunk_size:
+                    sentences = re.split(r"(?<=[。!?\.!?])\s*", para)
+                    sub = ""
+                    for s in sentences:
+                        if len(sub) + len(s) < chunk_size:
+                            sub += s
+                        else:
+                            if sub:
+                                chunks.append(sub)
+                            sub = s
+                    if sub:
+                        current = sub
+                    else:
+                        current = ""
+                else:
+                    current = para
+        if current:
+            chunks.append(current)
+
+        return [{
+            "id": f"{title}_{i}",
+            "title": title,
+            "source": source,
+            "content": c,
+        } for i, c in enumerate(chunks)]
+
+    # ===== 检索 =====
+    def search(self, query: str, top_k: int = 5) -> str:
+        """TF-IDF 检索最相关的知识块"""
+        if not self.chunks:
+            return "知识库为空。可以用 '导入知识 文件路径' 来添加文档。"
+
+        # 构建词汇表
+        all_docs = [c["content"] for c in self.chunks]
+        tokenized_docs = [self._tokenize(d) for d in all_docs]
+        tokenized_query = self._tokenize(query)
+
+        # TF-IDF 计算
+        df = Counter()
+        for tokens in tokenized_docs:
+            df.update(set(tokens))
+        N = len(tokenized_docs)
+
+        scores = []
+        for i, doc_tokens in enumerate(tokenized_docs):
+            tf = Counter(doc_tokens)
+            score = 0
+            for term in set(tokenized_query):
+                if term in tf:
+                    tf_val = tf[term] / max(len(doc_tokens), 1)
+                    idf_val = math.log((N + 1) / (df[term] + 1)) + 1
+                    score += tf_val * idf_val
+            if score > 0:
+                scores.append((score, i))
+
+        scores.sort(key=lambda x: x[0], reverse=True)
+
+        if not scores:
+            # 回退到关键词匹配
+            for i, (doc, doc_tokens) in enumerate(zip(all_docs, tokenized_docs)):
+                if any(kw in doc_tokens for kw in tokenized_query):
+                    scores.append((0.5, i))
+            scores.sort(key=lambda x: x[0], reverse=True)
+
+        if not scores:
+            return f"未找到与 '{query}' 相关的知识"
+
+        lines = [f"知识库检索结果 (查询: '{query}'):"]
+        for score, idx in scores[:top_k]:
+            chunk = self.chunks[idx]
+            lines.append(f"\n--- [{score:.2f}] {chunk['title']} ---")
+            lines.append(chunk["content"][:400])
+
+        return "\n".join(lines)
+
+    def _tokenize(self, text: str) -> List[str]:
+        """简单中文分词 (2-gram)"""
+        # 提取中文字符和英文单词
+        words = re.findall(r"[一-鿿]{1,2}|[a-zA-Z]+", text.lower())
+        return [w for w in words if len(w) >= 2]
+
+    def stats(self) -> str:
+        titles = set(c["title"] for c in self.chunks)
+        return (f"知识库: {len(self.chunks)} 个知识块, "
+                f"{len(titles)} 篇文档")
+
+    def clear(self) -> str:
+        self.chunks = []
+        self._save()
+        return "知识库已清空"
+
+
+# ===== 预置投资知识 =====
+INVESTMENT_KNOWLEDGE = """
+# 股票估值方法
+
+## 市盈率 (PE)
+PE = 股价 / 每股收益。反映市场愿意为每元利润支付的价格。
+- PE < 10: 可能低估(需排除盈利质量差的情况)
+- PE 10-20: 合理区间
+- PE 20-30: 中等偏高,通常对应成长股
+- PE > 30: 高估值,需有高增长支撑
+行业差异大:银行 PE 通常 5-10 倍,科技股 PE 可达 30-50 倍。
+
+## 市净率 (PB)
+PB = 股价 / 每股净资产。适用于重资产行业(银行、地产、制造业)。
+- PB < 1: 破净,可能严重低估
+- PB 1-2: 合理偏低
+- PB 2-5: 正常水平
+- PB > 5: 偏高,需有高 ROE 支撑
+
+## PEG 指标
+PEG = PE / 净利润增长率(%)。用于成长股估值。
+- PEG < 0.5: 显著低估
+- PEG 0.5-1.0: 合理偏低
+- PEG 1.0-1.5: 合理
+- PEG > 2.0: 高估
+
+## 股息率
+股息率 = 每股分红 / 股价。衡量现金回报。
+- 股息率 > 4%: 高股息,防御性强
+- 股息率 2-4%: 正常水平
+- 股息率 < 2%: 偏低
+
+# 技术指标解读
+
+## MACD 金叉死叉
+- 金叉: DIF 上穿 DEA,买入信号。零轴上方金叉更强。
+- 死叉: DIF 下穿 DEA,卖出信号。零轴下方死叉更弱。
+- 顶背离: 股价新高 MACD 未新高,见顶信号。
+- 底背离: 股价新低 MACD 未新低,见底信号。
+
+## RSI 相对强弱指标
+- RSI > 80: 严重超买,回调风险大
+- RSI 70-80: 超买区域,短期可能回调
+- RSI 30-70: 正常区间
+- RSI 20-30: 超卖区域,短期可能反弹
+- RSI < 20: 严重超卖,反弹概率高
+
+## 均线系统
+- 多头排列: MA5 > MA10 > MA20 > MA60,上升趋势
+- 空头排列: MA5 < MA10 < MA20 < MA60,下降趋势
+- 金叉: 短期均线上穿长期均线
+- 死叉: 短期均线下穿长期均线
+
+## 布林带
+- 价格触及上轨: 短期超买,可能回调
+- 价格触及下轨: 短期超卖,可能反弹
+- 带宽收窄: 变盘信号,可能突破
+- 带宽扩大: 趋势加速
+
+# 风险控制原则
+
+## 仓位管理
+- 单只股票不超过总仓位 20%
+- 单一行业不超过总仓位 30%
+- 永远保留 10-20% 现金应对极端情况
+- 分批建仓: 至少分 3 次买入,降低成本集中风险
+
+## 止损原则
+- 技术止损: 跌破关键支撑位(MA60/前低)止损
+- 比例止损: 亏损超过 8-10% 无条件止损
+- 时间止损: 买入后 20 个交易日未达预期,重新评估
+- 基本面止损: 公司基本面出现重大恶化,立即止损
+
+## 风险收益比
+- 每笔交易的风险收益比应 >= 1:2
+- 预期收益应至少是潜在亏损的 2 倍
+
+# A股交易规则
+
+## 交易时间
+- 早盘集合竞价: 9:15-9:25
+- 连续竞价: 9:30-11:30, 13:00-15:00
+- 深交所尾盘集合竞价: 14:57-15:00
+
+## 涨跌幅限制
+- 主板: ±10%
+- 创业板(300开头)/科创板(688开头): ±20%
+- ST 股票: ±5%
+- 新股上市前 5 日无涨跌幅限制
+
+## T+1 制度
+A股实行 T+1 交易,当日买入次日才能卖出。
+"""
+
+
+# 全局单例
+_kb_instance = None
+
+
+def get_kb() -> InvestmentKnowledgeBase:
+    global _kb_instance
+    if _kb_instance is None:
+        _kb_instance = InvestmentKnowledgeBase()
+        # 首次初始化时导入预置知识
+        if not _kb_instance.chunks:
+            _kb_instance.add_text(INVESTMENT_KNOWLEDGE, "投资基础知识", "built-in")
+    return _kb_instance
+
+
+# ===== 工具函数 =====
+
+def rag_search(query: str) -> str:
+    """搜索投资知识库。输入: 查询关键词或问题"""
+    return get_kb().search(query.strip())
+
+
+def rag_import(query: str) -> str:
+    """导入文档到知识库。输入: 文件路径"""
+    return get_kb().add_file(query.strip())
+
+
+def rag_stats(query: str = "") -> str:
+    """查看知识库统计"""
+    return get_kb().stats()

+ 200 - 0
Co-creation-projects/CC1227871-StockInsightAgent/reflection_agent.py

@@ -0,0 +1,200 @@
+"""Step 4: Reflection 反思模式 — 分析报告自审与优化
+来自 hello-agents 教程第4章 Reflection 范式:
+  初始分析 -> 反思评审 -> 优化改进 -> 循环直到无需改进
+"""
+from typing import List, Dict, Any
+from llm_client import HelloAgentsLLM
+from tools import (
+    get_realtime_quote, get_historical_data, get_financial_data,
+    calc_indicators, get_news
+)
+
+
+class Memory:
+    """短期记忆:存储分析轨迹(初始报告 + 反思 + 改进报告)"""
+    def __init__(self):
+        self.records: List[Dict[str, Any]] = []
+
+    def add_record(self, record_type: str, content: str):
+        self.records.append({"type": record_type, "content": content})
+        print(f"  [记忆] 新增 '{record_type}' 记录")
+
+    def get_trajectory(self) -> str:
+        parts = []
+        for r in self.records:
+            label = "分析报告" if r['type'] == 'execution' else "评审意见"
+            parts.append(f"--- {label} ---\n{r['content']}")
+        return "\n\n".join(parts)
+
+    def get_last_execution(self) -> str:
+        for r in reversed(self.records):
+            if r['type'] == 'execution':
+                return r['content']
+        return None
+
+
+# ===== 提示词模板 =====
+
+INITIAL_ANALYSIS_PROMPT = """你是资深股票分析师。请对以下股票进行全面分析。
+
+## 可用数据
+{data_section}
+
+## 分析要求
+{task}
+
+请输出一份结构化的分析报告,包含:
+1. 基本概况与走势判断
+2. 技术面分析(趋势、均线、指标、关键价位)
+3. 基本面与估值分析
+4. 消息面与市场情绪
+5. 风险提示
+6. 短期/中长期操作建议
+"""
+
+REFLECTION_PROMPT = """你是极其严格的股票投资评审专家。你的任务是审查以下分析报告,找出缺陷和遗漏。
+
+## 原始分析需求
+{task}
+
+## 待审查报告
+{report}
+
+## 评审维度
+1. **数据完整性**: 是否遗漏了关键数据维度?是否有数据解读错误?
+2. **风险覆盖**: 是否遗漏了重要风险因素?(如:行业政策风险、汇率风险、大股东减持、解禁压力)
+3. **逻辑一致性**: 技术面/基本面/消息面的结论是否一致?是否有自相矛盾?
+4. **盲区检查**: 有没有未考虑的视角?(如:产业链上下游、跨市场联动、资金流向)
+5. **反向思考**: 如果最终判断是看多,请从看空角度挑战;反之亦然。有没有可能判断错了?
+
+请直接输出你的评审意见,指出至少3个具体的缺陷或遗漏。
+如果分析报告已经全面、严谨、无明显疏漏,请回答"无需改进"。
+"""
+
+REFINE_PROMPT = """你是资深股票分析师。评审专家指出了你上一轮分析报告的缺陷。
+
+## 原始需求
+{task}
+
+## 你的上一轮报告
+{previous_report}
+
+## 评审意见
+{reflection}
+
+请基于评审意见,生成一份改进后的完整分析报告。要特别针对评审指出的问题补充分析和修正。
+输出完整的改进版报告(6个章节结构不变,但内容要体现反思后的改进)。
+"""
+
+
+class ReflectionStockAgent:
+    """反思式股票分析 Agent — 分析→评审→改进 循环"""
+
+    TOOLS = {
+        "GetRealtimeQuote": get_realtime_quote,
+        "GetHistoricalData": get_historical_data,
+        "CalcIndicators": calc_indicators,
+        "GetFinancialData": get_financial_data,
+        "GetNews": get_news,
+    }
+
+    def __init__(self, llm_client: HelloAgentsLLM, max_iterations: int = 2):
+        self.llm_client = llm_client
+        self.memory = Memory()
+        self.max_iterations = max_iterations
+
+    def run(self, task: str):
+        print(f"\n{'='*60}")
+        print(f"  Reflection 反思模式 (最多{self.max_iterations}轮)")
+        print(f"  问题: {task}")
+        print(f"{'='*60}")
+
+        # --- 阶段1: 自动采集数据 ---
+        print("\n  [阶段1] 自动采集数据...")
+        data_text = self._collect_data(task)
+
+        # --- 阶段2: 初始分析 ---
+        print(f"\n  [阶段2] 生成初始分析报告...")
+        initial_prompt = INITIAL_ANALYSIS_PROMPT.format(
+            data_section=data_text, task=task
+        )
+        messages = [{"role": "user", "content": initial_prompt}]
+        initial_report = self.llm_client.think(messages=messages) or ""
+        self.memory.add_record("execution", initial_report)
+        print(f"  [初始报告] 已生成 ({len(initial_report)} 字)")
+
+        # --- 阶段3: 反思-改进循环 ---
+        for iteration in range(self.max_iterations):
+            print(f"\n  [阶段3] 第 {iteration+1}/{self.max_iterations} 轮反思...")
+
+            # 评审
+            reflect_prompt = REFLECTION_PROMPT.format(
+                task=task, report=self.memory.get_last_execution()
+            )
+            messages = [{"role": "user", "content": reflect_prompt}]
+            feedback = self.llm_client.think(messages=messages) or ""
+            self.memory.add_record("reflection", feedback)
+
+            # 检查收敛
+            if "无需改进" in feedback:
+                print("\n  [评审] 报告已无明显缺陷,反思结束。")
+                break
+
+            # 改进
+            print(f"\n  [阶段3] 基于评审意见改进报告...")
+            refine_prompt = REFINE_PROMPT.format(
+                task=task,
+                previous_report=self.memory.get_last_execution(),
+                reflection=feedback,
+            )
+            messages = [{"role": "user", "content": refine_prompt}]
+            refined_report = self.llm_client.think(messages=messages) or ""
+            self.memory.add_record("execution", refined_report)
+            print(f"  [改进报告] 已生成 ({len(refined_report)} 字)")
+
+        # --- 输出最终报告 ---
+        final_report = self.memory.get_last_execution()
+        print(f"\n{'='*60}")
+        print(f"  最终分析报告 (经 {sum(1 for r in self.memory.records if r['type']=='reflection')} 轮反思)")
+        print(f"{'='*60}")
+        print(final_report)
+        return final_report
+
+    def _collect_data(self, task: str) -> str:
+        """自动从任务中提取股票代码,采集关键数据"""
+        import re
+
+        # 提取股票代码
+        codes = re.findall(r"\b(\d{6})\b", task)
+        if not codes:
+            return "(未能自动识别股票代码,请在问题中包含6位代码)"
+
+        code = codes[0]
+        parts = []
+
+        # 实时行情
+        print(f"    [采集] 实时行情 {code}...")
+        r = self.TOOLS["GetRealtimeQuote"](code)
+        parts.append(f"### 实时行情\n{r}")
+
+        # 历史K线 (60天)
+        print(f"    [采集] 60天K线 {code}...")
+        r = self.TOOLS["GetHistoricalData"](f"{code}|daily|60")
+        parts.append(f"### 60天历史K线\n{r}")
+
+        # 技术指标 (120天)
+        print(f"    [采集] 技术指标 {code}...")
+        r = self.TOOLS["CalcIndicators"](f"{code}|daily|120")
+        parts.append(f"### 技术指标\n{r}")
+
+        # 财务数据
+        print(f"    [采集] 财务数据 {code}...")
+        r = self.TOOLS["GetFinancialData"](code)
+        parts.append(f"### 财务数据\n{r}")
+
+        # 新闻
+        print(f"    [采集] 新闻舆情 {code}...")
+        r = self.TOOLS["GetNews"](code)
+        parts.append(f"### 新闻舆情\n{r}")
+
+        return "\n\n".join(parts)

+ 7 - 0
Co-creation-projects/CC1227871-StockInsightAgent/requirements.txt

@@ -0,0 +1,7 @@
+akshare>=1.15
+openai>=1.100
+python-dotenv>=0.21
+numpy>=1.26
+pandas>=2.2
+gradio>=5.0
+hello-agents>=1.0

+ 30 - 0
Co-creation-projects/CC1227871-StockInsightAgent/test_tools.py

@@ -0,0 +1,30 @@
+"""快速验证:单独测试每个工具函数"""
+from tools import get_realtime_quote, get_historical_data, get_financial_data, calc_indicators, get_news
+
+if __name__ == "__main__":
+    print("=" * 60)
+    print("测试 1: 实时行情")
+    print("=" * 60)
+    print(get_realtime_quote("600519"))
+
+    print("\n" + "=" * 60)
+    print("测试 2: 历史K线")
+    print("=" * 60)
+    print(get_historical_data("600519|daily|10"))
+
+    print("\n" + "=" * 60)
+    print("测试 3: 技术指标")
+    print("=" * 60)
+    print(calc_indicators("600519|daily|60"))
+
+    print("\n" + "=" * 60)
+    print("测试 4: 财务数据")
+    print("=" * 60)
+    print(get_financial_data("600519"))
+
+    print("\n" + "=" * 60)
+    print("测试 5: 新闻")
+    print("=" * 60)
+    print(get_news("600519"))
+
+    print("\n全部工具测试完成!")

+ 478 - 0
Co-creation-projects/CC1227871-StockInsightAgent/tools.py

@@ -0,0 +1,478 @@
+"""Step 2: 股票分析工具 — akshare (Sina/Tencent 源) 真实数据 + 技术指标"""
+import time
+import numpy as np
+import pandas as pd
+import akshare as ak
+from datetime import datetime, timedelta
+from typing import Dict, Any
+
+
+class ToolExecutor:
+    """工具注册与执行中心"""
+    def __init__(self):
+        self.tools: Dict[str, Dict[str, Any]] = {}
+
+    def registerTool(self, name: str, description: str, func: callable):
+        self.tools[name] = {"description": description, "func": func}
+        print(f"  [工具] {name} 已注册")
+
+    def getTool(self, name: str) -> callable:
+        return self.tools.get(name, {}).get("func")
+
+    def getAvailableTools(self) -> str:
+        return "\n".join([
+            f"- {name}: {info['description']}"
+            for name, info in self.tools.items()
+        ])
+
+
+# ==================== 辅助函数 ====================
+
+def _to_sina_code(code: str) -> str:
+    """将纯数字代码转换为 Sina 格式 (sh600519 / sz000001)"""
+    code = code.strip()
+    if code.startswith("6"):
+        return f"sh{code}"
+    elif code.startswith(("0", "3")):
+        return f"sz{code}"
+    return code
+
+
+def _resolve_symbol(query: str) -> str:
+    """解析股票代码:支持名称搜索,返回纯数字代码"""
+    query = query.strip()
+    if query.isdigit() and len(query) == 6:
+        return query
+
+    # 尝试使用 akshare stock_info_a_code_name 映射
+    try:
+        import akshare as ak
+        stock_info = ak.stock_info_a_code_name()
+
+        # 匹配名称
+        match = stock_info[stock_info["name"] == query]
+        if not match.empty:
+            return match["code"].values[0]
+
+        # 模糊匹配名称
+        fuzzy_match = stock_info[stock_info["name"].str.contains(query, na=False)]
+        if not fuzzy_match.empty:
+            return fuzzy_match["code"].values[0]
+    except Exception:
+        pass
+
+    # 尝试通过新闻接口反查(间接方式)
+    try:
+        time.sleep(1)
+        info = ak.stock_individual_info_em(symbol=query) if query.isdigit() else None
+        if info is not None and len(info) > 0:
+            return query
+    except Exception:
+        pass
+    return query
+
+
+def _safe_fetch(func, *args, **kwargs):
+    """带重试的数据获取"""
+    import random
+    for attempt in range(3):
+        try:
+            return func(*args, **kwargs)
+        except Exception as e:
+            if attempt < 2:
+                time.sleep(4 + random.random() * 2)
+            else:
+                return None
+
+
+# ==================== 工具函数 ====================
+
+def get_realtime_quote(query: str) -> str:
+    """
+    获取A股最新行情。输入: 股票代码(如"600519")或部分名称。
+    数据源: 东方财富个股信息 + Sina 日线最新一条。
+    """
+    print(f"  [查询实时行情] {query}")
+    symbol = _resolve_symbol(query)
+
+    # 使用 Sina 日线获取最新价格
+    try:
+        sina_code = _to_sina_code(symbol)
+        df = _safe_fetch(ak.stock_zh_a_daily,
+                         symbol=sina_code,
+                         start_date=(datetime.now() - timedelta(days=10)).strftime("%Y%m%d"),
+                         end_date=datetime.now().strftime("%Y%m%d"),
+                         adjust="qfq")
+        if df is None or df.empty:
+            df = _safe_fetch(ak.stock_zh_a_hist, symbol=symbol, period="daily", start_date=(datetime.now() - timedelta(days=10)).strftime("%Y%m%d"), end_date=datetime.now().strftime("%Y%m%d"), adjust="qfq")
+
+        if df is None or df.empty:
+            return f"未找到 {symbol} 的行情数据"
+
+        if df is not None and not df.empty:
+            # 统一列名为英文以适配下游逻辑
+            rename_map = {
+                "日期": "date", "开盘": "open", "收盘": "close",
+                "最高": "high", "最低": "low", "成交量": "volume", "成交额": "amount"
+            }
+            df = df.rename(columns=rename_map)
+    except Exception as e:
+        return f"获取行情失败: {e}"
+
+    latest = df.iloc[-1]
+    prev = df.iloc[-2] if len(df) > 1 else latest
+
+    # 尝试获取个股信息(名称、PE等)
+    name = symbol
+    pe = "N/A"
+    try:
+        time.sleep(2)
+        info = ak.stock_individual_info_em(symbol=symbol)
+        name_row = info[info["item"] == "股票简称"]
+        if not name_row.empty:
+            name = name_row["value"].values[0]
+        pe_row = info[info["item"] == "市盈率-动态"]
+        if not pe_row.empty:
+            pe = pe_row["value"].values[0]
+    except Exception:
+        pass
+
+    chg_pct = (latest["close"] - prev["close"]) / prev["close"] * 100
+
+    return (
+        f"{name}({symbol})\n"
+        f"  最新价: {latest['close']:.2f}  涨跌幅: {chg_pct:+.2f}%\n"
+        f"  今开: {latest['open']:.2f}  最高: {latest['high']:.2f}  最低: {latest['low']:.2f}\n"
+        f"  成交量: {latest.get('volume', 'N/A')}手  成交额: {latest.get('amount', 'N/A')}元\n"
+        f"  市盈率(动态): {pe}"
+    )
+
+
+def get_historical_data(query: str) -> str:
+    """
+    获取历史K线数据。输入格式: "symbol|period|days"
+    period: daily/weekly/monthly(日/周/月), days: 最近多少个周期(默认60)
+    示例: "600519|daily|30"
+    数据源: Sina
+    """
+    print(f"  [查询历史数据] {query}")
+
+    parts = query.strip().split("|")
+    symbol = _resolve_symbol(parts[0].strip())
+    period = parts[1].strip() if len(parts) > 1 else "daily"
+    try:
+        days = int(parts[2]) if len(parts) > 2 else 60
+    except ValueError:
+        days = 60
+
+    end = datetime.now().strftime("%Y%m%d")
+    start = (datetime.now() - timedelta(days=days * 30)).strftime("%Y%m%d") if period != "daily" else (datetime.now() - timedelta(days=days * 2)).strftime("%Y%m%d")
+
+    try:
+        sina_code = _to_sina_code(symbol)
+        period_map = {"daily": "daily", "weekly": "weekly", "monthly": "monthly"}
+        ak_period = period_map.get(period, "daily")
+        hist = _safe_fetch(ak.stock_zh_a_hist,
+                           symbol=symbol, period=ak_period, start_date=start,
+                           end_date=end, adjust="qfq")
+        if hist is None or hist.empty:
+            hist = _safe_fetch(ak.stock_zh_a_daily,
+                           symbol=sina_code, start_date=start,
+                           end_date=end, adjust="qfq")
+        if hist is None or hist.empty:
+            # 尝试 Tencent 源
+            time.sleep(2)
+            hist = ak.stock_zh_a_hist_tx(symbol=sina_code,
+                                         start_date=start, end_date=end)
+            if hist is None or hist.empty:
+                return f"未找到 {symbol} 的历史数据"
+            # Tencent 列名映射
+            hist = hist.rename(columns={
+                "date": "date", "open": "open", "close": "close",
+                "high": "high", "low": "low", "amount": "volume"
+            })
+        elif hist is not None and not hist.empty:
+            # 统一列名为英文以适配下游逻辑
+            rename_map = {
+                "日期": "date", "开盘": "open", "收盘": "close",
+                "最高": "high", "最低": "low", "成交量": "volume", "成交额": "amount"
+            }
+            hist = hist.rename(columns=rename_map)
+    except Exception as e:
+        return f"获取历史数据失败: {e}"
+
+    if hist is None or hist.empty:
+        return f"未找到 {symbol} 的历史数据"
+
+    hist = hist.tail(days)
+    latest = hist.iloc[-1]
+    first = hist.iloc[0]
+    change = (latest["close"] - first["close"]) / first["close"] * 100
+    date_col = "date" if "date" in hist.columns else hist.columns[0]
+    close_col = "close"
+
+    lines = [f"{symbol} daily K线 (近{len(hist)}条, {hist.iloc[0][date_col]} ~ {hist.iloc[-1][date_col]})"]
+    lines.append(f"  区间涨跌: {change:.2f}%")
+    lines.append(f"  最新: O={latest['open']:.2f} H={latest['high']:.2f} L={latest['low']:.2f} C={latest[close_col]:.2f}")
+    lines.append(f"  区间最高: {hist['high'].max():.2f}  区间最低: {hist['low'].min():.2f}")
+    closes = [f"{x:.2f}" for x in hist[close_col].tail(5).tolist()]
+    lines.append(f"  近5日收盘: {' -> '.join(closes)}")
+
+    return "\n".join(lines)
+
+
+def get_financial_data(symbol: str) -> str:
+    """
+    获取核心财务指标。输入: 股票代码(如"600519")
+    数据源: akshare stock_financial_abstract (Sina)
+    返回: 净利润、营收、ROE、毛利率、增长率等关键指标。
+    """
+    print(f"  [查询财务数据] {symbol}")
+    symbol = symbol.strip()
+
+    try:
+        df = _safe_fetch(ak.stock_financial_abstract, symbol=symbol)
+        if df is None or df.empty:
+            return f"未找到 {symbol} 的财务数据"
+    except Exception as e:
+        return f"获取财务数据失败: {e}"
+
+    # 取最近两期季度数据列
+    date_cols = [c for c in df.columns if c.isdigit() and len(c) == 8]
+    if len(date_cols) < 2:
+        return f"{symbol} 财务数据不足"
+    latest_col = date_cols[0]
+    prev_col = date_cols[1]
+
+    lines = [f"{symbol} 核心财务数据 (最新: {latest_col} vs 上期: {prev_col})"]
+
+    # 关键指标映射
+    key_metrics = [
+        ("归母净利润", "归母净利润", "元"),
+        ("营业总收入", "营业总收入", "元"),
+        ("净利润", "净利润", "元"),
+        ("扣非净利润", "扣非净利润", "元"),
+        ("基本每股收益", "基本每股收益", "元"),
+        ("每股净资产", "每股净资产", "元"),
+        ("净资产收益率", "净资产收益率", "%"),
+        ("总资产收益率", "总资产收益率", "%"),
+        ("销售毛利率", "销售毛利率", "%"),
+        ("销售净利率", "销售净利率", "%"),
+        ("营收同比增长", "营业总收入同比增长", "%"),
+        ("归母净利润同比增长", "归属母公司股东的净利润同比增长", "%"),
+        ("资产负债率", "资产负债率", "%"),
+        ("流动比率", "流动比率", ""),
+        ("速动比率", "速动比率", ""),
+    ]
+
+    for label, metric_name, unit in key_metrics:
+        row = df[df["指标"] == metric_name]
+        if row.empty:
+            continue
+        val = row[latest_col].values[0]
+        prev_val = row[prev_col].values[0] if prev_col in row.columns else None
+
+        if pd.isna(val):
+            continue
+
+        try:
+            if unit == "元" and abs(float(val)) > 1e8:
+                val_str = f"{float(val)/1e8:.2f}亿"
+                if prev_val is not None and not pd.isna(prev_val) and abs(float(prev_val)) > 1e8:
+                    prev_str = f"{float(prev_val)/1e8:.2f}亿"
+                else:
+                    prev_str = None
+            elif unit == "%":
+                val_str = f"{float(val):.2f}%"
+                prev_str = f"{float(prev_val):.2f}%" if prev_val is not None and not pd.isna(prev_val) else None
+            else:
+                val_str = f"{float(val):.4f}"
+                prev_str = f"{float(prev_val):.4f}" if prev_val is not None and not pd.isna(prev_val) else None
+        except (ValueError, TypeError):
+            val_str = str(val)
+            prev_str = str(prev_val) if prev_val is not None else None
+
+        line = f"  {label}: {val_str}"
+        if prev_str:
+            try:
+                trend = "[+]" if float(val) > float(prev_val) else "[-]"
+                line += f" {trend} (上期: {prev_str})"
+            except (ValueError, TypeError):
+                line += f" (上期: {prev_str})"
+        lines.append(line)
+
+    return "\n".join(lines)
+
+
+def calc_indicators(query: str) -> str:
+    """
+    计算技术指标。输入格式: "symbol|daily|days"
+    返回: MA5/10/20/60, MACD(DIF/DEA/柱), RSI14, 布林带, 支撑压力位。
+    数据源: Sina
+    """
+    print(f"  [计算技术指标] {query}")
+
+    parts = query.strip().split("|")
+    symbol = parts[0].strip()
+    try:
+        days = min(int(parts[2]), 365) if len(parts) > 2 else 120
+    except ValueError:
+        days = 120
+
+    end = datetime.now().strftime("%Y%m%d")
+    start = (datetime.now() - timedelta(days=days * 2)).strftime("%Y%m%d")
+
+    try:
+        sina_code = _to_sina_code(symbol)
+        df = _safe_fetch(ak.stock_zh_a_daily,
+                         symbol=sina_code, start_date=start,
+                         end_date=end, adjust="qfq")
+        if df is None or df.empty:
+            # 尝试新版 API fallback
+            df = _safe_fetch(ak.stock_zh_a_hist, symbol=symbol, period="daily", start_date=start, end_date=end, adjust="qfq")
+
+        if df is None or df.empty:
+            return f"未找到 {symbol} 的数据"
+
+        if df is not None and not df.empty:
+            # 统一列名为英文以适配下游逻辑
+            rename_map = {
+                "日期": "date", "开盘": "open", "收盘": "close",
+                "最高": "high", "最低": "low", "成交量": "volume", "成交额": "amount"
+            }
+            df = df.rename(columns=rename_map)
+    except Exception as e:
+        return f"获取数据失败: {e}"
+
+    df = df.tail(days).reset_index(drop=True)
+    close = df["close"].astype(float)
+    high = df["high"].astype(float)
+    low = df["low"].astype(float)
+    latest_close = close.iloc[-1]
+
+    lines = [f"{symbol} 技术指标分析 (基于近{len(df)}条日K线)"]
+    lines.append(f"  最新收盘价: {latest_close:.2f}")
+    lines.append("")
+
+    # --- 移动均线 ---
+    lines.append("--- 移动均线 ---")
+    ma_signals = []
+    for ma in [5, 10, 20, 60]:
+        if len(close) >= ma:
+            ma_val = close.rolling(window=ma).mean().iloc[-1]
+            relation = "[+]多头" if latest_close > ma_val else "[-]空头"
+            lines.append(f"  MA{ma:>2}: {ma_val:.2f}  ({relation})")
+            ma_signals.append(latest_close > ma_val)
+    if ma_signals:
+        bullish = sum(ma_signals)
+        lines.append(f"  均线综合: {bullish}/{len(ma_signals)} 条支撑  "
+                      f"({'偏多' if bullish >= 3 else '偏空' if bullish <= 1 else '震荡'})")
+
+    # --- MACD ---
+    lines.append("")
+    lines.append("--- MACD (12,26,9) ---")
+    ema12 = close.ewm(span=12).mean()
+    ema26 = close.ewm(span=26).mean()
+    dif = ema12 - ema26
+    dea = dif.ewm(span=9).mean()
+    macd_bar = 2 * (dif - dea)
+
+    lines.append(f"  DIF: {dif.iloc[-1]:.3f}  DEA: {dea.iloc[-1]:.3f}")
+    bar_color = "红柱" if macd_bar.iloc[-1] > 0 else "绿柱"
+    lines.append(f"  MACD柱: {macd_bar.iloc[-1]:.3f}  ({bar_color})")
+
+    if len(dif) >= 2:
+        if dif.iloc[-1] > dea.iloc[-1] and dif.iloc[-2] <= dea.iloc[-2]:
+            lines.append("  [!] 信号: 金叉(买入信号)")
+        elif dif.iloc[-1] < dea.iloc[-1] and dif.iloc[-2] >= dea.iloc[-2]:
+            lines.append("  [!] 信号: 死叉(卖出信号)")
+        else:
+            trend = "多头" if dif.iloc[-1] > dea.iloc[-1] else "空头"
+            lines.append(f"  趋势: {trend}持续")
+    else:
+        trend = "多头" if dif.iloc[-1] > dea.iloc[-1] else "空头"
+        lines.append(f"  趋势: {trend}持续")
+
+    # --- RSI ---
+    lines.append("")
+    lines.append("--- RSI (14) ---")
+    delta = close.diff()
+    gain = delta.clip(lower=0)
+    loss = (-delta).clip(lower=0)
+    avg_gain = gain.ewm(alpha=1/14).mean()
+    avg_loss = loss.ewm(alpha=1/14).mean()
+    rs = avg_gain / avg_loss
+    rsi = 100 - (100 / (1 + rs))
+    rsi_val = rsi.iloc[-1]
+
+    if rsi_val > 80:
+        rsi_status = "严重超买"
+    elif rsi_val > 70:
+        rsi_status = "超买区域"
+    elif rsi_val < 20:
+        rsi_status = "严重超卖"
+    elif rsi_val < 30:
+        rsi_status = "超卖区域"
+    else:
+        rsi_status = "中性"
+    lines.append(f"  RSI: {rsi_val:.1f} ({rsi_status})")
+
+    # --- 布林带 ---
+    lines.append("")
+    lines.append("--- 布林带 (20,2) ---")
+    bb_mid = close.rolling(window=20).mean()
+    bb_std = close.rolling(window=20).std()
+    bb_upper = bb_mid + 2 * bb_std
+    bb_lower = bb_mid - 2 * bb_std
+    lines.append(f"  上轨: {bb_upper.iloc[-1]:.2f}")
+    lines.append(f"  中轨: {bb_mid.iloc[-1]:.2f}")
+    lines.append(f"  下轨: {bb_lower.iloc[-1]:.2f}")
+    bb_pos = (latest_close - bb_lower.iloc[-1]) / (bb_upper.iloc[-1] - bb_lower.iloc[-1])
+    if bb_pos > 0.9:
+        lines.append(f"  价格位于布林带上沿附近,注意压力")
+    elif bb_pos < 0.1:
+        lines.append(f"  价格位于布林带下沿附近,关注支撑")
+    else:
+        lines.append(f"  价格位于布林带中轨附近")
+
+    # --- 支撑/压力位 ---
+    lines.append("")
+    lines.append("--- 关键价位 ---")
+    recent_high = high.tail(20).max()
+    recent_low = low.tail(20).min()
+    lines.append(f"  近20日最高: {recent_high:.2f} (压力位)")
+    lines.append(f"  近20日最低: {recent_low:.2f} (支撑位)")
+    if len(close) >= 60:
+        ma60 = close.rolling(60).mean().iloc[-1]
+        lines.append(f"  MA60: {ma60:.2f} (长期支撑/压力)")
+
+    return "\n".join(lines)
+
+
+def get_news(symbol: str) -> str:
+    """
+    获取近期新闻。输入: 股票代码(如"600519")
+    返回最新5条新闻标题。
+    """
+    print(f"  [查询新闻] {symbol}")
+    symbol = symbol.strip()
+
+    try:
+        news_df = _safe_fetch(ak.stock_news_em, symbol=symbol)
+        if news_df is None or news_df.empty:
+            return f"未找到 {symbol} 的相关新闻"
+    except Exception as e:
+        return f"获取新闻失败: {e}"
+
+    recent = news_df.head(5)
+    lines = [f"{symbol} 近期新闻:"]
+    for i, (_, row) in enumerate(recent.iterrows(), 1):
+        title = row.get("新闻标题", "N/A")
+        dt = row.get("发布时间", "")
+        content = row.get("新闻内容", "")
+        summary = content[:80] + "..." if isinstance(content, str) and len(content) > 80 else str(content or "")
+        lines.append(f"  {i}. [{dt}] {title}")
+        if summary:
+            lines.append(f"     {summary}")
+
+    return "\n".join(lines)