diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10db949..5b9d220 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,6 @@ jobs: run: python tools/package_project_helper.py . /dev/null 0 - name: Run detect-secrets - continue-on-error: true run: | if command -v detect-secrets &> /dev/null; then detect-secrets scan --disable-plugin KeywordDetector \ diff --git a/.gitignore b/.gitignore index 6e3f1f9..a7911a6 100644 --- a/.gitignore +++ b/.gitignore @@ -8,12 +8,14 @@ __pycache__/ .ruff_cache/ # runtime +config/runtime_state.yaml logs/ backups/ *.bak *.tmp chat/ -memory/pending_updates/ +memory/ +!memory/.gitkeep # packaging /release/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1418625 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,113 @@ +# Changelog + +## v0.8.0 — 文档同步 + UI 中文标签 + 工程收口 / Docs sync & Chinese UI labels + +- **文档版本同步**:5 份文档(USER_GUIDE / PROJECT_PLAN / FUTURE / COMPREHENSIVE_PROJECT / changelog)版本升至 v0.8.0,模块描述与实际代码对齐 +- **UI 中文标签**:`constants.py` 模型/性能标签改为中文主导,`status_bar.py` 统计/性能行全中文显示,`chat_panel.py` 显示标签同步 +- 合并 v0.7.8 全部变更(性能预算系统、依赖锁定、状态模型文档化、CI 门禁升级、入口页新闻流程修复、README 重构、140 tests) + +## v0.7.8 — 性能预算 + 状态模型 + 工程收口 + +- **性能预算系统**(新模块 `src/performance_budget.py`):所有 LLM 调用按 fast/standard/deep 三档分配 max_tokens 上限 + - 主聊天:700 / 1100 / 1600 + - 微信群聊回复:520 / 760 / 1050,历史行数按档自适应(16 / 28 / 40) + - 开场生成:420 / 620 / 850 + - 新闻摘要:650 / 950 / 1300 + - 新闻群聊讨论:520 / 760 / 1000 +- **依赖锁定**:迁移到 pip-tools 工作流(`requirements.in` → 锁定 `requirements.txt`) +- **状态模型文档化**:`docs/STATE_MODEL.md` 定义真源层级、迁移指南、清理清单 +- **CI 门禁升级**:`detect-secrets` 改为硬门禁 +- **入口页新闻流程修复**:群聊未开始时也能显示新闻阶段面板,`_clear_wechat_news_state` 统一清理 +- **新闻讨论状态同步修复**:`run_discussion_stage` 正确调用 `update_wechat_join_state` +- **README 重构**:展示型结构(Demo / 架构图 / 使用流程 / Roadmap) +- **`.gitignore` 收口**:排除 `config/runtime_state.yaml`、`memory/` +- **140 tests,Ruff clean** + +## v0.7.7 — 模块拆分与服务层解耦 + +- **新闻链路拆分**:`wechat.py`(~550 行)拆分为 4 个专注模块 + 1 个兼容门面 + - `wechat_format.py` — 纯文本 / 格式化工具 + - `wechat_state.py` — 文件 I/O、群聊状态、生命周期管理 + - `wechat_generator.py` — LLM 生成逻辑(群聊讨论、开场、互动回复) + - `wechat_prompt.py` — 系统 / 互动 prompt 加载(自包含,无外部依赖) + - `wechat.py` — 纯兼容门面,仅 re-export,零运行时逻辑 +- **服务层拆分**:`wechat_service.py` 改为直连子模块,绕开门面 +- **UI 逐阶段新闻流**:搜索 → 正文读取 → 生成摘要 → 群聊讨论,四步按钮推进 +- **自适应 max_articles**:fast→2、standard→4、deep→6 +- **安全加固**: + - 自定义 `_SafeHTTPRedirectHandler` 逐跳 SSRF 校验 + 最多 3 次重定向 + - 包扫描增加 GitHub PAT、OpenRouter、通用 key/token 模式 + - detect-secrets 加入 CI 流程 +- **内存保护**:Session logger 50 条自动 flush、2 小时老化警告 +- **防回归 guard**:`test_wechat_decoupling.py` 4 个测试确保模块边界不退化 +- **112 tests,Ruff clean** + +## v0.7.6 — 工程安全与新闻链路收口 + +详细内容见 `changelog/README_v0_7_6.md`。 + +## v0.7.5 — 文档同步收口 + 代码清理 + +- 版本信息同步 10 个文件 +- 死代码清理(chat_stream / 重复函数) +- YAML 同步 mtime canary 优化 +- 重复逻辑合并 +- 详见 `changelog/README_v0_7_5.md` + +## v0.7.4 — 工程体验收口 + +- 自动化版本 bump 工具 +- LLM 配置文档化(`.env.example` 5 个 provider) +- NewsRoundResult 结果对象化(覆盖率/警告/耗时) +- UI 来源可信度展示 +- 详见 `changelog/README_v0_7_4.md` + +## v0.7.3 — 服务层拆分与工程化收口 + +- Wechat news round 下沉到 `src/wechat_service.py` +- session flush 批量提交 +- GitHub Actions CI +- 架构级测试 +- LLM client 参数扩展 +- YAML runtime state 真源化 +- 详见 `changelog/README_v0_7_3.md` + +## v0.7.2 — 代码质量全面收口 + +修复 4 个 Bug、性能优化(缓存/YAML/diff)、架构改善(常量去重/模块拆分)、Streamlit fragment 反模式修复、关键路径错误处理增强。 + +## v0.7.1 — 搜索窗口与来源优化 + +90 天搜索窗口、来源块压缩、摘要覆盖率提示、prompt 乱码修复、覆盖率统计对齐。 + +## v0.7.0 — 多源新闻聚合 + +多源 RSS 聚合、正文三层提取、来源块写入群聊、摘要边界约束、URL 安全检查。 + +## v0.6.9 — 联网搜索增强 + +自由文本联网搜索、进度指示、新闻缓存按 query 隔离、记忆写入加固。 + +## v0.6.8 — 写入与缓存优化 + +.tmp 排除、文件锁重试 finally 清理、LLM client 自动重建、wechat_state 批量写入、新闻 10 分钟缓存。 + +## v0.6.7 — 刷新与健康检查 + +刷新逻辑拆分、notice queue、健康检查只读化、打包排除增强、lru_cache 缓存。 + +## v0.6.6 — 渲染与并发修复 + +未知发言人渲染修复、并发写入安全、打包测试对齐。 + +## v0.6.5 — 氛围与角色优化 + +开场氛围选择、角色归一化兜底、侧栏与 memory 对齐、打包脚本拆分。 + +## v0.6.4 — 首次可用正式包 + +Catppuccin 暗色主题、紫蓝渐变、微信气泡 UI、路由系统、会话隔离、safe_writer。 + +## v0.1 ~ v0.6.3 — 早期探索阶段 + +界面仅纯文本、按钮触发全页刷新、侧边栏与主区域不同步、无视觉资源、自动路由缺失、群聊格式简陋。问题过多,未发布 Release。 diff --git a/COMPREHENSIVE_PROJECT.md b/COMPREHENSIVE_PROJECT.md index 7147482..1d8dea2 100644 --- a/COMPREHENSIVE_PROJECT.md +++ b/COMPREHENSIVE_PROJECT.md @@ -1,7 +1,7 @@ # Study Agent 项目全貌 > 面向新协作者和无上下文接手者的当前阶段总览。 -> 当前开发阶段:`v0.7.5` +> 当前开发阶段:`v0.8.0` ## 1. 项目定义 @@ -31,11 +31,16 @@ ### 核心逻辑 -1. `src/wechat.py`: 微信群、搜索、页面读取、来源块、摘要 -2. `src/mode_manager.py`: 运行态模式和版本状态 -3. `src/session_logger.py`: 会话日志与安全写入 -4. `src/safe_writer.py`: 原子写入、备份、tmp 清理 -5. `src/llm_client.py`: LLM 调用与 client 重置 +1. `src/wechat_format.py`: 群聊文本格式化 +2. `src/wechat_state.py`: 群聊文件 I/O、状态管理 +3. `src/wechat_generator.py`: LLM 群聊生成(开场、互动回复、讨论) +4. `src/wechat_prompt.py`: Prompt 模板加载 +5. `src/wechat_memory.py`: 群聊记忆提取 +6. `src/mode_manager.py`: 运行态模式和版本状态 +7. `src/session_logger.py`: 会话日志与安全写入 +8. `src/safe_writer.py`: 原子写入、备份、tmp 清理 +9. `src/llm_client.py`: LLM 调用与 client 重置 +10. `src/performance_budget.py`: 性能预算(max_tokens 分级) ### UI @@ -45,7 +50,7 @@ ### 文档 -1. `changelog/README_v0_7_1.md`: 当前版本说明 +1. `CHANGELOG.md`: 当前版本说明 2. `USER_GUIDE.md`: 当前使用指南 3. `PROJECT_PLAN.md`: 当前阶段规划 4. `FUTURE.md`: 下一阶段方向 @@ -76,8 +81,8 @@ 如果你要快速接手,推荐顺序: -1. `changelog/README_v0_7_1.md` +1. `CHANGELOG.md` 2. `USER_GUIDE.md` 3. `PROJECT_PLAN.md` -4. `src/wechat.py` +4. `src/wechat_format.py` 5. `src/ui/wechat_panel.py` diff --git a/FUTURE.md b/FUTURE.md index d3a9685..6ea4ebf 100644 --- a/FUTURE.md +++ b/FUTURE.md @@ -1,31 +1,20 @@ # 未来方向 > 本文件记录当前版本之后的优先增强方向。 -> 当前开发阶段:`v0.7.4` 已落地,下一阶段主目标定位为 `v0.7.5`。 - -## v0.7.4 完成情况(工程体验收口) - -v0.7.4 已发布,重点包括: - -1. 自动化版本管理工具 `tools/bump_version.py`,自动同步 7 个文件 -2. LLM 配置文档化:`.env.example` 扩展至 50 行,README 配置节分 4 小节 -3. NewsRoundResult 结果对象化:source_block / article_coverage / elapsed_ms / warnings -4. UI 来源可信度展示:覆盖率条、逐条警告、条目图标区分 +> 当前开发阶段:`v0.8.0` 已落地,下一阶段主目标定位为 `v0.8.1`。 --- -## v0.7.5 重点 - -### 1. 联网搜索质量提升 +## v0.7.9 重点 -### 1. 联网搜索质量提升 +### 1. UI 交互打磨 优先考虑: -1. 进一步优化多源结果质量 -2. 更稳地区分"基于标题"与"基于页面文本" -3. 对 Google News 中转页做更稳的处理 -4. 继续减少低质量页面文本混入 +1. 搜索按钮 cooldown +2. 更明确的 running 状态反馈 +3. 上次搜索结果复用 +4. 网络失败 fallback 话题 ### 2. 引用与来源体验 @@ -36,16 +25,7 @@ v0.7.4 已发布,重点包括: 3. 支持查看本轮来源详情 4. 支持手动清理搜索缓存 -### 3. 搜索交互体验 - -优先考虑: - -1. 搜索按钮 cooldown -2. 更明确的 running 状态 -3. 上次搜索结果复用 -4. 网络失败 fallback 话题 - -### 4. 测试覆盖扩展 +### 3. 测试覆盖扩展 优先考虑: diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 80ea1dd..d889bc2 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -1,7 +1,7 @@ # Study Agent 项目规划 -> 当前活跃阶段:`v0.7.5` -> 当前状态:v0.7.5 文档同步收口已完成,下一阶段 v0.7.6 回归功能增量(联网搜索质量、来源 UI、测试覆盖)。 +> 当前活跃阶段:`v0.8.0` +> 当前状态:v0.8.0 已发布,基于 v0.7.8 的全量变更(性能预算、依赖锁定、状态模型、CI 门禁)追加文档版本同步与 UI 中文标签。 ## 当前定位 @@ -53,19 +53,18 @@ v0.7.4 是一次工程体验收口: 3. NewsRoundResult 结果对象化(source_block / article_coverage / elapsed_ms / warnings) 4. UI 来源可信度展示(覆盖率条、警告逐条展示、条目图标区分) -## v0.7.5 目标 +## v0.8.1 目标 -下一阶段回归功能增量: +下一阶段回归 UI 打磨与稳定性: -1. 联网搜索质量提升(多源结果优化、标题/正文区分增强) -2. 引用与来源 UI 展示优化(折叠、链接可点击、来源详情) -3. 搜索交互体验(按钮 cooldown、状态反馈、失败 fallback) -4. 测试覆盖扩展(UI 模块、LLM 集成、写入操作) -5. 会话状态无界增长防护 +1. UI 打磨(搜索按钮 cooldown、状态反馈、失败 fallback) +2. 会话状态无界增长防护(session_state 上限 + TTL 淘汰) +3. 测试覆盖扩展(UI 模块、LLM 集成、写入操作) +4. 引用与来源体验优化(折叠、链接可点击、来源详情) ## 文档分工 -1. `changelog/README_v0_7_4.md`: 当前版本说明 +1. `CHANGELOG.md`: 当前版本说明 2. `USER_GUIDE.md`: 当前使用方法 3. `FUTURE.md`: 下一阶段方向 4. 历史 `README_v0_x.md`: 版本留档,不再作为当前状态依据 diff --git a/README.md b/README.md index 58b39bf..ad2a7e5 100644 --- a/README.md +++ b/README.md @@ -3,229 +3,243 @@

CI Python 3.12 + 140 tests

-AI 学习搭子系统 —— 联网搜索 + 角色群聊 + 课后总结。 +**一个面向个人学习复盘的本地 AI 学习搭子系统** — 支持角色群聊、联网搜索、长期记忆和课后总结。 -## 功能 +> 不是又一个 AI 问答工具,而是一个会记住你学什么的 AI 学习伙伴。 -- **单人学习对话** — 与 AI 一对一讨论学习内容 -- **课后更新预览** — 总结学习进度,确认后写入记忆 -- **微信群互动** — 四位角色(三月七、刻晴、纳西妲、流萤)群聊讨论 -- **联网搜索** — 多源新闻聚合(Google News + Bing News + RSSHub),支持页面正文读取 -- **来源追溯** — 搜索结果写入群聊记录,可回溯依据 +--- + +## 为什么做这个 + +通用 AI 对话工具擅长回答问题,但不擅长「陪伴学习」: + +- 它们不记得你**昨天**学了什么、**上周**卡在了哪里 +- 它们不会主动帮你**总结**学习进展 +- 它们没有「角色感」—— 严肃还是轻松?鼓励还是挑战?全看随机 + +Study Agent 的定位很明确:**一个运行在你本地的、有长期记忆的、有角色区分的 AI 学习搭子**。它会记住你的学习轨迹,在群聊中用不同角色和你讨论,课后自动总结进展,并把新的知识写进长期记忆。 + +--- + +## Demo + +> 截图待补充。运行 `streamlit run app.py` 后即可看到实际界面。 + +| 界面 | 说明 | +|------|------| +| 主聊天 | 单人 AI 对话,支持 flash/pro 模型切换 | +| 微信群聊 | 四位角色群聊,自动生成开场/互动回复 | +| 联网搜索 | 多源新闻聚合(Google News + Bing + RSSHub)+ 正文提取 | +| 课后总结 | 学习完成后自动总结,确认后写入长期记忆 | + +--- + +## 使用流程 + +``` +启动 App → 选择学习模式 (氛围/专注度) + │ + ├── 单人对话 ──→ 提问/讨论 ──→ 课后总结 ──→ 记忆更新 + │ + └── 微信群聊 ──→ 生成开场 / 聊新闻 / 查资料 + │ + ┌────┴────┐ + │ │ + 联网搜索 角色互动讨论 + │ │ + 来源追溯写入 观点碰撞 + │ │ + └────┬────┘ + │ + 课后总结 → 确认 → 写入长期记忆 +``` + +--- + +## 核心功能 + +| 功能 | 说明 | +|------|------| +| **单人对话** | 与 AI 一对一讨论学习内容,支持 flash/pro 模型切换 | +| **角色群聊** | 四位角色(三月七、刻晴、纳西妲、流萤)群聊讨论,各有独立人设 | +| **联网搜索** | Google News + Bing News + RSSHub 多源聚合,页面正文三层提取 | +| **来源追溯** | 搜索结果写入群聊记录,可回溯依据 | +| **课后总结** | 学习完成后自动总结进展,用户确认后写入记忆 | +| **长期记忆** | 学习者画像、进度追踪、项目上下文、当前焦点,多级记忆档案 | +| **多 Provider** | 支持 OpenAI / DeepSeek / OpenRouter / SiliconFlow / 本地模型 | +| **氛围选择** | warm / close / standard 多种互动氛围切换 | + +--- + +## 架构 + +``` +streamlit run app.py + │ +┌──────┴──────┐ +│ app.py │ Streamlit 入口,路由到各 UI 面板 +└──────┬──────┘ + │ +┌──────┴──────────────────────────────────────────┐ +│ src/ui/ │ +│ ├── main_panel.py 主页 │ +│ ├── chat_panel.py 对话面板 │ +│ ├── wechat_panel.py 微信群面板 │ +│ ├── after_session_panel.py 课后总结面板 │ +│ └── sidebar.py 侧边栏 │ +└──────┬──────────────────────────────────────────┘ + │ +┌──────┴──────┬──────────────┬──────────────┬──────────────┐ +│ LLM Layer │ News Layer │ Memory │ WeChat │ +│ │ │ Layer │ Layer │ +│ llm_client │ news/ │ memory.py │ wechat_*.py │ +│ llm_router │ ├─rss_fetc │ memory_tools │ (format, │ +│ context_bui │ ├─article_e │ memory_writer│ state, │ +│ -ilder │ ├─link_reso │ │ generator, │ +│ │ ├─digest │ session_log │ prompt) │ +│ config.py │ └─article_f │ -ger │ │ +│ router.py │ etcher │ │ wechat_serv│ +│ │ │ │ -ice.py │ +└──────┬──────┴──────┬──────┴──────┬───────┴──────┬───────┘ + │ │ │ │ + .env.example chat/ memory/ roles/ + (5 providers) (群聊记录) (记忆文件) (角色人设) +``` + +--- ## 快速开始 ```bash -cd study-agent # 进入项目目录 +git clone study-agent +cd study-agent +cp .env.example .env +# 编辑 .env,填入 API Key + +# 稳定安装(推荐,锁定版本) pip install -r requirements.txt pip install -r requirements-dev.txt -cp .env.example .env + streamlit run app.py ``` 浏览器打开 `http://localhost:8501` -## 环境配置 - -编辑 `.env`(完整模板见 `.env.example`): +### 依赖管理 -### Provider 选择 +本项目使用 [pip-tools](https://github.com/jazzband/pip-tools) 管理依赖: -通过 `LLM_PROVIDER_PROFILE` 切换 LLM 提供商,支持 `openai` / `deepseek` / `openrouter` / `siliconflow` / `local`。每个 provider 读写自己的环境变量: +- [`requirements.in`](requirements.in) / [`requirements-dev.in`](requirements-dev.in) — **人类维护**,写范围版本 +- [`requirements.txt`](requirements.txt) / [`requirements-dev.txt`](requirements-dev.txt) — **自动生成**,写精确版本(lock 文件) -| Provider | API Key | Base URL | 默认 Base URL | -|---|---|---|---| -| `deepseek` | `DEEPSEEK_API_KEY` | `DEEPSEEK_BASE_URL` | `https://api.deepseek.com/v1` | -| `openrouter` | `OPENROUTER_API_KEY` | `OPENROUTER_BASE_URL` | `https://openrouter.ai/api/v1` | -| `siliconflow` | `SILICONFLOW_API_KEY` | `SILICONFLOW_BASE_URL` | `https://api.siliconflow.cn/v1` | -| `local` | `LOCAL_API_KEY` | `LOCAL_BASE_URL` | `http://127.0.0.1:8000/v1` | -| `openai` | `OPENAI_API_KEY` | `OPENAI_BASE_URL` | — | +修改依赖后重新生成 lock 文件: -每个 provider 的模型和连接参数: -- `{PROVIDER}_MODEL_FLASH_NAME` — flash 模型名 -- `{PROVIDER}_MODEL_PRO_NAME` — pro 模型名 -- `{PROVIDER}_DEFAULT_MODEL_PROFILE` — 默认模型档位(`flash`/`pro`) -- `{PROVIDER}_TIMEOUT_SECONDS` — 请求超时秒数 -- `{PROVIDER}_MAX_RETRIES` — 最大重试次数 - -### 全局默认值 - -未设置 provider 级参数时回退到以下变量: -- `MODEL_FLASH_NAME` / `MODEL_PRO_NAME` — 模型名 -- `DEFAULT_MODEL_PROFILE` — 默认档位(默认 `flash`) -- `LLM_TIMEOUT_SECONDS` — 全局超时(默认 `30`) -- `LLM_MAX_RETRIES` — 全局最大重试(默认 `2`) -- `LLM_MAX_TOKENS` — 全局最大 token 数 +```bash +pip install pip-tools +pip-compile requirements.in # 重新锁定主依赖 +pip-compile requirements-dev.in # 重新锁定开发依赖 +``` -### 任务级覆盖 +--- -内置任务(`llm_router` `after_session`)有硬编码默认值,可通过环境变量覆盖: +## 环境配置 -| 任务 | 默认 max_tokens | 默认 timeout | 默认 temperature | -|---|---|---|---| -| `llm_router` | 240 | 20s | 0.0 | -| `after_session` | 1200 | 45s | 0.3 | +通过 `LLM_PROVIDER_PROFILE` 切换 LLM 提供商(`openai` / `deepseek` / `openrouter` / `siliconflow` / `local`),每个 provider 读写独立的环境变量: -覆盖方式:`{TASK_KEY}_MAX_TOKENS` / `{TASK_KEY}_TIMEOUT_SECONDS` / `{TASK_KEY}_TEMPERATURE`(如 `AFTER_SESSION_MAX_TOKENS=1200`)。 +| Provider | 环境变量前缀 | 默认 Base URL | +|----------|-------------|---------------| +| `deepseek` | `DEEPSEEK_*` | `https://api.deepseek.com/v1` | +| `openrouter` | `OPENROUTER_*` | `https://openrouter.ai/api/v1` | +| `siliconflow` | `SILICONFLOW_*` | `https://api.siliconflow.cn/v1` | +| `local` | `LOCAL_*` | `http://127.0.0.1:8000/v1` | +| `openai` | `OPENAI_*` | — | -### 解析链 +参数优先级:代码显式参数 → 任务级环境变量 → 任务默认值 → 全局环境变量 → provider 级环境变量。完整配置见 [`.env.example`](.env.example) 和 [用户指南](USER_GUIDE.md)。 -每个参数按以下优先级解析: -1. 代码调用传入的显式参数 -2. 任务级环境变量(如 `AFTER_SESSION_MAX_TOKENS`) -3. 任务硬编码默认值(`_TASK_DEFAULTS`) -4. 全局环境变量(如 `LLM_MAX_TOKENS`) -5. provider 级环境变量(如 `DEEPSEEK_TIMEOUT_SECONDS`) +--- ## 项目结构 ``` ├── app.py # Streamlit 入口 -├── requirements.txt # 运行依赖 -├── requirements-dev.txt # 开发/测试依赖 -├── .env.example # 环境变量模板 -├── USER_GUIDE.md # 用户指南 -│ ├── src/ │ ├── llm_client.py # LLM 调用(chat / stream) │ ├── llm_router.py # 模型路由分发 │ ├── context_builder.py # 上下文构建 -│ ├── mode_manager.py # 模式管理(性能/互动氛围) +│ ├── mode_manager.py # 模式管理(版本/性能/氛围) │ ├── role_manager.py # 角色加载与管理 -│ ├── session_logger.py # 会话日志 -│ ├── session_store.py # 会话持久化 -│ ├── safe_writer.py # 安全文件写入 -│ ├── backup_manager.py # 备份管理 -│ ├── wechat.py # 兼容门面(legacy re-exports) -│ ├── wechat_format.py # 群聊文本格式化工具 -│ ├── wechat_state.py # 群聊 I/O、状态管理、生命周期 -│ ├── wechat_generator.py # LLM 生成逻辑 -│ ├── wechat_prompt.py # 系统/互动 prompt 加载 -│ ├── wechat_memory.py # 微信群记忆提取 +│ ├── performance_budget.py # 性能预算(max_tokens 分级) │ ├── memory.py # 记忆系统 │ ├── memory_tools.py # 记忆工具 │ ├── memory_writer.py # 记忆写入 -│ ├── model_stats.py # 模型用量统计 -│ ├── perf.py # 性能日志 -│ ├── router.py # 路由配置 -│ ├── config.py # 全局配置 -│ ├── constants.py # 公共常量 -│ ├── text_utils.py # 文本工具 -│ ├── log_utils.py # 日志工具 -│ ├── health_check.py # 健康检查 -│ ├── export_tools.py # 导出工具 -│ ├── update_validator.py # 更新校验 +│ ├── wechat_format.py # 群聊文本格式化 +│ ├── wechat_state.py # 群聊 I/O、状态管理 +│ ├── wechat_generator.py # LLM 生成逻辑 +│ ├── wechat_prompt.py # Prompt 模板加载 +│ ├── wechat_memory.py # 群聊记忆提取 │ ├── after_session.py # 课后总结 -│ ├── news/ -│ │ ├── article_extractor.py # 文章正文提取(三层回退) -│ │ ├── article_fetcher.py # 文章抓取(DNS/IP 安全校验 + SSRF 防御) -│ │ ├── link_resolver.py # Google News 跳转链接解析 -│ │ ├── rss_fetcher.py # 多源 RSS 聚合与去重 -│ │ └── digest.py # 新闻摘要生成与来源块 -│ └── ui/ -│ ├── main_panel.py # 主页 -│ ├── chat_panel.py # 对话面板 -│ ├── sidebar.py # 侧边栏 -│ ├── wechat_panel.py # 微信群面板 -│ ├── wechat_bubble.py # 微信气泡渲染 -│ ├── after_session_panel.py # 课后总结面板 -│ ├── status_bar.py # 状态栏 -│ ├── session_state.py # 会话状态 -│ ├── theme.py # 主题 -│ └── avatar.py # 头像 -│ -├── tests/ -│ ├── test_wechat.py # 微信/新闻搜索测试 -│ ├── test_wechat_article_extract.py # 文章提取测试 -│ ├── test_wechat_service.py # 服务层测试 -│ ├── test_architecture_flows.py # 架构流测试 -│ ├── test_wechat_decoupling.py # 模块解耦 guard -│ ├── test_after_session.py # 课后总结测试 -│ ├── test_v03_accept.py # 验收测试 -│ └── test_packaging_guards.py # 打包校验测试 -│ -├── chat/ # 群聊记录 -│ ├── wechat_group.md # 当前群聊 -│ ├── wechat_state.md # 群聊状态 -│ ├── wechat_unread.md # 未读消息 -│ └── archive/ # 历史群聊归档 -│ -├── memory/ # AI 记忆系统 -│ ├── learner_profile.md # 学习者画像 -│ ├── project_context.md # 项目上下文 -│ ├── progress.md # 学习进度 -│ ├── summary.md # 学习摘要 -│ ├── current_focus.md # 当前焦点 -│ ├── task_board.md # 任务看板 -│ ├── internal_state.md # 内部状态(版本号等) -│ ├── system_detail.md # 系统详情 -│ ├── agent.md # Agent 配置 -│ └── pending_updates/ # 待确认更新 -│ -├── roles/ # 角色人设 -│ ├── march7.md # 三月七 -│ ├── keqing.md # 刻晴 -│ ├── nahida.md # 纳西妲 -│ ├── firefly.md # 流萤 -│ └── references/ # 角色背景资料 -│ -├── templates/ # Prompt 模板 -│ ├── wechat_update.md # 群聊生成 -│ ├── wechat_interactive_reply.md # 互动回复 -│ ├── wechat_memory_extract.md # 记忆提取 -│ └── routing_rules.md # 路由规则 -│ -├── config/ -│ └── routing_rules.yaml # 路由规则配置 -│ -├── tools/ -│ ├── package_project.ps1 # 打包脚本 -│ └── package_project_helper.py -│ -├── assets/ # 静态资源(头像/背景/图标) -│ -├── logs/ # 运行日志 -│ -└── backups/ # 备份文件 +│ ├── session_logger.py # 会话日志 +│ ├── config.py # 全局配置 +│ ├── router.py # 路由配置 +│ ├── news/ # 新闻聚合链路 +│ └── ui/ # Streamlit UI 组件 +├── tests/ # 140 个测试 +├── docs/ # 设计文档 +│ └── STATE_MODEL.md # 状态模型 +├── chat/ # 群聊记录 +├── memory/ # AI 长期记忆 +├── roles/ # 角色人设 +├── templates/ # Prompt 模板 +├── config/ # YAML 配置 +├── requirements.in # 依赖声明(范围版本) +└── assets/ # 视觉资源 +``` + +--- + +## 测试 + +```bash +pytest tests/ -v # 140 tests +pytest tests/ --cov=src # 覆盖率 +ruff check src/ tests/ # linting ``` +CI 通过 GitHub Actions 运行(每次 push 触发),集成 `detect-secrets` 扫描。 + +--- + ## 版本历史 -- **v0.1 ~ v0.6.3** — 早期探索阶段。界面仅纯文本、按钮触发全页刷新、侧边栏与主区域不同步、无视觉资源、自动路由缺失、群聊格式简陋。问题过多,未发布 Release。 -- **v0.6.4** — 首次可用的正式包:Catppuccin 暗色主题、紫蓝渐变、微信气泡 UI、路由系统、会话隔离、safe_writer。 -- **v0.6.5** — 开场氛围选择、角色归一化兜底、侧栏与 memory 对齐、打包脚本拆分。 -- **v0.6.6** — 未知发言人渲染修复、并发写入安全、打包测试对齐。 -- **v0.6.7** — 刷新逻辑拆分、notice queue、健康检查只读化、打包排除增强、lru_cache 缓存。 -- **v0.6.8** — .tmp 排除、文件锁重试 finally 清理、LLM client 自动重建、wechat_state 批量写入、新闻 10 分钟缓存。 -- **v0.6.9** — 自由文本联网搜索、进度指示、新闻缓存按 query 隔离、记忆写入加固。 -- **v0.7.0** — 多源 RSS 聚合、正文三层提取、来源块写入群聊、摘要边界约束、URL 安全检查。 -- **v0.7.1** — 90 天搜索窗口、来源块压缩、摘要覆盖率提示、prompt 乱码修复、覆盖率统计对齐。 -- **v0.7.2** — 代码质量全面收口:修复 4 个 Bug、性能优化(缓存/YAML/diff)、架构改善(常量去重/模块拆分)、Streamlit fragment 反模式修复、关键路径错误处理增强。 -- **v0.7.3** — 服务层拆分与工程化收口:Wechat news round 下沉到 `src/wechat_service.py`、session flush 批量提交、GitHub Actions CI、架构级测试、LLM client 参数扩展、YAML runtime state 真源化。详见 `changelog/README_v0_7_3.md`。 -- **v0.7.4** — 工程体验收口:自动化版本 bump 工具、LLM 配置文档化(`.env.example` 5 个 provider)、NewsRoundResult 结果对象化(覆盖率/警告/耗时)、UI 来源可信度展示。详见 `changelog/README_v0_7_4.md`。 -- **v0.7.5** — 文档同步收口 + 代码清理:版本信息同步 10 个文件、死代码清理(chat_stream / 重复函数)、YAML 同步 mtime canary 优化、重复逻辑合并。详见 `changelog/README_v0_7_5.md`。 -- **v0.7.6** — 工程安全与新闻链路收口。详见 `changelog/README_v0_7_6.md`。 -- **v0.7.7** — 模块拆分与服务层解耦: - - **新闻链路拆分**:`wechat.py`(~550 行)拆分为 4 个专注模块 + 1 个兼容门面 - - `wechat_format.py` — 纯文本 / 格式化工具 - - `wechat_state.py` — 文件 I/O、群聊状态、生命周期管理 - - `wechat_generator.py` — LLM 生成逻辑(群聊讨论、开场、互动回复) - - `wechat_prompt.py` — 系统 / 互动 prompt 加载(自包含,无外部依赖) - - `wechat.py` — 纯兼容门面,仅 re-export,零运行时逻辑 - - **服务层拆分**:`wechat_service.py` 改为直连子模块,绕开门面 - - **UI 逐阶段新闻流**:搜索 → 正文读取 → 生成摘要 → 群聊讨论,四步按钮推进 - - **自适应 max_articles**:fast→2、standard→4、deep→6 - - **安全加固**: - - 自定义 `_SafeHTTPRedirectHandler` 逐跳 SSRF 校验 + 最多 3 次重定向 - - 包扫描增加 GitHub PAT、OpenRouter、通用 key/token 模式 - - detect-secrets 加入 CI 流程 - - **内存保护**:Session logger 50 条自动 flush、2 小时老化警告 - - **防回归 guard**:`test_wechat_decoupling.py` 4 个测试确保模块边界不退化 - - **112 tests,Ruff clean** - -完整 Release 及下载见 [Releases](https://github.com/2002yy/-study-agent/releases)。 +### v0.8.0 — 文档同步 + UI 中文标签 + 工程收口 + +文档版本同步(5 份文档统一升级);UI 中文标签(模型/性能/状态栏全中文);合并性能预算系统、依赖锁定、状态模型文档化、CI 门禁升级、入口页新闻流程修复。**140 tests,Ruff clean**。 + +### v0.7.8 — 性能预算 + 状态模型 + 工程收口 + +### v0.7.7 — 模块拆分与服务层解耦 + +新闻链路拆分为 4 个专注模块 + 兼容门面;服务层直连子模块;UI 逐阶段新闻流;SSRF 安全加固;Session logger 自动 flush 保护。**112 tests,Ruff clean**。 + +### v0.7.6 — 工程安全与新闻链路收口 + +完整历史见 [CHANGELOG.md](CHANGELOG.md)。 + +--- + +## Roadmap + +| 版本 | 方向 | +|------|------| +| v0.8.1 | 稳定性和 UI 打磨 | +| v0.9 | 知识库 / RAG 能力 | +| v0.10 | 多语言支持、导出增强 | +| v1.0 | 插件化架构 + 自定义角色 | + +--- ## 许可 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index a02866e..4128ff0 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -2,15 +2,16 @@ ## 1. 当前阶段 -当前项目处于 `v0.7.5` 阶段,核心能力包括: +当前项目处于 `v0.8.0` 阶段,核心能力包括: 1. 单人学习对话 2. 课后更新预览与确认写入 -3. 微信群互动 +3. 微信群互动(四位角色群聊) 4. 联网搜索与页面文本增强摘要 5. 多源新闻聚合与来源追溯 +6. 性能预算系统(fast/standard/deep 三级 max_tokens) -历史版本 `README_v0_x.md` 主要用于留档;日常使用请优先参考本文件和 [changelog/README_v0_7_1.md](C:/Users/96967/Desktop/study%20agent/changelog/README_v0_7_1.md)。 +日常使用请优先参考本文件和 [v0.8.0 发布说明](changelog/README_v0_8_0.md)。 ## 2. 启动 @@ -28,14 +29,14 @@ streamlit run app.py 复制 `.env.example` 为 `.env`,按文件内注释填写。`.env.example` 是唯一准配置,本指南不重复列另一套。 -当前 `.env.example` 默认项示意: +配置以 `.env.example` 为准。当前推荐使用 Provider Profile 方式: ```text -OPENAI_API_KEY=your_api_key_here -OPENAI_BASE_URL=https://api.deepseek.com/v1 -MODEL_FLASH_NAME=deepseek-v4-flash -MODEL_PRO_NAME=deepseek-v4-pro -DEFAULT_MODEL_PROFILE=pro +LLM_PROVIDER_PROFILE=deepseek +DEEPSEEK_API_KEY=your_api_key_here +DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 +DEEPSEEK_MODEL_FLASH_NAME=deepseek-chat +DEEPSEEK_MODEL_PRO_NAME=deepseek-reasoner ``` 常用建议: diff --git a/changelog/README_v0_7_7.md b/changelog/README_v0_7_7.md index ea8dafb..4582e9f 100644 --- a/changelog/README_v0_7_7.md +++ b/changelog/README_v0_7_7.md @@ -1,4 +1,4 @@ -# Study Agent v0.7.7 release notes +# Study Agent v0.7.7 发布说明 / release notes > 模块拆分收口版。wechat.py 从 ~1243 行拆为 6 个独立模块,保留兼容层,无功能变更。 @@ -10,7 +10,7 @@ | 模块 | 职责 | 行数 | |---|---|---| -| `src/wechat.py` | 兼容层 + 群聊生命周期、互动回复、开场生成、搜索摘要 | ~540 | +| `src/wechat.py` | 纯兼容门面,零运行时逻辑,所有接口 re-export | ~100 | | `src/wechat_format.py` | 纯文本/格式化工具(角色块解析、兜底文案) | ~95 | | `src/news/link_resolver.py` | Google News 跳转链接解析 | ~90 | | `src/news/article_fetcher.py` | 正文抓取 + DNS/IP 安全校验 | ~180 | diff --git a/changelog/README_v0_8_0.md b/changelog/README_v0_8_0.md new file mode 100644 index 0000000..873d091 --- /dev/null +++ b/changelog/README_v0_8_0.md @@ -0,0 +1,90 @@ +# Study Agent v0.8.0 发布说明 / Release Notes + +> 文档同步 + UI 中文标签 + 工程收口。合并 v0.7.8 全量变更与本轮文档/UI 统一。 + +--- + +## v0.8.0 新增 + +### 1. 文档版本同步 + +5 份文档统一升至 v0.8.0: + +- `USER_GUIDE.md`:版本号、配置示例(Provider Profile)、能力清单更新 +- `PROJECT_PLAN.md` / `FUTURE.md` / `COMPREHENSIVE_PROJECT.md`:版本升级、模块描述与实际代码对齐(wechat.py 拆分为 5 个子模块)、文档引用路径修正 +- `changelog/README_v0_7_7.md`:标题中英双语、wechat.py 行数/职责修正 + +### 2. UI 中文标签 + +用户可见文案统一为中文主导: + +- 模型标签:`自动` / `Flash(快速)` / `Pro(高质量)` +- 性能模式:`快速` / `标准` / `深度` +- 状态栏统计:`Flash 调用` / `路由调用` / `上次延迟` / `估算成本` +- Debug 性能行:`性能: 路由 0.xxx | 记忆 0.xxx | 上下文 0.xxx | ...` + +### 3. 语言规范确立 + +仓库采用中文主导 + 英文技术名保留的策略: + +| 类型 | 语言 | +|------|------| +| README / CHANGELOG | 中文主导,标题中英双语 | +| 用户文档 | 全中文 | +| UI 文案 | 中文 | +| 代码/配置变量/函数名 | 保留英文 | + +--- + +## 合并 v0.7.8 变更 + +以下变更在开发分支上累积,与 v0.8.0 一并发布: + +### 性能预算系统 + +新模块 `src/performance_budget.py`,按 fast/standard/deep 三档分配 max_tokens: + +- 主聊天:700 / 1100 / 1600 +- 群聊回复:520 / 760 / 1050,历史行数自适应(16 / 28 / 40) +- 开场生成:420 / 620 / 850 +- 新闻摘要:650 / 950 / 1300 +- 新闻讨论:520 / 760 / 1000 + +### 依赖锁定 + +迁移至 pip-tools 工作流:`requirements.in` → `requirements.txt`(锁定精确版本)。 + +### 状态模型文档化 + +`docs/STATE_MODEL.md`:真源层级、迁移指南、清理清单。YAML 为真源,MD 文件为视图。 + +### CI 门禁升级 + +`detect-secrets` 改为硬门禁(移除 `continue-on-error`)。 + +### 入口页新闻流程修复 + +- 群聊未开始时也能显示新闻阶段面板 +- `_clear_wechat_news_state` 统一清理 +- `run_discussion_stage` 正确调用 `update_wechat_join_state` + +### README 重构 + +展示型结构:产品定位 / Demo / 使用流程 / 架构图 / Roadmap。 + +### `.gitignore` 收口 + +排除 `config/runtime_state.yaml`、`memory/`。 + +### 测试 + +新增 28 个测试,总测试数 140,Ruff clean。 + +--- + +## 兼容性 + +- 无新增依赖 +- 140 测试通过 +- Ruff clean +- 所有对外接口保持向后兼容 diff --git a/config/runtime_state.yaml b/config/runtime_state.yaml index f657bef..b85037c 100644 --- a/config/runtime_state.yaml +++ b/config/runtime_state.yaml @@ -1,7 +1,7 @@ version: - current: v0.7.7 - next: v0.7.8 - active_task: post-v0.7.7 stabilization and UI polish + current: v0.8.0 + next: v0.8.1 + active_task: 文档同步 + UI 中文标签 + 性能预算 + 状态模型 + 工程收口 runtime: entry_mode: wechat performance_mode: standard @@ -12,7 +12,7 @@ runtime: interaction: relationship_mode: close wechat: - mode: unread_feedback + mode: interactive_group user_has_joined_group: false first_join_reaction_done: false memory_capture_enabled: true diff --git a/docs/STATE_MODEL.md b/docs/STATE_MODEL.md new file mode 100644 index 0000000..f28e04a --- /dev/null +++ b/docs/STATE_MODEL.md @@ -0,0 +1,321 @@ +# 状态模型 + +> 本文档定义 Study Agent 中**每个文件**的角色归属:哪些是源码、哪些是用户数据、哪些是缓存、哪些可以删除、哪些换电脑要迁移。 + +--- + +## 1. 总览 + +``` +项目根目录 +├── 源码(进 Git,只读不改) +│ ├── app.py, src/*.py, tests/*.py +│ ├── config/routing_rules.yaml +│ ├── .env.example, requirements*.txt +│ ├── README.md, CHANGELOG.md, USER_GUIDE.md +│ ├── roles/*.md, templates/*.md +│ └── tools/*.ps1, tools/*.py +│ +├── 配置模板(进 Git,运行后由用户派生) +│ └── .env.example → 用户复制为 .env +│ +├── 真源(运行生成,不进 Git/包) +│ ├── config/runtime_state.yaml +│ └── memory/pending_updates/*.json +│ +├── 视图(由真源同步,可改但会被覆盖) +│ ├── memory/internal_state.md +│ ├── memory/interaction_settings.md +│ └── chat/wechat_state.md +│ +├── 用户数据(不进 Git,默认不进包) +│ ├── .env +│ ├── config/runtime_state.yaml +│ ├── memory/*.md(不含 pending_updates/) +│ ├── chat/*.md(不含 archive/) +│ └── assets/*(头像/背景/横幅) +│ +├── 缓存/临时(可安全删除) +│ ├── logs/ +│ ├── backups/ +│ ├── memory/pending_updates/ +│ └── *.bak, *.tmp +│ +└── 打包产物(进 .gitignore) + └── release/ +``` + +--- + +## 2. 逐文件分类 + +### 2.1 源码(Source Code) + +| 文件 | 说明 | 进 Git | 进包 | +|------|------|--------|------| +| `app.py` | Streamlit 入口 | 是 | 是 | +| `src/*.py` | 全部 Python 模块 | 是 | 是 | +| `tests/*.py` | 测试套件 | 是 | 可选 | +| `config/routing_rules.yaml` | 路由规则配置 | 是 | 是 | +| `.env.example` | 环境变量模板 | 是 | 是 | +| `requirements*.txt` | 依赖声明 | 是 | 是 | +| `roles/*.md`, `templates/*.md` | 角色人设和 Prompt 模板 | 是 | 是 | +| `tools/*.ps1`, `tools/*.py` | 打包/辅助工具 | 是 | 是 | +| `docs/*.md` | 设计文档 | 是 | 是 | +| `README.md`, `CHANGELOG.md`, `USER_GUIDE.md` | 项目文档 | 是 | 是 | + +源码的特征:**提交后不改,不依赖运行时状态**。 + +### 2.2 配置模板 vs 用户配置 + +| 文件 | 真源 | 进 Git | 进包 | 可删除 | +|------|------|--------|------|--------| +| `.env.example` | 手动维护 | **是** | 是 | 否 | +| `.env` | 用户复制自 `.env.example` | **否** (.gitignore) | 否 (显式排除) | 是(需重新配) | + +`.env` 是唯一承载 API Key、Base URL 等敏感信息的文件。 +换电脑迁移时必须复制。 + +### 2.3 运行时状态(真源) + +| 文件 | 真源角色 | 写入方 | 进 Git | 进包 | +|------|---------|--------|--------|------| +| `config/runtime_state.yaml` | **维一的运行时真源** | `mode_manager._write_runtime_state()` | **不应进** | 不推荐 | +| `memory/pending_updates/*.json` | 群聊记忆候选真源 | `wechat_memory.save_candidates()` | **否** (.gitignore) | 否 | + +#### 真源层级说明 + +`config/runtime_state.yaml` 包含版本、运行模式、交互设置、微信群状态。 +它的写入路径是: + +``` +mode_manager.update_*() + → _apply_state_updates() + → _write_runtime_state() # 先写 YAML(真源) + → _sync_runtime_state_markdown_views() # 再同步到以下视图 +``` + +**为什么 YAML 是真源?** 因为 YAML 结构化、可程序化读写;MD 视图仅供人工阅读和 Streamlit 展示。 +**为什么不进 Git?** 因为 performance_mode、memory_mode、wechat_mode 等字段随每次使用变化,提交会产生噪音。 + +> **当前问题**:`.gitignore` 未排除 `config/runtime_state.yaml`,有意外提交风险。建议添加。 + +### 2.4 视图(View Only,由真源同步) + +| 文件 | 真源来源 | 被谁读取 | 可手动编辑 | +|------|---------|---------|-----------| +| `memory/internal_state.md` | `config/runtime_state.yaml` → synced | `mode_manager._read_md_state_migration()` | 可改,但会被 YAML 覆盖 | +| `memory/interaction_settings.md` | 同上 | `mode_manager._read_md_state_migration()` | 同上 | +| `chat/wechat_state.md` | 同上 | `wechat_state.py` | 同上 | +| `memory/pending_updates/wechat_memory_candidates.md` | `.../*.json` | 人工预览 | 改也没用,JSON 是真源 | + +这些视图的存在意义:**让人眼可读、让 Streamlit cache 可缓存**。修改视图不会影响真源,下次同步会覆盖。 + +### 2.5 用户数据(User Data) + +| 文件 | 内容 | 进 Git | 进包 | 可恢复 | 迁移要求 | +|------|------|--------|------|--------|---------| +| `memory/index.md` | 记忆索引 | 示例可进 | 默认排除 | 有备份 | 需迁移 | +| `memory/current_focus.md` | 当前学习焦点 | 示例可进 | 默认排除 | 有备份 | 需迁移 | +| `memory/summary.md` | 学习摘要 | 示例可进 | 默认排除 | 有备份 | 需迁移 | +| `memory/learner_profile.md` | 学习者画像 | 示例可进 | 默认排除 | 有备份 | 需迁移 | +| `memory/progress.md` | 学习进度 | 示例可进 | 默认排除 | 有备份 | 需迁移 | +| `memory/project_context.md` | 项目上下文 | 示例可进 | 默认排除 | 有备份 | 需迁移 | +| `memory/task_board.md` | 任务看板 | 示例可进 | 默认排除 | 有备份 | 需迁移 | +| `memory/agent.md` | Agent 配置 | 示例可进 | 默认排除 | 有备份 | 需迁移 | +| `memory/system_detail.md` | 系统详情 | 示例可进 | 默认排除 | 有备份 | 需迁移 | +| `memory/archive_summary.md` | 归档摘要 | 示例可进 | 默认排除 | 有备份 | 需迁移 | +| `chat/wechat_group.md` | 群聊记录 | **否** (.gitignore) | 否 | 可恢复(有 archive) | 可选 | +| `chat/wechat_unread.md` | 未读消息 | **否** (.gitignore) | 否 | 丢 | 不迁 | +| `chat/archive/*.md` | 历史群聊归档 | **否** (.gitignore) | 否 (显式排除) | 是 | 可选 | +| `assets/*` | 头像/背景/横幅 | 建议不进(版权) | 否 (无扩展名排除) | 可重下 | 可选 | + +**关于 memory 进 Git 的约定**:`memory/*.md` 未被 `.gitignore` 排除。项目的预期行为是: +- 示例/初始内容可提交一次进 Git +- 真实学习数据**不进 Git**(用户自行维护 `.gitignore` 或在 `git add` 时留意) +- 当前 `.gitignore` 只排除了 `memory/pending_updates/` + +#### 写入链路 + +``` +app.py (用户操作) + → after_session / wechat_memory + → memory_writer.append_memory() / .write_current_focus() + → is_memory_write_allowed() 权限检查 + → safe_writer.safe_write_text() # 先备份旧文件到 backups/,再原子写入 +``` + +#### 恢复方式 + +1. **从 backups 恢复** — 每次 `safe_write_text` 写入前自动备份旧文件到 `backups/memory_backups/` + ```bash + python -m src.backup_manager # 查看备份列表 + python -m src.backup_manager restore # 恢复 + ``` +2. **从 chat/archive 重建** — 群聊历史在 `chat/archive/*.md`,可通过 LLM 重新提取记忆 + +### 2.6 会话/日志(可丢) + +| 文件 | 内容 | 进 Git | 进包 | 可删除 | +|------|------|--------|------|--------| +| `logs/current/*.md` | 当前会话日志(实时写入) | **否** (.gitignore) | 否 | 安全删除 | +| `logs/sessions/*.md` | 已归档会话日志 | **否** (.gitignore) | 否 | 安全删除 | +| `logs/revision_notes.md` | 修订笔记 | **否** (.gitignore) | 否 | 安全删除 | +| `logs/session_archive.md` | 会话归档 | **否** (.gitignore) | 否 | 安全删除 | +| `st.session_state` (内存) | Streamlit 会话状态 | — | — | 页面刷新即丢 | + +会话日志的写入链路: + +``` +app.py (对话) + → session_logger.log() # 追加到内存 _state + → session_logger.flush_current_session() # 按间隔刷到 logs/current/ + → session_logger.save() # 归档到 logs/sessions/ + 清理 current/ +``` + +**换电脑可以不迁 logs**。它们不是系统运行的必要状态。 + +### 2.7 备份(Cache) + +| 文件 | 内容 | 进 Git | 进包 | 可删除 | +|------|------|--------|------|--------| +| `backups/memory_backups/*.bak` | 写入前自动备份 | **否** (.gitignore) | 否 | 安全删除 | +| `*.bak`, `*.tmp` | 残留临时文件 | **否** (.gitignore) | 否 | 安全删除 | +| `__pycache__/`, `.ruff_cache/` | Python/pyc 缓存 | **否** | 否 | 安全删除 | + +备份是安全网,不是真源。删除不会丢数据,但会失去最近一次写入前的回滚点。 + +### 2.8 打包产物 + +| 路径 | 进 Git | 进包 | +|------|--------|------| +| `release/` | **否** (.gitignore) | — | + +--- + +## 3. 真源层级汇总 + +``` + 用户操作 + │ + ▼ + st.session_state + (内存,页面刷新即丢) + │ + ┌────────────┴────────────┐ + │ │ + config/ memory/ + runtime_state.yaml pending_updates/*.json + ┌────┼────┐ │ + │ │ │ ▼ + ▼ ▼ ▼ pending_updates/*.md + memory/ memory/ chat/ (视图) + internal interaction wechat_ + _state.md _settings.md state.md + (视图) (视图) (视图) + + memory/*.md ←─── after_session.py / memory_writer ←─── 权限检查 + chat/*.md ←─── wechat_state.py / wechat_generator.py + logs/* ←─── session_logger.py + + backups/* ←─── safe_writer.py(自动备份旧文件) +``` + +### 读取优先级 + +各模块读取状态的顺序(以 `mode_manager` 为例): + +1. `st.cache_data(ttl=30)` → `load_runtime_modes()` 缓存结果 +2. `_runtime_state_from_yaml()` → 读 `config/runtime_state.yaml`(真源) +3. 如果 YAML 不存在 → 回退 `_read_md_state_migration()` 从 `memory/internal_state.md` + `memory/interaction_settings.md` + `chat/wechat_state.md` 读取(迁移兼容) +4. 取默认值 `RuntimeModes()` 兜底 + +--- + +## 4. 运维操作指南 + +### 4.1 换电脑迁移 + +必须迁移的状态(丢则系统不完整): + +| 必须迁移 | 路径 | 原因 | +|---------|------|------| +| API 密钥 | `.env` | 无法连接 LLM | +| 长期记忆 | `memory/*.md`(不含 `pending_updates/`) | 学习历史丢失 | +| 运行时状态 | `config/runtime_state.yaml` | 可选,丢了可从默认重建 | + +建议迁移: + +| 建议迁移 | 路径 | 原因 | +|---------|------|------| +| 群聊历史 | `chat/`(含 `archive/`) | 学习讨论记录 | +| 视觉资源 | `assets/` | 头像/背景 | +| 角色印象 | `roles/*.md`(`### 对用户当前印象` 段) | 角色观察记录 | + +不需要迁移: + +| 不迁 | 原因 | +|------|------| +| `logs/` | 运行日志,对系统运行无用 | +| `backups/` | 安全网,到新机器重新生成 | +| `__pycache__/` | Python 会重建 | +| `release/` | 打包产物 | + +### 4.2 安全删除(空间清理) + +```bash +# 清空会话日志(最占空间) +rm -rf logs/current/*.md logs/sessions/*.md + +# 清空备份 +rm -rf backups/memory_backups/*.bak + +# 清空临时文件 +find . -name "*.bak" -delete +find . -name "*.tmp" -delete + +# 清空未读消息(重启后重建) +echo "" > chat/wechat_unread.md +``` + +### 4.3 打包排除清单 + +`tools/package_project_helper.py` 的 `EXCLUDE_DIRS`: + +``` +__pycache__, .pytest_cache, .ruff_cache, .git, .github, +.vscode, .idea, venv, .venv, env, node_modules, +logs, backups, exports, release, dist, build, 图片资料 +``` + +额外排除规则: +- `chat/archive/` — 历史群聊归档 +- `*.pyc, *.pyo, *.bak, *.tmp` — 临时文件 +- `.env, .env.*`(除非 `.env.example`)— 用户密钥 +- `tools/package_project_v*.ps1` — 旧版打包脚本 +- 含 `visual_assets_pack` 的路径 + +打包前还会扫描密钥模式(sk-*, ghp_*, sk-or-v1-*, api_key/token/secret 赋值)。 + +--- + +## 5. 当前已知问题 + +1. **`config/runtime_state.yaml` 未进 `.gitignore`** — 虽无敏感信息,但频繁变动不应提交。 +2. **memory/*.md 示例/真实数据混用** — `.gitignore` 应增加 `memory/` 排除,或只留示例文件。 +3. **chat/ 被 .gitignore 排除但 runtime_state.yaml 未排除** — 不一致。 +4. **`memory/pending_updates/` 缓存的 JSON 与 MD 之间没有版本对应** — JSON 是真源,MD 是视图,但二者无显式关联约束。 +5. **`memory/pending_updates/` 不自动清理** — 确认写入后,候选文件应删除或标记已处理,当前无此逻辑。 + +--- + +## 6. 推荐改进 + +```gitignore +# 在 .gitignore 中补充: +config/runtime_state.yaml +memory/ +!memory/.gitkeep +``` + +同时为 `memory/` 添加一个 `.gitkeep` 保持目录结构,初始 memory 文件可作为示例通过 `git add --force` 选择性提交。 diff --git a/memory/internal_state.md b/memory/internal_state.md index 987d7e5..4c8b4fc 100644 --- a/memory/internal_state.md +++ b/memory/internal_state.md @@ -9,6 +9,6 @@ - entry_mode: wechat ## Version -- current_version: v0.7.7 -- active_task: post-v0.7.7 stabilization and UI polish -- next_version: v0.7.8 +- current_version: v0.8.0 +- active_task: 文档同步 + UI 中文标签 + 性能预算 + 状态模型 + 工程收口 +- next_version: v0.8.1 diff --git a/requirements-dev.in b/requirements-dev.in new file mode 100644 index 0000000..bedbee9 --- /dev/null +++ b/requirements-dev.in @@ -0,0 +1,6 @@ +-r requirements.in + +pytest>=8.0,<9 +ruff>=0.6,<1 +mypy>=1.0,<2 +detect-secrets>=1.5,<2 diff --git a/requirements-dev.txt b/requirements-dev.txt index c74acab..acfc17f 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,83 @@ -pytest>=8.0,<9 -ruff>=0.6,<1 -mypy>=1.0,<2 -detect-secrets>=1.5,<2 +altair==6.1.0 +annotated-types==0.7.0 +anyio==4.13.0 +attrs==26.1.0 +babel==2.18.0 +blinker==1.9.0 +cachetools==7.1.2 +certifi==2026.4.22 +chardet==7.4.3 +charset-normalizer==3.4.7 +click==8.4.0 +colorama==0.4.6 +courlan==1.3.2 +cssselect==1.4.0 +dateparser==1.4.0 +detect-secrets==1.5.0 +distro==1.9.0 +gitdb==4.0.12 +gitpython==3.1.50 +h11==0.16.0 +htmldate==1.9.4 +httpcore==1.0.9 +httptools==0.7.1 +httpx==0.28.1 +idna==3.15 +iniconfig==2.3.0 +itsdangerous==2.2.0 +jinja2==3.1.6 +jiter==0.14.0 +jsonschema==4.26.0 +jsonschema-specifications==2025.9.1 +justext==3.0.2 +librt==0.11.0 +lxml[html-clean]==6.1.0 +lxml-html-clean==0.4.4 +markupsafe==3.0.3 +mypy==1.20.2 +mypy-extensions==1.1.0 +narwhals==2.21.2 +numpy==2.4.5 +openai==1.109.1 +packaging==26.2 +pandas==3.0.3 +pathspec==1.1.1 +pillow==12.2.0 +pluggy==1.6.0 +protobuf==7.34.1 +pyarrow==24.0.0 +pydantic==2.13.4 +pydantic-core==2.46.4 +pydeck==0.9.2 +pygments==2.20.0 +pytest==8.4.2 +python-dateutil==2.9.0.post0 +python-docx==1.2.0 +python-dotenv==1.2.2 +python-multipart==0.0.28 +pytz==2026.2 +pyyaml==6.0.3 +readability-lxml==0.8.4.1 +referencing==0.37.0 +regex==2026.5.9 +requests==2.34.2 +rpds-py==0.30.0 +ruff==0.15.13 +six==1.17.0 +smmap==5.0.3 +sniffio==1.3.1 +starlette==1.0.0 +streamlit==1.57.0 +tenacity==9.1.4 +tld==0.13.2 +toml==0.10.2 +tqdm==4.67.3 +trafilatura==2.0.0 +typing-extensions==4.15.0 +typing-inspection==0.4.2 +tzdata==2026.2 +tzlocal==5.3.1 +urllib3==2.7.0 +uvicorn==0.47.0 +watchdog==6.0.0 +websockets==16.0 diff --git a/requirements.in b/requirements.in new file mode 100644 index 0000000..3c098d9 --- /dev/null +++ b/requirements.in @@ -0,0 +1,8 @@ +streamlit>=1.35,<2 +openai>=1.0,<2 +python-dotenv>=1.0,<2 +pyyaml>=6.0,<7 +python-docx>=1.1,<2 +trafilatura>=1.12,<3 +readability-lxml>=0.8,<1 +lxml>=5.0,<7 diff --git a/requirements.txt b/requirements.txt index 3c098d9..91378ab 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,73 @@ -streamlit>=1.35,<2 -openai>=1.0,<2 -python-dotenv>=1.0,<2 -pyyaml>=6.0,<7 -python-docx>=1.1,<2 -trafilatura>=1.12,<3 -readability-lxml>=0.8,<1 -lxml>=5.0,<7 +altair==6.1.0 +annotated-types==0.7.0 +anyio==4.13.0 +attrs==26.1.0 +babel==2.18.0 +blinker==1.9.0 +cachetools==7.1.2 +certifi==2026.4.22 +chardet==7.4.3 +charset-normalizer==3.4.7 +click==8.4.0 +colorama==0.4.6 +courlan==1.3.2 +cssselect==1.4.0 +dateparser==1.4.0 +distro==1.9.0 +gitdb==4.0.12 +gitpython==3.1.50 +h11==0.16.0 +htmldate==1.9.4 +httpcore==1.0.9 +httptools==0.7.1 +httpx==0.28.1 +idna==3.15 +itsdangerous==2.2.0 +jinja2==3.1.6 +jiter==0.14.0 +jsonschema==4.26.0 +jsonschema-specifications==2025.9.1 +justext==3.0.2 +lxml[html-clean]==6.1.0 +lxml-html-clean==0.4.4 +markupsafe==3.0.3 +narwhals==2.21.2 +numpy==2.4.5 +openai==1.109.1 +packaging==26.2 +pandas==3.0.3 +pillow==12.2.0 +protobuf==7.34.1 +pyarrow==24.0.0 +pydantic==2.13.4 +pydantic-core==2.46.4 +pydeck==0.9.2 +python-dateutil==2.9.0.post0 +python-docx==1.2.0 +python-dotenv==1.2.2 +python-multipart==0.0.28 +pytz==2026.2 +pyyaml==6.0.3 +readability-lxml==0.8.4.1 +referencing==0.37.0 +regex==2026.5.9 +requests==2.34.2 +rpds-py==0.30.0 +six==1.17.0 +smmap==5.0.3 +sniffio==1.3.1 +starlette==1.0.0 +streamlit==1.57.0 +tenacity==9.1.4 +tld==0.13.2 +toml==0.10.2 +tqdm==4.67.3 +trafilatura==2.0.0 +typing-extensions==4.15.0 +typing-inspection==0.4.2 +tzdata==2026.2 +tzlocal==5.3.1 +urllib3==2.7.0 +uvicorn==0.47.0 +watchdog==6.0.0 +websockets==16.0 diff --git a/src/constants.py b/src/constants.py index 50fb61e..8a066be 100644 --- a/src/constants.py +++ b/src/constants.py @@ -36,11 +36,11 @@ "概念地图": "🗺", } -MODEL_LABELS = {"auto": "Auto", "flash": "Flash", "pro": "Pro"} +MODEL_LABELS = {"auto": "自动", "flash": "Flash(快速)", "pro": "Pro(高质量)"} MODEL_ICONS = {"auto": "🧩", "flash": "⚡", "pro": "💎"} -PERF_LABELS = {"fast": "Fast", "standard": "Standard", "deep": "Deep"} +PERF_LABELS = {"fast": "快速", "standard": "标准", "deep": "深度"} PERF_ICONS = {"fast": "🚀", "standard": "🎯", "deep": "🔍"} diff --git a/src/mode_manager.py b/src/mode_manager.py index 8eca51e..0ae6f10 100644 --- a/src/mode_manager.py +++ b/src/mode_manager.py @@ -26,9 +26,9 @@ class RuntimeModes: route_mode: str = "auto_rule" debug_mode: bool = False safe_mode: bool = False - current_version: str = "v0.7.7" - active_task: str = "post-v0.7.7 stabilization and UI polish" - next_version: str = "v0.7.8" + current_version: str = "v0.8.0" + active_task: str = "文档同步 + UI 中文标签 + 性能预算 + 状态模型 + 工程收口" + next_version: str = "v0.8.1" relationship_mode: str = "standard" wechat_mode: str = "unread_feedback" user_has_joined: bool = False diff --git a/src/news/digest.py b/src/news/digest.py index f0e3979..db6120a 100644 --- a/src/news/digest.py +++ b/src/news/digest.py @@ -6,6 +6,7 @@ from src.llm_client import ModelProfile, chat from src.news.link_resolver import _display_link_host +from src.performance_budget import news_digest_max_tokens from src.news.rss_fetcher import normalize_news_query @@ -173,4 +174,10 @@ def generate_news_digest( ), }, ] - return chat(messages, temperature=0.3, model_profile=model_profile).strip() + return chat( + messages, + temperature=0.3, + model_profile=model_profile, + max_tokens=news_digest_max_tokens(performance_mode), + task_name="news_digest", + ).strip() diff --git a/src/performance_budget.py b/src/performance_budget.py new file mode 100644 index 0000000..9a348bf --- /dev/null +++ b/src/performance_budget.py @@ -0,0 +1,61 @@ +from __future__ import annotations + + +def normalize_performance_mode(mode: str | None) -> str: + if mode in {"fast", "standard", "deep"}: + return mode + return "standard" + + +def chat_max_tokens(mode: str | None) -> int: + mode = normalize_performance_mode(mode) + return { + "fast": 700, + "standard": 1100, + "deep": 1600, + }[mode] + + +def wechat_history_lines(mode: str | None) -> int: + mode = normalize_performance_mode(mode) + return { + "fast": 16, + "standard": 28, + "deep": 40, + }[mode] + + +def wechat_reply_max_tokens(mode: str | None) -> int: + mode = normalize_performance_mode(mode) + return { + "fast": 520, + "standard": 760, + "deep": 1050, + }[mode] + + +def wechat_opening_max_tokens(mode: str | None) -> int: + mode = normalize_performance_mode(mode) + return { + "fast": 420, + "standard": 620, + "deep": 850, + }[mode] + + +def news_digest_max_tokens(mode: str | None) -> int: + mode = normalize_performance_mode(mode) + return { + "fast": 650, + "standard": 950, + "deep": 1300, + }[mode] + + +def news_discussion_max_tokens(mode: str | None) -> int: + mode = normalize_performance_mode(mode) + return { + "fast": 520, + "standard": 760, + "deep": 1000, + }[mode] diff --git a/src/ui/chat_panel.py b/src/ui/chat_panel.py index 13cbbf9..4384560 100644 --- a/src/ui/chat_panel.py +++ b/src/ui/chat_panel.py @@ -8,6 +8,7 @@ from src.context_builder import build_messages from src.llm_client import stream_chat from src.memory import read_memory_bundle +from src.performance_budget import chat_max_tokens from src.mode_manager import load_runtime_modes from src.model_stats import estimate_tokens, record_call, record_perf from src.perf import PerfTracker, write_perf_log @@ -38,9 +39,9 @@ def _render_history_message(msg: dict): _DISPLAY_LABELS = { "mode": {"auto": "自动"}, - "model": {"auto": "Auto", "flash": "Flash", "pro": "Pro"}, - "perf": {"fast": "Fast", "standard": "Standard", "deep": "Deep"}, - "atmos": {"standard": "Standard", "warm": "Warm", "close": "Close"}, + "model": {"auto": "自动", "flash": "Flash(快速)", "pro": "Pro(高质量)"}, + "perf": {"fast": "快速", "standard": "标准", "deep": "深度"}, + "atmos": {"standard": "标准", "warm": "温和", "close": "贴近"}, } @@ -280,6 +281,8 @@ def _mark_first_token(): api_messages, model_profile=resolved_model, on_first_token=_mark_first_token, + max_tokens=chat_max_tokens(runtime_modes.performance_mode), + task_name="single_chat", ) reply = st.write_stream(stream) perf.set("llm_total_time", time.perf_counter() - llm_started) diff --git a/src/ui/status_bar.py b/src/ui/status_bar.py index 4e12b10..11c37af 100644 --- a/src/ui/status_bar.py +++ b/src/ui/status_bar.py @@ -211,22 +211,22 @@ def render_model_stats_line(): return st.caption( - f"Flash: {stats.flash_calls} | Pro: {stats.pro_calls} | " - f"Router: {stats.llm_router_calls} | " - f"Last latency: {stats.last_latency:.2f}s | Cost: ¥{estimated_cost():.4f}" + f"Flash 调用: {stats.flash_calls} | Pro 调用: {stats.pro_calls} | " + f"路由调用: {stats.llm_router_calls} | " + f"上次延迟: {stats.last_latency:.2f}s | 估算成本: ¥{estimated_cost():.4f}" ) perf = stats.last_perf or st.session_state.get("perf_metrics", {}) if st.session_state.runtime_modes.debug_mode and perf: st.caption( - "Perf: " - f"route {perf.get('route_time', 0):.3f}s | " - f"memory {perf.get('memory_read_time', 0):.3f}s | " - f"context {perf.get('context_build_time', 0):.3f}s | " - f"first token {perf.get('llm_first_token_time', 0):.3f}s | " - f"llm {perf.get('llm_total_time', 0):.3f}s | " - f"ui {perf.get('ui_render_time', 0):.3f}s | " - f"total {perf.get('total_time', 0):.3f}s" + "性能: " + f"路由 {perf.get('route_time', 0):.3f}s | " + f"记忆 {perf.get('memory_read_time', 0):.3f}s | " + f"上下文 {perf.get('context_build_time', 0):.3f}s | " + f"首 token {perf.get('llm_first_token_time', 0):.3f}s | " + f"模型 {perf.get('llm_total_time', 0):.3f}s | " + f"UI {perf.get('ui_render_time', 0):.3f}s | " + f"总计 {perf.get('total_time', 0):.3f}s" ) if st.button("重置统计"): diff --git a/src/ui/wechat_panel.py b/src/ui/wechat_panel.py index 9e3ebc5..05e2c40 100644 --- a/src/ui/wechat_panel.py +++ b/src/ui/wechat_panel.py @@ -63,12 +63,27 @@ def _apply_mark_wechat_read(session_id: str, session_state) -> None: session_state.wechat_messages = read_wechat_group() +def _clear_wechat_news_state(session_state) -> None: + session_state.wechat_news_items = [] + session_state.wechat_news_digest = "" + session_state.wechat_news_phase = "" + session_state.wechat_news_query_text = "" + session_state.wechat_news_read_articles = True + session_state.wechat_news_source_block = "" + session_state.wechat_news_coverage = {} + session_state.wechat_news_warnings = [] + session_state.wechat_news_elapsed_ms = 0 + + +def _should_render_news_phase_before_group(session_state) -> bool: + return bool(session_state.get("wechat_news_phase")) + + def _apply_new_wechat_group(session_state) -> None: reset_wechat_group() session_state.wechat_messages = None session_state.wechat_pending_input = None - session_state.wechat_news_items = [] - session_state.wechat_news_digest = "" + _clear_wechat_news_state(session_state) def _rerun_wechat_fragment(): @@ -433,6 +448,11 @@ def render_wechat_panel(): content_placeholder = st.empty() if not group_started: + if _should_render_news_phase_before_group(st.session_state): + st.caption("已获取新闻搜索结果,请按阶段继续生成摘要和群聊讨论。") + render_news_round_phases() + return + st.caption("也可以直接联网查一个话题,系统会自动拉起一轮群聊讨论。") _render_opening_setup() return diff --git a/src/wechat_generator.py b/src/wechat_generator.py index d6b0b32..0175379 100644 --- a/src/wechat_generator.py +++ b/src/wechat_generator.py @@ -7,6 +7,12 @@ from src.llm_client import ModelProfile, chat, stream_chat from src.mode_manager import load_runtime_modes +from src.performance_budget import ( + news_discussion_max_tokens, + wechat_history_lines, + wechat_opening_max_tokens, + wechat_reply_max_tokens, +) from src.role_manager import load_role from src.wechat_format import ( PERFORMANCE_STYLE_HINTS, @@ -68,7 +74,13 @@ def generate_wechat_news_discussion( ), }, ] - raw = chat(messages, temperature=0.7, model_profile=model_profile).strip() + raw = chat( + messages, + temperature=0.7, + model_profile=model_profile, + max_tokens=news_discussion_max_tokens(performance_mode), + task_name="wechat_news_discussion", + ).strip() return _ensure_all_roles_reply(raw) @@ -86,7 +98,8 @@ def _build_interactive_messages( is_first = not modes.first_reaction_done prompt = load_interactive_prompt() history = read_wechat_group() - history_lines = history.splitlines()[-40:] if history else [] + history_limit = wechat_history_lines(modes.performance_mode) + history_lines = history.splitlines()[-history_limit:] if history else [] if is_first: prompt += ( @@ -230,6 +243,8 @@ def generate_wechat_opening( ], temperature=0.8, model_profile=model_profile, + max_tokens=wechat_opening_max_tokens(performance_mode), + task_name="wechat_opening", ).strip() return _ensure_all_roles_reply(opening) @@ -243,7 +258,13 @@ def generate_interactive_wechat_reply( relationship_mode: str | None = None, ) -> str: messages, _is_first = _build_interactive_messages(user_text, relationship_mode) - raw = chat(messages, temperature=0.7, model_profile=model_profile) + raw = chat( + messages, + temperature=0.7, + model_profile=model_profile, + max_tokens=wechat_reply_max_tokens(load_runtime_modes().performance_mode), + task_name="wechat_interactive", + ) return _ensure_all_roles_reply(raw.strip()) @@ -253,7 +274,16 @@ def generate_interactive_wechat_reply_stream( relationship_mode: str | None = None, ): messages, is_first = _build_interactive_messages(user_text, relationship_mode) - return stream_chat(messages, temperature=0.7, model_profile=model_profile), is_first + return ( + stream_chat( + messages, + temperature=0.7, + model_profile=model_profile, + max_tokens=wechat_reply_max_tokens(load_runtime_modes().performance_mode), + task_name="wechat_interactive", + ), + is_first, + ) def normalize_interactive_wechat_reply(content: str) -> str: diff --git a/src/wechat_service.py b/src/wechat_service.py index ff1c9b6..e2934b9 100644 --- a/src/wechat_service.py +++ b/src/wechat_service.py @@ -5,6 +5,7 @@ from typing import Callable from src.session_logger import set_wechat_interactive +from src.mode_manager import update_wechat_join_state from src.news.article_fetcher import enrich_news_items_with_article_text from src.news.digest import format_news_source_block, generate_news_digest from src.news.rss_fetcher import fetch_news_items @@ -178,7 +179,15 @@ def run_discussion_stage( if source_block: append_system_group_note(source_block) + append_interactive_group_reply(discussion) + + update_wechat_join_state( + user_has_joined=False, + first_reaction_done=False, + mode="interactive_group", + ) + group_content = read_wechat_group() if session_id: diff --git a/tests/test_packaging_guards.py b/tests/test_packaging_guards.py index ea44390..34b8784 100644 --- a/tests/test_packaging_guards.py +++ b/tests/test_packaging_guards.py @@ -156,12 +156,12 @@ def test_runtime_version_is_synced(): state_text = Path("memory/internal_state.md").read_text(encoding="utf-8") yaml_text = Path("config/runtime_state.yaml").read_text(encoding="utf-8") - assert 'current_version: str = "v0.7.7"' in mode_text - assert 'next_version: str = "v0.7.8"' in mode_text - assert '- current_version: v0.7.7' in state_text - assert '- next_version: v0.7.8' in state_text - assert "current: v0.7.7" in yaml_text - assert "next: v0.7.8" in yaml_text + assert 'current_version: str = "v0.8.0"' in mode_text + assert 'next_version: str = "v0.8.1"' in mode_text + assert '- current_version: v0.8.0' in state_text + assert '- next_version: v0.8.1' in state_text + assert "current: v0.8.0" in yaml_text + assert "next: v0.8.1" in yaml_text def test_ci_workflow_exists_and_runs_core_checks(): diff --git a/tests/test_performance_budget.py b/tests/test_performance_budget.py new file mode 100644 index 0000000..5b501a3 --- /dev/null +++ b/tests/test_performance_budget.py @@ -0,0 +1,99 @@ +"""Test performance budget module — verify ordering and fallback.""" +from __future__ import annotations + +from src.performance_budget import ( + chat_max_tokens, + news_digest_max_tokens, + news_discussion_max_tokens, + wechat_history_lines, + wechat_opening_max_tokens, + wechat_reply_max_tokens, +) + + +def _assert_strictly_increasing(values: list[int]): + for a, b in zip(values, values[1:]): + assert a < b, f"Expected {a} < {b}" + + +class TestChatMaxTokens: + def test_fast_lt_standard_lt_deep(self): + _assert_strictly_increasing([ + chat_max_tokens("fast"), + chat_max_tokens("standard"), + chat_max_tokens("deep"), + ]) + + def test_none_falls_back_to_standard(self): + assert chat_max_tokens(None) == chat_max_tokens("standard") + + def test_unknown_mode_falls_back_to_standard(self): + assert chat_max_tokens("unknown") == chat_max_tokens("standard") + + def test_values_are_positive(self): + for mode in ("fast", "standard", "deep"): + assert chat_max_tokens(mode) > 0 + + +class TestWechatHistoryLines: + def test_fast_lt_standard_lt_deep(self): + _assert_strictly_increasing([ + wechat_history_lines("fast"), + wechat_history_lines("standard"), + wechat_history_lines("deep"), + ]) + + def test_unknown_falls_back(self): + assert wechat_history_lines(None) == wechat_history_lines("standard") + + def test_values_are_positive(self): + for mode in ("fast", "standard", "deep"): + assert wechat_history_lines(mode) > 0 + + +class TestWechatReplyMaxTokens: + def test_fast_lt_standard_lt_deep(self): + _assert_strictly_increasing([ + wechat_reply_max_tokens("fast"), + wechat_reply_max_tokens("standard"), + wechat_reply_max_tokens("deep"), + ]) + + def test_unknown_falls_back(self): + assert wechat_reply_max_tokens(None) == wechat_reply_max_tokens("standard") + + +class TestWechatOpeningMaxTokens: + def test_fast_lt_standard_lt_deep(self): + _assert_strictly_increasing([ + wechat_opening_max_tokens("fast"), + wechat_opening_max_tokens("standard"), + wechat_opening_max_tokens("deep"), + ]) + + def test_unknown_falls_back(self): + assert wechat_opening_max_tokens(None) == wechat_opening_max_tokens("standard") + + +class TestNewsDigestMaxTokens: + def test_fast_lt_standard_lt_deep(self): + _assert_strictly_increasing([ + news_digest_max_tokens("fast"), + news_digest_max_tokens("standard"), + news_digest_max_tokens("deep"), + ]) + + def test_unknown_falls_back(self): + assert news_digest_max_tokens(None) == news_digest_max_tokens("standard") + + +class TestNewsDiscussionMaxTokens: + def test_fast_lt_standard_lt_deep(self): + _assert_strictly_increasing([ + news_discussion_max_tokens("fast"), + news_discussion_max_tokens("standard"), + news_discussion_max_tokens("deep"), + ]) + + def test_unknown_falls_back(self): + assert news_discussion_max_tokens(None) == news_discussion_max_tokens("standard") diff --git a/tests/test_wechat_news_entry_flow.py b/tests/test_wechat_news_entry_flow.py new file mode 100644 index 0000000..f21f8b8 --- /dev/null +++ b/tests/test_wechat_news_entry_flow.py @@ -0,0 +1,119 @@ +"""Test the news entry state machine fix: + +- _should_render_news_phase_before_group detects wechat_news_phase +- _clear_wechat_news_state resets all news-related session state +""" +from __future__ import annotations + +from src.ui.wechat_panel import ( + _clear_wechat_news_state, + _should_render_news_phase_before_group, +) + + +class _FakeSessionState(dict): + """dict subclass that also supports attribute access like st.session_state.""" + def __getattr__(self, name: str): + try: + return self[name] + except KeyError: + raise AttributeError(name) from None + + def __setattr__(self, name: str, value): + self[name] = value + + def __delattr__(self, name: str): + try: + del self[name] + except KeyError: + raise AttributeError(name) from None + + +def _make_session(**overrides) -> _FakeSessionState: + """Build a fake session_state-like object with news defaults.""" + ss = _FakeSessionState() + for k, v in { + "wechat_news_items": [], + "wechat_news_digest": "", + "wechat_news_phase": "", + "wechat_news_query_text": "", + "wechat_news_read_articles": True, + "wechat_news_source_block": "", + "wechat_news_coverage": {}, + "wechat_news_warnings": [], + "wechat_news_elapsed_ms": 0, + }.items(): + ss[k] = v + for k, v in overrides.items(): + ss[k] = v + return ss + + +# ── _should_render_news_phase_before_group ──────────────────────────────── + + +class TestShouldRenderNewsPhaseBeforeGroup: + def test_phase_searched_returns_true(self): + ss = _make_session(wechat_news_phase="searched") + assert _should_render_news_phase_before_group(ss) is True + + def test_phase_empty_returns_false(self): + ss = _make_session(wechat_news_phase="") + assert _should_render_news_phase_before_group(ss) is False + + def test_phase_none_returns_false(self): + ss = _make_session() + del ss.wechat_news_phase + assert _should_render_news_phase_before_group(ss) is False + + def test_phase_other_string_returns_true(self): + ss = _make_session(wechat_news_phase="digested") + assert _should_render_news_phase_before_group(ss) is True + + def test_phase_discussed_returns_true(self): + ss = _make_session(wechat_news_phase="discussed") + assert _should_render_news_phase_before_group(ss) is True + + +# ── _clear_wechat_news_state ────────────────────────────────────────────── + + +class TestClearWechatNewsState: + def test_clears_all_fields(self): + ss = _make_session( + wechat_news_items=[{"title": "A"}], + wechat_news_digest="some digest", + wechat_news_phase="searched", + wechat_news_query_text="latest AI news", + wechat_news_read_articles=False, + wechat_news_source_block="source block", + wechat_news_coverage={"total": 5}, + wechat_news_warnings=["warning"], + wechat_news_elapsed_ms=1234, + ) + + _clear_wechat_news_state(ss) + + assert ss.wechat_news_items == [] + assert ss.wechat_news_digest == "" + assert ss.wechat_news_phase == "" + assert ss.wechat_news_query_text == "" + assert ss.wechat_news_source_block == "" + assert ss.wechat_news_coverage == {} + assert ss.wechat_news_warnings == [] + assert ss.wechat_news_elapsed_ms == 0 + assert ss.wechat_news_read_articles is True + + def test_idempotent_on_already_clean(self): + ss = _make_session() + _clear_wechat_news_state(ss) + + assert ss.wechat_news_items == [] + assert ss.wechat_news_digest == "" + assert ss.wechat_news_phase == "" + assert ss.wechat_news_query_text == "" + assert ss.wechat_news_read_articles is True + assert ss.wechat_news_source_block == "" + assert ss.wechat_news_coverage == {} + assert ss.wechat_news_warnings == [] + assert ss.wechat_news_elapsed_ms == 0 diff --git a/tests/test_wechat_service_news_flow.py b/tests/test_wechat_service_news_flow.py new file mode 100644 index 0000000..808325f --- /dev/null +++ b/tests/test_wechat_service_news_flow.py @@ -0,0 +1,215 @@ +"""Test the wechat_service news flow with state sync fix. + +- run_discussion_stage must call update_wechat_join_state with correct args +- WeChat generator calls must pass max_tokens and task_name +""" +from __future__ import annotations + + +# ── run_discussion_stage ────────────────────────────────────────────────── + + +class TestRunDiscussionStage: + def test_writes_discussion_and_syncs_join_state(self, monkeypatch): + from src import wechat_service + + writes: list[str] = [] + join_state_calls: list[tuple] = [] + + monkeypatch.setattr( + wechat_service, + "generate_wechat_news_discussion", + lambda *args, **kwargs: "mock discussion text", + ) + monkeypatch.setattr( + wechat_service, + "append_system_group_note", + lambda content: writes.append(("note", content)), + ) + monkeypatch.setattr( + wechat_service, + "append_interactive_group_reply", + lambda content: writes.append(("reply", content)), + ) + monkeypatch.setattr( + wechat_service, + "update_wechat_join_state", + lambda user_has_joined, first_reaction_done, mode: join_state_calls.append( + (user_has_joined, first_reaction_done, mode) + ), + ) + monkeypatch.setattr(wechat_service, "read_wechat_group", lambda: "group content") + + discussion, group_content = wechat_service.run_discussion_stage( + digest="test digest", + interaction_mode="warm", + performance_mode="standard", + selected_model="flash", + source_block="source block text", + ) + + # Verify output + assert discussion == "mock discussion text" + assert group_content == "group content" + + # Verify writes in order + assert writes == [ + ("note", "source block text"), + ("reply", "mock discussion text"), + ] + # Verify join state was synced with correct args + assert join_state_calls == [ + (False, False, "interactive_group"), + ] + + def test_writes_discussion_without_source_block(self, monkeypatch): + from src import wechat_service + + writes: list[str] = [] + join_state_calls: list[tuple] = [] + + monkeypatch.setattr( + wechat_service, + "generate_wechat_news_discussion", + lambda *args, **kwargs: "discussion", + ) + monkeypatch.setattr( + wechat_service, + "append_system_group_note", + lambda content: writes.append(("note", content)), + ) + monkeypatch.setattr( + wechat_service, + "append_interactive_group_reply", + lambda content: writes.append(("reply", content)), + ) + monkeypatch.setattr( + wechat_service, + "update_wechat_join_state", + lambda user_has_joined, first_reaction_done, mode: join_state_calls.append( + (user_has_joined, first_reaction_done, mode) + ), + ) + monkeypatch.setattr(wechat_service, "read_wechat_group", lambda: "group") + + wechat_service.run_discussion_stage( + digest="digest", + interaction_mode="standard", + performance_mode="fast", + selected_model="auto", + source_block="", + ) + + # Without source_block, no system note should be written + assert writes == [("reply", "discussion")] + # Join state should still be synced + assert join_state_calls == [(False, False, "interactive_group")] + + +# ── wechat_generator budget parameter verification ─────────────────────── + + +class TestWechatGeneratorBudget: + def test_generate_wechat_news_discussion_passes_max_tokens_and_task_name( + self, monkeypatch + ): + from src import wechat_generator + + call_kwargs: dict = {} + + def _mock_chat(*args, **kwargs): + call_kwargs.update(kwargs) + return "【三月七】\nok\n\n【刻晴】\nok\n\n【纳西妲】\nok\n\n【流萤】\nok" + + monkeypatch.setattr(wechat_generator, "chat", _mock_chat) + + result = wechat_generator.generate_wechat_news_discussion( + news_digest="test digest", + relationship_mode="standard", + performance_mode="fast", + selected_model="auto", + ) + + assert result + assert call_kwargs.get("max_tokens") is not None + assert call_kwargs.get("task_name") == "wechat_news_discussion" + + def test_generate_interactive_wechat_reply_stream_passes_max_tokens_and_task_name( + self, monkeypatch + ): + from src import wechat_generator + + call_kwargs: dict = {} + stream_result = iter(["hello"]) + + def _mock_stream_chat(*args, **kwargs): + call_kwargs.update(kwargs) + return stream_result + + monkeypatch.setattr(wechat_generator, "stream_chat", _mock_stream_chat) + monkeypatch.setattr(wechat_generator, "read_wechat_group", lambda: "【三月七】\nhi") + monkeypatch.setattr( + wechat_generator, "load_runtime_modes", lambda: type("Modes", (), { + "performance_mode": "fast", + "relationship_mode": "standard", + "first_reaction_done": True, + })() + ) + + stream, _is_first = wechat_generator.generate_interactive_wechat_reply_stream( + user_text="hello", + model_profile="flash", + relationship_mode="standard", + ) + + # Exhaust the iterator so the generator runs + list(stream) + + assert call_kwargs.get("max_tokens") is not None + assert call_kwargs.get("task_name") == "wechat_interactive" + + def test_generate_news_digest_passes_max_tokens_and_task_name(self, monkeypatch): + from src.news import digest as news_digest_module + + call_kwargs: dict = {} + + def _mock_chat(*args, **kwargs): + call_kwargs.update(kwargs) + return "【搜索结果摘要】\nno news" + + monkeypatch.setattr(news_digest_module, "chat", _mock_chat) + + result = news_digest_module.generate_news_digest( + news_items=[{"title": "A", "source": "S", "published_at": "today"}], + performance_mode="standard", + selected_model="auto", + ) + + assert result + assert call_kwargs.get("max_tokens") is not None + assert call_kwargs.get("task_name") == "news_digest" + + def test_generate_wechat_opening_passes_max_tokens_and_task_name( + self, monkeypatch + ): + from src import wechat_generator + + call_kwargs: dict = {} + + def _mock_chat(*args, **kwargs): + call_kwargs.update(kwargs) + return "【三月七】\nok\n\n【刻晴】\nok\n\n【纳西妲】\nok\n\n【流萤】\nok" + + monkeypatch.setattr(wechat_generator, "chat", _mock_chat) + monkeypatch.setattr(wechat_generator, "load_role", lambda x: "") + + result = wechat_generator.generate_wechat_opening( + role_hint="auto", + relationship_mode="standard", + performance_mode="fast", + selected_model="auto", + ) + + assert result + assert call_kwargs.get("max_tokens") is not None + assert call_kwargs.get("task_name") == "wechat_opening"