16 changed files with 2063 additions and 554 deletions
+199 -233
View File
@@ -1,17 +1,17 @@
# AGENTS.md
## Purpose
## 目的
This file defines how AI coding agents should work in this repository. It is an operating guide, not a product requirements document.
本文件定义 AI 编码代理在本仓库中的工作方式。它是操作指南,不是产品需求文档。
Do not restate or replace the product specs here. Product requirements, UI decisions, technical decisions, and test expectations live in `docs/`.
不要在这里复述或替代产品规格。产品需求、UI 决策、技术决策和测试期望都放在 `docs/` 中。
## Source Of Truth
## 信息源
Before implementation, read the relevant docs in this order:
开始实现前,按以下顺序阅读相关文档:
1. `docs/PRD.md`
2. `docs/RequirementsDoc.md` (if it exists in the repository; skip if absent)
2. `docs/RequirementsDoc.md`(如果仓库中存在;不存在则跳过)
3. `docs/FeatureSummary.md`
4. `docs/DevelopmentPlan.md`
5. `docs/Tasks.md`
@@ -20,188 +20,161 @@ Before implementation, read the relevant docs in this order:
8. `docs/API-Spike-Xiaohongshu.md`
9. `docs/API-Spike-Douyin.md`
If `docs/review-*.md` files exist, read them after the corresponding source document.
Review files contain corrections and clarifications that override ambiguous points
in the source document. For example, read `review-Tasks-kiro.md` after `Tasks.md`.
如果存在 `docs/review-*.md` 文件,请在对应源文档之后阅读。
评审文件包含修正和澄清,可覆盖源文档中的模糊点。
例如,阅读 `Tasks.md` 后再阅读 `review-Tasks-kiro.md`
Use `docs/DevelopmentPlan.md` for architecture and technology choices. Use `docs/Tasks.md` for implementation sequencing. Use `docs/TDD.md` for test strategy. Use `docs/UIDesign.md` for page structure and UI behavior. Use the API Spike docs for platform field mapping and external API flow.
使用 `docs/DevelopmentPlan.md` 判断架构和技术选型。使用 `docs/Tasks.md` 判断实现顺序。使用 `docs/TDD.md` 判断测试策略。使用 `docs/UIDesign.md` 判断页面结构和 UI 行为。使用 API Spike 文档判断平台字段映射和外部 API 流程。
If documents conflict or if the provided context is insufficient to make a decision,
DO NOT guess or hallucinate. Stop execution immediately, summarize the conflict or
information gap, cite the files involved, and ask the user for confirmation before
implementing. This applies to all levels of conflict -- architecture, implementation
details, naming conventions, and behavioral expectations alike.
Never proceed with an assumption when the source documents are ambiguous or silent
on a required decision.
如果文档之间存在冲突,或提供的上下文不足以做决定,
不要猜测或编造。立即停止执行,概述冲突或信息缺口,引用相关文件,
并在实现前向用户确认。该规则适用于所有层级的冲突,包括架构、
实现细节、命名约定和行为预期。
当源文档对必需决策表述模糊或没有说明时,绝不要带着假设继续。
If you cannot find the answer in the listed documents and your own knowledge is
uncertain, state explicitly: "I don't have enough context to decide this. Please
provide [specific document or clarification]." Never fill gaps with invented behavior.
如果无法在上述文档中找到答案,并且你自己的知识也不确定,请明确说明:
"I don't have enough context to decide this. Please provide [specific document or clarification]."
不要用虚构行为填补空白。
## Project Constraints
## 项目约束
- Build a lightweight MVP first.
- Prefer a working end-to-end flow over broad incomplete features.
- The entire project follows TDD (Test-Driven Development). For every feature,
bug fix, data transformation, service, or behavior change, write the relevant
failing test first, implement the smallest code to pass it, then refactor only
after tests pass. If a task cannot reasonably be test-first, state the reason
before implementation and add verification coverage as close to the change as possible.
- Keep the architecture aligned with the current plan: FastAPI, SQLite, SQLAlchemy, Jinja2 templates, simple CSS or Bootstrap, native JavaScript, and Docker Compose.
- Do not introduce a frontend SPA framework, Redis, Celery, PostgreSQL, login system, scheduled jobs, or distributed workers unless the user explicitly changes the scope.
- Do not commit real API keys, tokens, cookies, or private credentials.
- Treat TikHub and AI providers as external dependencies that must be mocked in tests.
- Background tasks run in a ThreadPoolExecutor thread, not in the async event loop.
Use synchronous `httpx.Client` for all external HTTP calls in background tasks.
Do not use `httpx.AsyncClient` or `await` in background task functions.
- Reports are pre-generated after task completion and stored in the `reports` table.
Page rendering and file exports must read from the same pre-generated report data.
Do not compute statistics on-the-fly in page route handlers.
- Task execution uses `ThreadPoolExecutor(max_workers=1)`. Only one task can run at
a time. If a task with `status=running` already exists, reject new task creation
with HTTP 400.
- 先构建轻量级 MVP。
- 优先保证可工作的端到端流程,而不是大量未完成的功能。
- 整个项目遵循 TDDTest-Driven Development,测试驱动开发)。对于每个功能、
bug 修复、数据转换、服务或行为变更,先编写相关的失败测试,
再实现让测试通过的最小代码,然后只在测试通过后进行重构。
如果某个任务确实不适合测试先行,先说明原因,并添加尽可能贴近变更的验证覆盖。
- 保持架构与当前计划一致:FastAPI、SQLite、SQLAlchemy、Jinja2 模板、简单 CSS 或 Bootstrap、原生 JavaScript,以及 Docker Compose。
- 除非用户明确改变范围,否则不要引入前端 SPA 框架、Redis、Celery、PostgreSQL、登录系统、定时任务或分布式 worker。
- 不要提交真实 API key、token、cookie 或私密凭据。
- 将 TikHub 和 AI 提供商视为必须在测试中 mock 的外部依赖。
- 后台任务运行在 ThreadPoolExecutor 线程中,而不是 async event loop 中。
后台任务中的所有外部 HTTP 调用都使用同步 `httpx.Client`
不要在后台任务函数中使用 `httpx.AsyncClient` `await`
- 报告在任务完成后预生成,并存储在 `reports` 表中。
页面渲染和文件导出必须读取同一份预生成报告数据。
不要在页面路由处理器中即时计算统计信息。
- 任务执行使用 `ThreadPoolExecutor(max_workers=1)`。一次只能运行一个任务。
如果已经存在 `status=running` 的任务,创建新任务时返回 HTTP 400。
## MVP Discipline (Optimized for Speed)
## MVP 纪律(为速度优化)
The constraints in §Project Constraints are always active regardless of timeline.
This section provides prioritization guidance, not permission to skip P0 features
defined in `docs/Tasks.md`.
无论时间线如何,§Project Constraints 中的约束始终生效。
本节提供优先级指导,不代表可以跳过 `docs/Tasks.md` 中定义的 P0 功能。
When the user asks for fast delivery, optimize for the earliest demonstrable slice.
But you MUST deliver all P0 tasks defined in `docs/Tasks.md` §7 (T01-T23).
Do NOT defer any P0 feature without explicit user confirmation.
当用户要求快速交付时,优化目标是尽早拿出可演示的切片。
但必须交付 `docs/Tasks.md` §7 中定义的全部 P0 任务(T01-T23)。
未经用户明确确认,不得延期任何 P0 功能。
Items that may be deferred only with explicit user confirmation:
只有在用户明确确认后才可以延期的项目:
- Playwright e2e tests (T23 Playwright portion only)
- P1 optional tasks (docs/Tasks.md §9: auto-polling, JSON debug panel, progress bar)
- Advanced UI polish (breadcrumbs, `<title>` naming, progress animations)
- Playwright e2e 测试(仅 T23 Playwright 部分)
- P1 可选任务(docs/Tasks.md §9:自动轮询、JSON 调试面板、进度条)
- 高级 UI 打磨(面包屑、`<title>` 命名、进度动画)
Items that must NOT be deferred without explicit user confirmation:
未经用户明确确认绝不能延期的项目:
- Zombie task recovery (T05)
- 429 exponential backoff (T07)
- Comment pagination (T10)
- AI retry and degradation (T13)
- Report pre-generation (T14/T15/T16)
- CSV/Markdown export (T20)
- Docker Compose packaging (T22)
- 僵尸任务恢复(T05
- 429 指数退避(T07
- 评论分页(T10
- AI 重试和降级(T13
- 报告预生成(T14/T15/T16
- CSV/Markdown 导出(T20
- Docker Compose 打包(T22
Defer polish, scale, authentication, scheduling, and features not listed in
`docs/Tasks.md` P0 scope. When in doubt about whether something is P0,
check `docs/Tasks.md` -- it is the authoritative task list.
延期打磨、规模化、认证、调度以及未列入 `docs/Tasks.md` P0 范围的功能。
如果不确定某项是否属于 P0,请查看 `docs/Tasks.md`,它是权威任务列表。
## Development Workflow
## 开发流程
For each task:
对于每个任务:
1. Inspect the relevant docs and existing code.
2. Identify the smallest useful implementation slice.
3. Write tests BEFORE business logic for:
- Data mapping and field transformation (platforms -> models)
- AI JSON Schema validation and parsing
- Report statistics calculation
- Export formatting (CSV structure, Markdown structure)
- State machine transitions (task status, analysis status)
- Error degradation paths (retry exhaustion -> fallback behavior)
For UI templates, route handler wiring, and configuration setup,
write tests before or alongside implementation -- but never after.
This is a hard rule from `docs/TDD.md` §2.1: "禁止先实现后补测试。"
4. Implement the minimum code needed to pass the tests.
5. Run focused verification commands.
6. Report what changed, what was verified, and what remains.
1. 检查相关文档和现有代码。
2. 确定最小的有用实现切片。
3. 在业务逻辑之前编写测试,覆盖:
- 数据映射和字段转换(platforms -> models
- AI JSON Schema 校验和解析
- 报告统计计算
- 导出格式(CSV 结构、Markdown 结构)
- 状态机转换(task statusanalysis status
- 错误降级路径(重试耗尽 -> fallback behavior
对于 UI 模板、路由处理器接线和配置设置,
在实现之前或同时编写测试,但绝不能在实现之后补写。
这是 `docs/TDD.md` §2.1 的硬性规则:"禁止先实现后补测试。"
4. 实现让测试通过的最小代码。
5. 运行聚焦的验证命令。
6. 汇报变更内容、已验证内容和剩余事项。
Keep changes scoped. Avoid unrelated refactors. If a file or design issue blocks the requested work, propose the smallest corrective change that serves the current goal.
保持变更范围聚焦。避免无关重构。如果某个文件或设计问题阻塞当前请求,
提出服务于当前目标的最小修正方案。
### Project Memory And Conversation Learnings
### 项目记忆和对话经验
After any valuable conversation iteration, agents should consider whether the
learning should be preserved in this `AGENTS.md` file so future agents understand
the project better. Examples include successfully resolving a recurring problem,
confirming an ambiguous project convention, discovering a reliable workflow, or
clarifying how agents should coordinate work in this repository.
每次有价值的对话迭代后,代理都应考虑是否需要把经验保存在本 `AGENTS.md` 文件中,
以便未来代理更好地理解项目。例如:成功解决了反复出现的问题、
确认了模糊的项目约定、发现了可靠工作流,或澄清了代理之间应如何协作。
Do not silently add uncertain or speculative rules. If the learning is ambiguous,
could change product behavior, or might conflict with the source documents, ask the
user for confirmation before updating `AGENTS.md`.
不要静默添加不确定或推测性的规则。如果经验存在歧义、可能改变产品行为,
或可能与信息源文档冲突,请在更新 `AGENTS.md` 前向用户确认。
Keep additions concise and operational. `AGENTS.md` should capture durable agent
working rules, not replace product requirements, implementation specs, or detailed
task plans that belong in `docs/`.
新增内容应简洁且具备可操作性。`AGENTS.md` 应记录持久的代理工作规则,
而不是替代属于 `docs/` 的产品需求、实现规格或详细任务计划。
### Error Handling Philosophy
### 错误处理理念
These principles are defined in `docs/DevelopmentPlan.md` §10 and `docs/TDD.md` §13.
Apply them consistently across all implementations:
这些原则定义于 `docs/DevelopmentPlan.md` §10 `docs/TDD.md` §13
在所有实现中一致应用:
- A single item failure must not crash the entire task. Isolate failures at the
per-comment or per-content-item level.
- External API failures (4xx/5xx) should be retried with exponential backoff
(1s -> 2s -> 4s), then gracefully degraded with error recorded in the database.
- AI analysis failures should be recorded per-comment via `ai_analysis_status=failed`
and reflected in `analysis_success_rate`, not propagated as task-level failures.
- Page rendering must never return HTTP 500 due to missing report data.
Use default text or empty-state UI instead.
- Each processing unit (content item, comment batch) should commit to the database
independently. Do not hold a single transaction open for the entire task duration.
- 单个条目的失败不得导致整个任务崩溃。按每条评论或每个内容项隔离失败。
- 外部 API 失败(4xx/5xx)应使用指数退避重试(1s -> 2s -> 4s),
然后优雅降级,并将错误记录到数据库中。
- AI 分析失败应按评论记录为 `ai_analysis_status=failed`
并反映到 `analysis_success_rate` 中,不得作为任务级失败向外抛出。
- 页面渲染绝不能因为缺少报告数据而返回 HTTP 500。
使用默认文本或空状态 UI 替代。
- 每个处理单元(内容项、评论批次)都应独立提交到数据库。
不要在整个任务期间持有一个长事务。
## Superpowers Workflow
## 开发约定
Superpowers are structured thinking modes available in certain AI coding environments
(e.g., Codex). If the current environment does not support `superpowers:*` prefixes,
apply the same cognitive sequence manually: brainstorm -> plan -> test-first -> implement
-> debug -> verify.
- 修复任何工单必须在独立的 git worktree 中进行;开工前先用 `git worktree add` 创建专属工作目录,避免污染主工作区、便于多工单并行。修 CI 配置(Dockerfile、Drone 流水线等)可以直接在主仓库改,因为 CI 改动要打 tag 才能触发构建。
- 处理任何工单必须先检查并使用适用的 Superpowers Skill;在分析、提问、制定计划或改代码前,至少先启用 `using-superpowers`,并按任务性质继续使用 `systematic-debugging``test-driven-development``using-git-worktrees``verification-before-completion` 等相关技能。若判断没有适用技能,必须简短说明原因后再继续。
- 新功能需求类工单必须先使用 Superpowers 的 `brainstorming` 技能帮助澄清目标、约束和方案,再进入计划或实现;缺陷类工单必须先使用 `systematic-debugging` 技能复现问题并分析 root cause,再开始修复,禁止在根因未明确时直接改代码。
- 实现或修复工单完成后,必须继续按 Superpowers 收尾流程执行验证、代码审查、PR/合并准备和工作区清理;通常应依次使用 `verification-before-completion``requesting-code-review``finishing-a-development-branch` 等适用技能,在完成这些流程前不得声称工单已结束。
When superpowers skills are available, use them as the development process layer:
如果当前环境不支持 `superpowers:*` 前缀,请手动应用同样的认知顺序:
brainstorm -> plan -> test-first -> implement -> debug -> verify。进入验证阶段前,删除占位注释、调试 print/console.log,以及无说明的注释掉代码块。
- Use `superpowers:brainstorming` before unclear feature design, scope decisions, or behavior changes.
- Use `superpowers:writing-plans` before substantial implementation.
- Use `superpowers:test-driven-development` for business logic, data mapping, parsing, reporting, and bug fixes when practical.
## 多代理规则
Before moving from implementation to the verification step:
仅在任务彼此独立、且可由主代理评审和集成时使用多个代理。
- Remove all placeholder comments (e.g., `# TODO: implement this`, `# FIXME`).
- Remove all debugging print/console.log statements.
- Ensure no commented-out code blocks remain unless they serve as documentation
for a deliberate design decision (annotated with a reason).
适合并行的任务:
- Use `superpowers:systematic-debugging` before fixing failing behavior or unexpected test results.
- Use `superpowers:verification-before-completion` before claiming work is complete.
- Use `superpowers:requesting-code-review` for large changes or milestone completion.
- 审查文档是否存在冲突。
- 调研一个平台 API 映射。
- 为一个服务起草测试。
- 根据 `docs/UIDesign.md` 审查 UI 行为。
- 功能完成后审查实现中的 bug。
- 实现两个独立的平台适配器(例如 T08 Xiaohongshu 和 T09 Douyin)。
- 构建两个不共享数据查询的页面模板(例如 T17 和 T18)。
- 一个代理实现导出服务(T20),另一个代理处理模板宏(T21)。
- 一个代理为模块 A 编写单元测试,另一个代理实现与 A 没有依赖关系的模块 B。
If the skills are unavailable, follow the same principles manually: clarify scope, write a short plan, test first where practical, debug from evidence, verify before completion, and keep commits focused.
避免为以下事项使用并行代理:
## Multi-Agent Rules
- 同时编辑同一个核心文件。
- 做出相互竞争的架构决策。
- 在没有单一负责人的情况下修改共享数据模型。
- 在没有集成计划的情况下实现广泛的横切变更。
Use multiple agents only for independent work that can be reviewed and integrated by the main agent.
主代理仍然负责最终决策、集成、验证和 Git 提交。
Good parallel tasks:
## 测试和验证
- Review docs for conflicts.
- Investigate one platform API mapping.
- Draft tests for one service.
- Review UI behavior against `docs/UIDesign.md`.
- Review implementation for bugs after a feature is complete.
- Implementing two independent platform adapters (e.g., T08 Xiaohongshu and T09 Douyin).
- Building two page templates that do not share data queries (e.g., T17 and T18).
- Implementing export service (T20) while another agent works on template macros (T21).
- Writing unit tests for module A while another agent implements module B that has no
dependency on A.
遵循 `docs/TDD.md`
Avoid parallel agents for:
- Editing the same core file at the same time.
- Making competing architecture decisions.
- Changing shared data models without one owner.
- Implementing broad cross-cutting changes without an integration plan.
The main agent remains responsible for final decisions, integration, verification, and Git commits.
## Testing And Verification
Follow `docs/TDD.md`.
Default verification targets:
默认验证目标:
```bash
pytest tests/unit -q
@@ -210,81 +183,75 @@ pytest tests/unit tests/integration -q
curl -f http://localhost:8000/health
```
Before claiming a milestone or task complete, run the coverage check:
在声称里程碑或任务完成前,运行覆盖率检查:
```bash
pytest tests/unit tests/integration --cov=app --cov-branch --cov-report=term-missing
```
Target line coverage: 80% or above for the following modules:
以下模块的目标行覆盖率为 80% 或以上:
- `app/platforms/` (all platform adapters)
- `app/platforms/`(所有平台适配器)
- `app/services/ai_service.py`
- `app/services/report_service.py`
- `app/services/export_service.py`
If coverage drops below 80% for these modules, add tests before proceeding.
如果这些模块的覆盖率低于 80%,先补充测试再继续。
Use focused commands while developing, then run broader verification before completion. Do not rely on real TikHub or AI API calls for unit tests. Mock external HTTP calls and AI responses.
开发期间使用聚焦命令,然后在完成前运行更广泛的验证。不要在单元测试中依赖真实 TikHub AI API 调用。mock 外部 HTTP 调用和 AI 响应。
For UI work, verify rendered pages manually or with browser automation when practical. Check that text does not overlap, core actions are visible, and the page follows `docs/UIDesign.md`.
对于 UI 工作,在可行时通过手动或浏览器自动化验证渲染页面。
检查文本不重叠、核心操作可见,并且页面符合 `docs/UIDesign.md`
## Git Workflow
## Git 工作流
Use small, focused commits. One commit should represent one clear change.
使用小而聚焦的提交。一个提交应代表一个清晰变更。
### Solo Commit Discipline
### 单人提交纪律
For this solo project, after completing each independent feature/task, run the
relevant tests and create one focused git commit for the files related to that
task. Do not push unless the user explicitly asks. Do not commit unrelated
files.
对于这个单人项目,在完成每个独立功能/任务后,运行相关测试,
并为该任务相关文件创建一个聚焦的 git commit。除非用户明确要求,否则不要 push。
不要提交无关文件。
An "independent feature/task" means the smallest useful change that can be
understood, tested, and reverted on its own. Examples:
"独立功能/任务" 指能够被单独理解、测试和回滚的最小有用变更。例如:
- One `docs/Tasks.md` task such as T07 API retry, T20 export, or T22 Docker.
- One narrow bug fix, such as fixing 401 error display or CSV newline handling.
- One cohesive page or route improvement, such as adding the task detail page.
- One test-only change that documents or locks down one behavior.
- 一个 `docs/Tasks.md` 任务,例如 T07 API 重试、T20 导出或 T22 Docker
- 一个狭窄 bug 修复,例如修复 401 错误显示或 CSV 换行处理。
- 一个内聚的页面或路由改进,例如添加任务详情页。
- 一个仅测试变更,用于记录或锁定某个行为。
Do not mix unrelated changes in one commit. For example, do not combine Docker
deployment, UI redesign, AI retry logic, and documentation edits unless they are
strictly required to complete one same task.
不要把无关变更混在一个提交中。例如,不要把 Docker 部署、UI 重设计、
AI 重试逻辑和文档编辑合并在一个提交里,除非它们确实都严格服务于同一任务。
Commit messages for this project must be written in Chinese while keeping the
standard prefix. Examples:
本项目的提交消息必须用中文书写,同时保留标准前缀。示例:
- `feat: 接入真实 AI 评论分析`
- `fix: 修复评论分页停止条件`
- `test: 补充导出 CSV 注入防护测试`
- `docs: 记录单人项目提交规则`
### Current Solo Execution Mode
### 当前单人执行模式
The current project phase is initial solo development and process practice. Unless
the user explicitly enables parallel work or PR workflow, execute tasks
sequentially: one task at a time, in dependency order.
当前项目阶段是初始单人开发和流程练习。除非用户明确启用并行工作或 PR 工作流,
否则按依赖顺序串行执行任务:一次一个任务。
For this phase, use one task branch per `docs/Tasks.md` task, named
`feat/tXX-short-description` (for example, `feat/t01-project-skeleton`). After the
task passes its required checks, create one focused commit for that task when
practical. Large tasks may be split into multiple meaningful commits only when a
single commit would be hard to review or safely revert.
除 CI 配置修复外,每个工单都应先在主仓库外创建独立 git worktree,再在该 worktree 中创建或检出对应任务分支。分支命名建议继续使用
`feat/tXX-short-description``fix/tXX-short-description` 或工单系统约定名称。
任务通过所需检查后,在可行时为该任务创建一个聚焦提交。
如果单个提交难以评审或安全回滚,大型任务可以拆分为多个有意义的提交。
Do not start the next task until the previous task has been reviewed, verified,
and either merged into the working baseline or explicitly approved as the base for
the next branch. Do not open a PR unless the user asks for one.
前一个任务未完成评审、验证,并合并到工作基线或被明确批准作为下一个分支基线前,
不要开始下一个任务。除非用户要求,否则不要打开 PR。
Recommended commit prefixes:
推荐提交前缀:
- `docs:` documentation changes
- `feat:` new user-visible functionality
- `fix:` bug fixes
- `test:` tests only
- `chore:` maintenance, tooling, or project setup
- `docs:` 文档变更
- `feat:` 新的用户可见功能
- `fix:` bug 修复
- `test:` 仅测试
- `chore:` 维护、工具或项目设置
Before committing:
提交前:
```bash
git status --short
@@ -292,57 +259,56 @@ git diff
git diff --check
```
Commit only files related to the current task. Do not revert unrelated user changes. Push only after the commit is verified and the user wants the branch updated remotely.
只提交与当前任务相关的文件。不要回滚无关的用户变更。只有在提交已验证且用户希望更新远端分支时才 push。
### Branch Strategy
### 分支与 Worktree 策略
For single-agent development: work directly on `main` unless the user specifies
a different branch.
工单开发默认不直接在主工作区或 `main` 上修改业务代码。开工前从当前 `main` 创建专属 git worktree,并在 worktree 中使用任务分支完成开发。
For multi-agent parallel development: each agent MUST operate on a separate Git
branch derived from the current `main`. Branch naming convention:
`agent/<agent-id>/<task-id>` (e.g., `agent/codex-1/T08`).
修 CI 配置(Dockerfile、Drone 流水线等)可以直接在主仓库改,因为 CI 改动要打 tag 才能触发构建。除该例外外,如需直接在主仓库改动,必须先得到用户明确确认。
Only the primary agent (or the user) may perform merges back to `main`.
Before merging, the branch must pass all tests defined in §Testing And Verification.
多代理并行开发:每个代理必须在从当前 `main` 派生的独立 Git 分支和独立 worktree 上工作。
分支命名约定:`agent/<agent-id>/<task-id>`(例如 `agent/codex-1/T08`)。
Never have two agents editing the same file on different branches simultaneously.
If task dependencies require touching the same file, serialize the work.
只有主代理(或用户)可以合并回 `main`
合并前,该分支必须通过 §Testing And Verification 中定义的所有测试。
## Safety Rules
绝不能让两个代理在不同分支上同时编辑同一个文件。
如果任务依赖要求触碰同一文件,请串行处理。
- Never store secrets in source files, docs, tests, fixtures, or commit messages.
- Use `.env.example` for variable names only.
- Keep raw external API responses only where the docs require them and avoid including private user data in fixtures.
- In test fixtures, replace real usernames, avatar URLs, user IDs, and IP addresses
with placeholder values (e.g., "test_user_001", "https://example.com/avatar.png",
"user_id_placeholder_001"). Do not copy production API responses directly into
fixture files without sanitization.
- The `raw_data` JSON field in the database may contain user-generated content.
When writing tests that assert on `raw_data`, use synthetic fixture data only.
- If credentials are exposed during the conversation, remind the user to rotate or delete them.
- Do not use destructive Git commands unless the user explicitly asks for them.
## 安全规则
## Completion Standard
- 绝不要把密钥保存在源文件、文档、测试、fixture 或提交消息中。
- `.env.example` 只用于变量名。
- 仅在文档要求时保留原始外部 API 响应,并避免在 fixture 中包含私有用户数据。
- 在测试 fixture 中,将真实用户名、头像 URL、用户 ID 和 IP 地址替换为占位值
(例如 "test_user_001"、"https://example.com/avatar.png"、
"user_id_placeholder_001")。不要未经脱敏就把生产 API 响应直接复制到 fixture 文件中。
- 数据库中的 `raw_data` JSON 字段可能包含用户生成内容。
编写断言 `raw_data` 的测试时,只使用合成 fixture 数据。
- 如果对话中暴露了凭据,提醒用户轮换或删除它们。
- 除非用户明确要求,否则不要使用破坏性 Git 命令。
A task is complete only when:
## 完成标准
1. The requested behavior or document exists.
2. Relevant tests or checks have been run, or the reason they could not be run is stated.
3. The work is scoped to the request.
4. The final response explains the result in plain language.
5. Any remaining risks or follow-up tasks are clearly named.
6. If the completed task corresponds to a checkbox in `docs/Tasks.md`, mark it
as done (change `- [ ]` to `- [x]`).
7. The final response MUST explicitly state which tests were run and passed,
which edge cases were verified or mocked, and any known limitations of the
current implementation.
只有满足以下条件,任务才算完成:
1. 请求的行为或文档已经存在。
2. 已运行相关测试或检查,或说明了无法运行的原因。
3. 工作范围限定在请求内。
4. 最终回复用通俗语言解释结果。
5. 清楚列出任何剩余风险或后续任务。
6. 如果完成的任务对应 `docs/Tasks.md` 中的复选框,将其标记为完成
(把 `- [ ]` 改为 `- [x]`)。
7. 最终回复必须明确说明哪些测试已运行并通过、哪些边界情况已验证或 mock,
以及当前实现的已知限制。
---
## Changelog
## 变更日志
| Date | Version | Changes |
| 日期 | 版本 | 变更 |
|---|---|---|
| 2025-07-10 | v1.0 | Initial version |
| 2025-07-10 | v1.1 | Post-review revision based on dual review merge (12 instructions): §Two-Day MVP Discipline rewritten as "MVP Discipline (Optimized for Speed)" -- no longer a cutting list, explicitly prohibits deferring P0 features without user confirmation; §Project Constraints gains three architecture constraints (httpx sync-only in background threads, pre-generated reports, single-task executor with 400 rejection); §Source Of Truth gains review-file inclusion, absolute-stop conflict resolution policy, anti-hallucination directive, and RequirementsDoc.md existence guard; §Development Workflow TDD instruction upgraded from "when practical" to mandatory-by-category with explicit TDD.md §2.1 citation; Error Handling Philosophy subsection added; §Testing And Verification gains coverage command and 80% target; §Git Workflow gains branch strategy with mandatory multi-agent branch isolation; §Multi-Agent Rules gains 4 construction-type parallel task examples; §Superpowers Workflow gains environment compatibility note and pre-verification cleanup rule; §Safety Rules gains raw_data sanitization guidance for fixtures; §Completion Standard gains Tasks.md checkbox sync requirement and explicit test-result reporting requirement. |
| 2025-07-10 | v1.0 | 初始版本 |
| 2025-07-10 | v1.1 | 基于双重评审合并后的修订(12 条指令):§Two-Day MVP Discipline 重写为 "MVP Discipline (Optimized for Speed)",不再是裁剪清单,并明确禁止未经用户确认延期 P0 功能;§Project Constraints 增加三项架构约束(后台线程中仅使用同步 httpx、预生成报告、单任务 executor 且冲突时返回 400);§Source Of Truth 增加评审文件纳入规则、冲突时绝对停止策略、防幻觉指令和 RequirementsDoc.md 存在性保护;§Development Workflow 中的 TDD 指令从 "when practical" 升级为按类别强制执行,并明确引用 TDD.md §2.1;新增 Error Handling Philosophy 小节;§Testing And Verification 增加覆盖率命令和 80% 目标;§Git Workflow 增加分支策略和强制多代理分支隔离;§Multi-Agent Rules 增加 4 个构建类并行任务示例;§Superpowers Workflow 增加环境兼容性说明和验证前清理规则;§Safety Rules 增加 raw_data fixture 脱敏指导;§Completion Standard 增加 Tasks.md 复选框同步要求和明确测试结果汇报要求。 |
| 2026-07-07 | v1.2 | 融入新的开发约定:工单默认使用独立 git worktree;处理工单前必须检查并使用适用的 Superpowers Skill;新功能先 brainstorming,缺陷先 systematic-debugging;完成后按 verification、code review、finishing branch 流程收尾。同步删除旧的直接在 main 上工作的单代理分支规则,避免与 worktree 约定冲突。 |
+5 -1
View File
@@ -5,6 +5,7 @@ from datetime import UTC, datetime
from sqlalchemy import Engine, create_engine, event
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from sqlalchemy.pool import NullPool
from app.config import get_settings
@@ -20,7 +21,10 @@ def create_sqlite_engine(database_url: str):
db_path.parent.mkdir(parents=True, exist_ok=True)
connect_args = {"check_same_thread": False, "timeout": 10}
engine = create_engine(database_url, connect_args=connect_args)
engine_kwargs = {"connect_args": connect_args}
if database_url.startswith("sqlite:///") and database_url != "sqlite:///:memory:":
engine_kwargs["poolclass"] = NullPool
engine = create_engine(database_url, **engine_kwargs)
engine.dialect.connect_args = connect_args
@event.listens_for(engine, "connect")
+26 -5
View File
@@ -94,15 +94,36 @@ class TikHubClient:
status_code=response.status_code,
)
if response.is_error:
raise PlatformAPIError(
f"External API returned HTTP {response.status_code}",
error_type="api_error",
status_code=response.status_code,
)
if attempt >= self.max_retries:
raise PlatformAPIError(
self._format_api_error(response),
error_type="api_error",
status_code=response.status_code,
)
time.sleep(2**attempt)
continue
return response.json()
raise PlatformAPIError("External API request failed", error_type="api_error", status_code=last_status)
def _format_api_error(self, response: httpx.Response) -> str:
message = f"External API returned HTTP {response.status_code}"
detail = self._response_error_detail(response)
return f"{message}: {detail}" if detail else message
def _response_error_detail(self, response: httpx.Response) -> str | None:
try:
payload = response.json()
except ValueError:
text = response.text.strip()
return text[:200] if text else None
if isinstance(payload, dict):
for key in ("message_zh", "message", "error", "detail"):
value = payload.get(key)
if value:
return str(value)[:200]
return None
def parse_timestamp(value: Any) -> datetime | None:
if value in (None, ""):
+4 -5
View File
@@ -3,6 +3,7 @@ import json
from concurrent.futures import ThreadPoolExecutor
from collections.abc import Callable
from datetime import UTC, datetime
from zoneinfo import ZoneInfo
from sqlalchemy import func, select
from sqlalchemy.orm import Session, sessionmaker
@@ -22,6 +23,7 @@ RUNNING_TASK_MESSAGE = "当前有正在运行的任务,请稍后再试"
RESTART_ERROR_MESSAGE = "系统重启,任务被中断"
STALE_PROGRESS_THRESHOLD_SECONDS = 10 * 60
STALE_PROGRESS_ERROR_TYPE = "stale_progress_timeout"
DISPLAY_TIMEZONE = ZoneInfo("Asia/Shanghai")
task_executor = ThreadPoolExecutor(max_workers=1)
@@ -183,10 +185,7 @@ def hydrate_task_progress(session: Session, task: Task) -> Task:
task.display_number = display_number
task.display_id = str(display_number)
task.created_at_label = format_datetime_minute(task.created_at)
task.scale_label = (
f"{PLATFORM_LABELS.get(task.platform, task.platform)} · "
f"{task.hotspot_limit}热点 × {task.item_limit_per_hotspot}内容 × {task.comment_limit_per_item}评论"
)
task.scale_label = f"{task.hotspot_limit}热点 × {task.item_limit_per_hotspot}内容 × {task.comment_limit_per_item}评论"
task.error_summary = build_task_error_summary(task)
task.hotspots_count = session.scalar(select(func.count(Hotspot.id)).where(Hotspot.task_id == task.id)) or 0
task.comments_count = session.scalar(select(func.count(Comment.id)).where(Comment.task_id == task.id)) or 0
@@ -230,7 +229,7 @@ def calculate_task_display_number(session: Session, task: Task) -> int:
def format_datetime_minute(value: datetime | None) -> str:
value = ensure_utc_datetime(value)
return value.strftime("%Y-%m-%d %H:%M") if value else ""
return value.astimezone(DISPLAY_TIMEZONE).strftime("%Y-%m-%d %H:%M") if value else ""
def build_task_error_summary(task: Task) -> str | None:
+1286 -65
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -5,14 +5,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}热榜评论分析工具{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="/static/app.css" rel="stylesheet">
<link href="/static/app.css?v=shadcn-ui-refresh" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="/static/app.js" defer></script>
</head>
<body>
<nav class="navbar navbar-expand-lg bg-body-tertiary border-bottom">
<nav class="navbar navbar-expand-lg app-navbar">
<div class="container">
<a class="navbar-brand" href="/">热榜评论分析工具</a>
<a class="navbar-brand fw-semibold" href="/">热榜评论雷达</a>
<div class="navbar-nav">
<a class="nav-link" href="/">任务列表</a>
</div>
+17 -5
View File
@@ -8,15 +8,27 @@
</ol></nav>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="h3 mb-0">{{ hotspot.title }} 汇总报告</h1>
</div>
{% if report %}
{% set metrics = report.metrics_json | from_json_object %}
{% set sentiment = metrics.get("sentiment", {}) %}
{% set positive_pct = sentiment.get("positive", {}).get("pct", 0) %}
<section class="report-reading-hero">
<div class="report-title-block">
<p class="section-kicker">HOTSPOT REPORT</p>
<h1>{{ hotspot.title }}</h1>
<p>{{ report.summary or "报告总结生成失败,请查看下方结构化统计。" }}</p>
</div>
<aside class="sentiment-summary-card">
<span>整体情绪</span>
<strong>正向 {{ positive_pct }}%</strong>
<p>基于 {{ metrics.get("sample_count", 0) }} 条评论样本生成,结论来自预生成报告数据。</p>
</aside>
</section>
{% include "partials/report_panel.html" %}
{% else %}
<div class="text-center text-muted py-5">
<div class="empty-state text-muted py-5">
<div class="spinner-border text-warning mb-3" role="status"></div>
<p>报告生成中,请稍候...</p>
<p class="mb-0">报告生成中,请稍候...</p>
</div>
{% endif %}
{% endblock %}
+93 -49
View File
@@ -1,79 +1,123 @@
{% extends "base.html" %}
{% set has_running_tasks = tasks | selectattr("status", "equalto", "running") | list | length > 0 %}
{% block title %}热榜评论雷达 - 热榜评论分析工具{% endblock %}
{% block breadcrumbs %}{% endblock %}
{% block content %}
<section class="hero-band mb-4">
<div class="hero-copy">
<p class="eyebrow">Hot Comment Radar</p>
<h1>热榜评论雷达</h1>
<p>从小红书和抖音热点出发,抓取真实评论,生成 AI 情绪、标签和可导出的分析报告。</p>
<section class="control-hero">
<div class="control-hero-copy">
<p class="section-kicker">COMMENT INTELLIGENCE</p>
<p class="control-subtitle">内容平台评论分析控制台</p>
<h1>用默认配置快速启动一次评论分析</h1>
<p>选择平台后直接开始抓取,系统会按 5 个热点、25 条内容、最多 1,250 条评论完成采集、AI 分析和报告生成。</p>
</div>
<div class="hero-actions">
<a class="btn btn-light" href="#create-task">创建新任务</a>
<a class="btn btn-outline-light" href="#task-history">查看 Demo 数据</a>
</div>
</section>
<section class="card mb-4" id="create-task">
<div class="card-header">创建抓取任务</div>
<div class="card-body">
<section class="quick-launch-panel" id="create-task" aria-label="快速创建抓取任务">
<div class="panel-title-row">
<div>
<p class="section-kicker">采集配置</p>
<h2>默认抓取方案</h2>
</div>
<span class="soft-badge soft-badge-success">推荐</span>
</div>
<div id="form-error" class="alert alert-danger d-none" role="alert"></div>
<form id="task-form" onsubmit="submitTask(event)">
<div class="row g-3 align-items-end">
<div class="col-md-3">
<div class="default-scale-grid" aria-label="默认任务规模">
<div class="scale-tile">
<strong>5</strong>
<span>热点</span>
</div>
<div class="scale-tile">
<strong>25</strong>
<span>内容</span>
</div>
<div class="scale-tile">
<strong id="scale-calc">1250</strong>
<span>评论上限</span>
</div>
</div>
<div class="quick-form-grid">
<div>
<label class="form-label" for="platform">平台</label>
<select class="form-select" id="platform" name="platform">
<option value="xiaohongshu">小红书</option>
<option value="douyin">抖音</option>
</select>
</div>
<div class="col-md-2">
<div>
<label class="form-label" for="hotspot_limit">热点数</label>
<input class="form-control scale-input" id="hotspot_limit" name="hotspot_limit" type="number" min="1" max="10" value="5">
</div>
<div class="col-md-2">
<div>
<label class="form-label" for="item_limit_per_hotspot">每热点内容</label>
<input class="form-control scale-input" id="item_limit_per_hotspot" name="item_limit_per_hotspot" type="number" min="1" max="10" value="5">
</div>
<div class="col-md-2">
<div>
<label class="form-label" for="comment_limit_per_item">每内容评论</label>
<input class="form-control scale-input" id="comment_limit_per_item" name="comment_limit_per_item" type="number" min="10" max="100" value="50">
</div>
<div class="col-md-3">
<button class="btn btn-primary w-100" id="submit-btn" type="submit">开始抓取</button>
</div>
</div>
<div class="form-text text-muted mt-2" id="scale-hint">
预计最多抓取:<strong id="scale-calc">1250</strong> 条评论(实际数量可能受平台返回数量、去重、失败、限流影响)
<button class="btn btn-primary quick-start-btn" id="submit-btn" type="submit">开始抓取</button>
</div>
<p class="scale-hint" id="scale-hint">实际数量可能受平台返回数量、去重、失败、限流影响。</p>
</form>
</section>
</section>
<section class="overview-grid" aria-label="任务指标概览">
<div class="overview-card">
<span>最近任务</span>
<strong>{{ tasks | length }}</strong>
<small>{% if has_running_tasks %}1 个任务运行中{% else %}暂无运行中任务{% endif %}</small>
</div>
<div class="overview-card">
<span>默认规模</span>
<strong>1,250</strong>
<small>5 热点 / 25 内容 / 评论上限</small>
</div>
<div class="overview-card">
<span>平台范围</span>
<strong>2</strong>
<small>小红书与抖音</small>
</div>
<div class="overview-card">
<span>任务策略</span>
<strong>1</strong>
<small>同一时间只运行一个任务</small>
</div>
</section>
<section class="card" id="task-history">
<div class="card-header d-flex justify-content-between align-items-center">
<span>最近任务与 Demo 数据</span>
<button class="btn btn-outline-secondary btn-sm" onclick="window.location.href='/'">手动刷新</button>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead>
<tr>
<th>任务 ID</th>
<th>平台</th>
<th>创建时间</th>
<th>配置规模</th>
<th>进度</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody id="task-table-body" {% if has_running_tasks %}data-task-list-auto-poll="true"{% endif %}>
{% include "partials/task_rows.html" %}
</tbody>
</table>
</div>
</section>
<div class="dashboard-workspace">
<section class="task-history-panel" id="task-history">
<div class="panel-title-row">
<div>
<p class="section-kicker">任务历史</p>
<h2>最近任务</h2>
</div>
<span class="panel-note">{% if has_running_tasks %}自动更新中{% else %}卡片化展示,减少表格压迫感 · 运行中任务自动刷新{% endif %}</span>
</div>
<div class="task-card-list" {% if has_running_tasks %}data-task-list-auto-poll="true"{% endif %}>
{% include "partials/task_cards.html" %}
</div>
</section>
<aside class="task-status-sidepanel">
<section class="sidepanel-card">
<div class="sidepanel-icon"></div>
<h2>运行中任务</h2>
<p>当前最多只允许一个任务运行,避免外部 API 限流和结果混淆。</p>
<div class="stage-list">
<span><i class="stage-dot stage-dot-success"></i>热点抓取</span>
<span><i class="stage-dot stage-dot-success"></i>内容采集</span>
<span><i class="stage-dot stage-dot-active"></i>评论分页</span>
<span><i class="stage-dot"></i>AI 分析</span>
</div>
</section>
<section class="sidepanel-card sidepanel-card-blue">
<div class="sidepanel-icon"></div>
<h2>看板重点回到动作</h2>
<p>首页围绕“开始抓取、看状态、进详情”组织信息,弱化表格压迫感。</p>
</section>
</aside>
</div>
{% if has_running_tasks %}
<script>document.addEventListener("DOMContentLoaded", () => pollTaskListStatus());</script>
{% endif %}
+10 -6
View File
@@ -9,21 +9,25 @@
</ol></nav>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="h3 mb-0">{{ item.title or item.source_item_id }}</h1>
<div class="page-heading">
<div>
<p class="section-kicker">内容详情</p>
<h1>{{ item.title or item.source_item_id }}</h1>
</div>
<span class="badge text-bg-light border">{{ item.status }}</span>
</div>
{% if report %}
{% include "partials/report_panel.html" %}
{% else %}
<div class="text-center text-muted py-5">
<div class="empty-state text-muted py-5">
<div class="spinner-border text-warning mb-3" role="status"></div>
<p>报告生成中,请稍候...</p>
<p class="mb-0">报告生成中,请稍候...</p>
</div>
{% endif %}
<section class="card"><div class="card-header">评论明细</div><div class="table-responsive">
<table class="table mb-0"><thead><tr><th>评论内容</th><th>情绪</th><th>标签</th><th>点赞</th></tr></thead><tbody>
<table class="table app-table mb-0"><thead><tr><th>评论内容</th><th>情绪</th><th>标签</th><th>点赞</th></tr></thead><tbody>
{% for comment in comments %}
<tr><td>{{ comment.content }}</td><td>{{ comment.sentiment }}</td><td>{% for label in comment.labels | from_json %}<span class="badge bg-secondary me-1">{{ label }}</span>{% else %}-{% endfor %}</td><td>{{ comment.like_count or 0 }}</td></tr>
<tr><td>{{ comment.content }}</td><td><span class="badge text-bg-light border">{{ comment.sentiment }}</span></td><td>{% for label in comment.labels | from_json %}<span class="badge text-bg-light border me-1">{{ label }}</span>{% else %}<span class="text-muted">-</span>{% endfor %}</td><td>{{ comment.like_count or 0 }}</td></tr>
{% else %}
<tr><td colspan="4" class="text-center text-muted">暂无评论数据(该内容无评论或评论抓取为空)</td></tr>
{% endfor %}
+98 -72
View File
@@ -4,88 +4,114 @@
{% set top_labels = metrics.get("top_labels", []) %}
{% set sample_count = metrics.get("sample_count", 0) %}
{% set item_count = metrics.get("item_count") %}
{% set positive = sentiment.get("positive", {"count": 0, "pct": 0}) %}
{% set neutral = sentiment.get("neutral", {"count": 0, "pct": 0}) %}
{% set negative = sentiment.get("negative", {"count": 0, "pct": 0}) %}
<section class="card mb-4">
<div class="card-body">
{% if task.analysis_status == "insufficient" %}
<div class="alert alert-warning">当前有效评论样本不足,AI 总结暂不可用。建议增加评论抓取数量后重新分析。</div>
{% endif %}
{% if task.analysis_status == "insufficient" %}
<div class="alert alert-warning">当前有效评论样本不足,AI 总结暂不可用。建议增加评论抓取数量后重新分析。</div>
{% endif %}
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="border rounded p-3 h-100">
<div class="text-muted small">评论样本</div>
<div class="h4 mb-0">{{ sample_count }}</div>
</div>
</div>
{% if item_count is not none %}
<div class="col-md-3">
<div class="border rounded p-3 h-100">
<div class="text-muted small">关联内容</div>
<div class="h4 mb-0">{{ item_count }}</div>
</div>
</div>
{% endif %}
<div class="col-md-3">
<div class="border rounded p-3 h-100">
<div class="text-muted small">AI 成功率</div>
<div class="h4 mb-0">{{ rate_percent(task.analysis_success_rate) }}%</div>
</div>
</div>
</div>
<div class="p-3 bg-info-subtle border border-info-subtle rounded mb-4">
<div class="fw-semibold mb-2">AI 总结</div>
<p class="mb-0">{{ report.summary or "总结生成失败,请查看上方统计数据。" }}</p>
</div>
<div class="row g-4">
<div class="col-lg-6">
<h2 class="h5">情绪分布</h2>
{% for key, bar_class in [("positive", "bg-success"), ("neutral", "bg-secondary"), ("negative", "bg-danger"), ("unknown", "bg-warning")] %}
{% set item = sentiment.get(key, {"count": 0, "pct": 0}) %}
<div class="d-flex justify-content-between mb-1">
<span>{{ sentiment_label(key) }}</span>
<span class="text-muted">{{ item.get("count", 0) }} 条({{ item.get("pct", 0) }}%</span>
</div>
<div class="progress mb-3 report-progress" role="progressbar" aria-label="{{ sentiment_label(key) }}情绪占比" aria-valuenow="{{ item.get("pct", 0) }}" aria-valuemin="0" aria-valuemax="100">
<div class="progress-bar {{ bar_class }}" style="width: {{ item.get("pct", 0) }}%"></div>
</div>
{% endfor %}
</div>
<div class="col-lg-6">
<h2 class="h5">Top 标签</h2>
<div class="report-reading-layout">
<section class="report-main-column">
<section class="insight-card">
<h2>核心结论</h2>
<div class="insight-list">
<p><span></span>{{ report.summary or "总结生成失败,请优先查看情绪分布、Top 标签和典型评论。" }}</p>
<p><span></span>评论样本 {{ sample_count }} 条{% if item_count is not none %},关联内容 {{ item_count }} 条{% endif %}AI 成功率 {{ rate_percent(task.analysis_success_rate) }}%。</p>
{% if top_labels %}
<div class="d-flex flex-wrap gap-2">
{% for label in top_labels %}
<span class="badge text-bg-light border">{{ label.get("name") }} ({{ label.get("count", 0) }})</span>
{% endfor %}
</div>
<p><span></span>当前高频讨论方向集中在{% for label in top_labels[:3] %}{{ "、" if not loop.first }}{{ label.get("name") }}{% endfor %}。</p>
{% else %}
<p class="text-muted mb-0">暂无标签数据</p>
<p><span></span>暂无高频标签数据,建议结合评论明细人工复核。</p>
{% endif %}
</div>
</div>
</div>
</section>
</section>
<section class="card mb-4">
<div class="card-header">典型评论</div>
<div class="card-body">
<div class="row g-3">
<section class="representative-comments">
<div class="panel-title-row">
<div>
<p class="section-kicker">COMMENTS</p>
<h2>代表评论 · 典型评论</h2>
</div>
<span class="panel-note">按情绪和点赞综合筛选</span>
</div>
{% for key in ["positive", "neutral", "negative"] %}
<div class="col-md-4">
<h3 class="h6">{{ sentiment_label(key) }}</h3>
<article class="quote-card">
<span class="soft-badge">{{ sentiment_label(key) }}</span>
{% for comment in typical.get(key, []) %}
<div class="border rounded p-2 mb-2">
<p class="mb-1">{{ comment.get("content") }}</p>
<small class="text-muted">点赞 {{ comment.get("like_count", 0) }}</small>
</div>
<p>{{ comment.get("content") }}</p>
<small>点赞 {{ comment.get("like_count", 0) }}</small>
{% else %}
<p class="text-muted small mb-0">暂无{{ sentiment_label(key) }}代表评论</p>
<p>暂无{{ sentiment_label(key) }}代表评论</p>
{% endfor %}
</article>
{% endfor %}
</section>
<section class="content-performance-card">
<h2>内容表现</h2>
<div class="performance-row">
<div>
<strong>评论样本</strong>
<span>用于本次报告统计的有效评论</span>
</div>
<em>{{ sample_count }} 条</em>
</div>
{% if item_count is not none %}
<div class="performance-row">
<div>
<strong>关联内容</strong>
<span>参与热点级聚合的内容条目</span>
</div>
<em>{{ item_count }} 条</em>
</div>
{% endif %}
<div class="performance-row">
<div>
<strong>高频标签</strong>
<span>用于定位主要讨论方向</span>
</div>
<em>{{ top_labels | length }} 个</em>
</div>
</section>
</section>
<aside class="report-side-column">
<section class="sentiment-sidepanel">
<h2>情绪分布</h2>
{% for key, item, class_name in [("positive", positive, "sentiment-positive"), ("neutral", neutral, "sentiment-neutral"), ("negative", negative, "sentiment-negative")] %}
<div class="sentiment-row">
<div class="progress-label-row">
<span>{{ sentiment_label(key) }}</span>
<span>{{ item.get("count", 0) }} 条({{ item.get("pct", 0) }}%</span>
</div>
<div class="progress report-progress" role="progressbar" aria-label="{{ sentiment_label(key) }}情绪占比" aria-valuenow="{{ item.get("pct", 0) }}" aria-valuemin="0" aria-valuemax="100">
<div class="progress-bar {{ class_name }}" style="width: {{ item.get("pct", 0) }}%"></div>
</div>
</div>
{% endfor %}
</div>
</div>
</section>
</section>
<section class="keyword-sidepanel">
<h2>Top 标签 / 关键词</h2>
{% if top_labels %}
<div class="keyword-cloud">
{% for label in top_labels %}
<span>{{ label.get("name") }} ({{ label.get("count", 0) }})</span>
{% endfor %}
</div>
{% else %}
<p class="text-muted mb-0">暂无标签数据</p>
{% endif %}
</section>
<section class="report-data-note">
<h2>同一份报告数据</h2>
<p>页面展示、Markdown 和 CSV 导出都应读取预生成报告,避免页面和文件结果不一致。</p>
<div class="ghost-actions" aria-label="报告数据说明">
<span>预生成报告</span>
<span>结构化统计</span>
</div>
</section>
</aside>
</div>
+44
View File
@@ -0,0 +1,44 @@
{% for task in tasks %}
{% set status_label, status_class = status_badge_config(task.status) %}
{% set percent = progress_percent(task.processed_items_count, task.total_items_count) %}
{% set ai_percent = rate_percent(task.analysis_success_rate) %}
<article class="task-card">
<div class="task-card-number">
<strong>{{ task.display_id or loop.index }}</strong>
<span>{{ task.platform | platform_label }}</span>
</div>
<div class="task-card-body">
<div class="task-card-title-row">
<div>
<h3>{{ status_label }}</h3>
<p>{{ task.scale_label }} · {{ task.created_at_label }}</p>
</div>
<span class="badge {{ status_class }}">{{ status_label }}</span>
</div>
<div class="task-card-progress">
<div class="progress-label-row">
<span>已处理 {{ task.processed_items_count }} / 共 {{ task.total_items_count }} 条内容</span>
<span>{{ percent }}%</span>
</div>
<div class="progress task-progress" role="progressbar" aria-label="任务进度" aria-valuenow="{{ percent }}" aria-valuemin="0" aria-valuemax="100">
<div class="progress-bar" style="width: {{ percent }}%"></div>
</div>
</div>
<div class="task-card-meta">
<span>成功 {{ task.successful_items_count }} / 失败 {{ task.failed_items_count }}</span>
<span>评论 {{ task.comments_count or 0 }}</span>
<span>报告 {{ task.reports_count or 0 }}</span>
<span>AI 成功率 {{ ai_percent }}%</span>
{% if task.analysis_status == "insufficient" %}
<span class="text-warning">AI 样本不足</span>
{% endif %}
</div>
{% if task.error_summary %}
<p class="task-card-error">{{ task.error_summary }}</p>
{% endif %}
</div>
<a class="btn btn-sm btn-outline-secondary task-card-action" href="/tasks/{{ task.id }}">查看</a>
</article>
{% else %}
<div class="empty-state text-muted py-5">还没有任何任务,请在上方创建第一个任务</div>
{% endfor %}
+10 -10
View File
@@ -4,14 +4,11 @@
{% set ai_percent = rate_percent(task.analysis_success_rate) %}
<tr>
<td>
<strong>{{ task.display_id or loop.index }}</strong>
{% if task.is_demo %}
<br><span class="badge text-bg-info">Demo 数据</span>
{% endif %}
<span class="task-number">{{ task.display_id or loop.index }}</span>
</td>
<td>{{ task.platform | platform_label }}</td>
<td>{{ task.created_at_label }}</td>
<td><small class="text-muted">{{ task.scale_label }}</small></td>
<td><span class="badge text-bg-light border">{{ task.platform | platform_label }}</span></td>
<td><span class="text-muted small">{{ task.created_at_label }}</span></td>
<td><span class="text-muted small">{{ task.scale_label }}</span></td>
<td class="task-progress-cell">
<div class="d-flex justify-content-between align-items-center gap-2">
<span class="small">已处理 {{ task.processed_items_count }} / 共 {{ task.total_items_count }} 条内容</span>
@@ -20,9 +17,12 @@
<div class="progress task-progress mt-1" role="progressbar" aria-label="任务进度" aria-valuenow="{{ percent }}" aria-valuemin="0" aria-valuemax="100">
<div class="progress-bar" style="width: {{ percent }}%"></div>
</div>
<div class="small text-muted mt-1">成功 {{ task.successful_items_count }} / 失败 {{ task.failed_items_count }}</div>
<div class="progress-summary mt-2">
<span class="small text-muted">成功 {{ task.successful_items_count }} / 失败 {{ task.failed_items_count }}</span>
<span class="small text-muted">评论 {{ task.comments_count or 0 }}</span>
<span class="small text-muted">报告 {{ task.reports_count or 0 }}</span>
</div>
<div class="small text-muted mt-1">阶段:{{ task.current_stage_label or "等待启动" }}</div>
<div class="small text-muted mt-1">评论 {{ task.comments_count or 0 }} / 报告 {{ task.reports_count or 0 }}</div>
<div class="small {% if task.analysis_status == 'insufficient' %}text-warning{% else %}text-muted{% endif %}">
AI 成功率 {{ ai_percent }}%
{% if task.analysis_status == "insufficient" %}
@@ -36,7 +36,7 @@
<br><small class="text-danger">{{ task.error_summary }}</small>
{% endif %}
</td>
<td><a class="btn btn-sm btn-outline-primary" href="/tasks/{{ task.id }}">查看</a></td>
<td><a class="btn btn-sm btn-outline-secondary" href="/tasks/{{ task.id }}">查看</a></td>
</tr>
{% else %}
<tr>
+92 -88
View File
@@ -7,101 +7,105 @@
</ol></nav>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="h3 mb-0">任务 {{ task.display_id }}</h1>
<button class="btn btn-outline-secondary btn-sm" onclick="window.location.reload()">手动刷新</button>
</div>
<section class="card mb-4" {% if task.status == "running" %}data-task-id="{{ task.id }}" data-auto-poll="true"{% endif %}><div class="card-body">
{% set label, cls = status_badge_config(task.status) %}
{% set percent = progress_percent(task.processed_items_count, task.total_items_count) %}
{% set ai_percent = rate_percent(task.analysis_success_rate) %}
{% set target_comments = task.hotspot_limit * task.item_limit_per_hotspot * task.comment_limit_per_item %}
<div class="d-flex flex-wrap justify-content-between gap-3 mb-3">
<p class="mb-0">平台:{{ task.platform | platform_label }} <span class="badge {{ cls }}">{{ label }}</span>{% if task.is_demo %}<span class="badge text-bg-info ms-1">Demo 数据</span>{% endif %}</p>
<p class="mb-0 text-muted">阶段:{{ task.current_stage_label or "等待启动" }}</p>
{% set label, cls = status_badge_config(task.status) %}
{% set percent = progress_percent(task.processed_items_count, task.total_items_count) %}
{% set ai_percent = rate_percent(task.analysis_success_rate) %}
{% set target_comments = task.hotspot_limit * task.item_limit_per_hotspot * task.comment_limit_per_item %}
<section class="task-detail-hero" {% if task.status == "running" %}data-task-id="{{ task.id }}" data-auto-poll="true"{% endif %}>
<div class="task-detail-copy">
<p class="section-kicker">TASK DETAIL</p>
<h1>{{ task.platform | platform_label }}热点评论采集任务 {{ task.display_id }}</h1>
<p>创建时间 {{ task.created_at_label }},当前阶段 {{ task.current_stage_label or "等待启动" }}。任务执行过程会隔离单条内容失败,并保留失败原因用于复查。</p>
</div>
<div class="small text-muted mb-3">创建时间 {{ task.created_at_label }} · 完整 ID {{ task.id }}</div>
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center gap-2">
<span>已处理 {{ task.processed_items_count }} / 共 {{ task.total_items_count }} 条内容</span>
<span class="text-muted">{{ percent }}%</span>
<aside class="task-state-card">
<span>当前状态</span>
<strong>{{ label }}</strong>
<p>已处理 {{ task.processed_items_count }} / 共 {{ task.total_items_count }} 条内容 · AI 成功率 {{ ai_percent }}% · 报告 {{ task.reports_count or 0 }} 份 · 失败内容 {{ task.failed_items_count }}</p>
</aside>
</section>
<section class="execution-timeline" aria-label="执行阶段">
{% for stage, desc, state in [
("创建任务", "参数校验完成", "done"),
("热点抓取", (task.hotspots_count or 0) ~ " 个热点入库", "done" if (task.hotspots_count or 0) > 0 or task.status == "success" else "pending"),
("内容采集", task.processed_items_count ~ " / " ~ task.total_items_count ~ " 条内容", "done" if task.processed_items_count >= task.total_items_count and task.total_items_count > 0 else "active"),
("评论分页", (task.comments_count or 0) ~ " 条评论", "done" if (task.comments_count or 0) > 0 else "pending"),
("AI 分析", "成功率 " ~ ai_percent ~ "%", "done" if ai_percent >= 80 else "active"),
("报告生成", (task.reports_count or 0) ~ " 份报告", "done" if (task.reports_count or 0) > 0 else "pending")
] %}
<div class="execution-step execution-step-{{ state }}">
<span class="execution-step-icon">{% if state == "done" %}✓{% elif state == "active" %}•{% else %}○{% endif %}</span>
<strong>{{ stage }}</strong>
<small>{{ desc }}</small>
</div>
<div class="progress task-progress mt-2" role="progressbar" aria-label="任务进度" aria-valuenow="{{ percent }}" aria-valuemin="0" aria-valuemax="100">
<div class="progress-bar" style="width: {{ percent }}%"></div>
</div>
<div class="small text-muted mt-2">成功 {{ task.successful_items_count }} / 失败 {{ task.failed_items_count }}</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-4">
<div class="metric-box">
<div class="text-muted small">目标上限</div>
<strong>{{ task.hotspot_limit }} 热点 × {{ task.item_limit_per_hotspot }} 内容 × {{ task.comment_limit_per_item }} 评论</strong>
<div class="text-muted small">最多 {{ target_comments }} 条评论</div>
</div>
</div>
<div class="col-md-4">
<div class="metric-box">
<div class="text-muted small">实际结果</div>
<strong>实际评论 {{ task.comments_count or 0 }}</strong>
<div class="text-muted small">已获取热点 {{ task.hotspots_count or 0 }} / 目标 {{ task.hotspot_limit }} · 报告 {{ task.reports_count or 0 }}</div>
</div>
</div>
<div class="col-md-4">
<div class="metric-box">
<div class="text-muted small">最近进度</div>
<strong>{{ task.last_progress_ago_label or "暂无记录" }}</strong>
<div class="text-muted small">运行时长 {{ task.running_duration_label or "刚刚" }} · 评论 {{ task.comments_count or 0 }}</div>
</div>
</div>
</div>
{% if task.status == "running" %}
<div class="alert alert-secondary">
小规模通常较快,默认规模可能需要数分钟,取决于 TikHub 与 AI 响应速度。
</div>
{% endif %}
{% if task.is_progress_stale %}
<div class="alert alert-warning">
超过 {{ task.stale_threshold_minutes }} 分钟没有进度更新,可能仍在等待外部接口或 AI 响应;如果长时间不恢复,请刷新状态或查看后端日志。
</div>
{% endif %}
{% if task.status == "success" and (task.comments_count or 0) < target_comments %}
<div class="alert alert-info">少于理论上限通常是内容本身评论不足或平台返回不足,不直接代表任务失败。若失败内容数大于 0,请结合失败原因判断。</div>
{% endif %}
<div class="mb-2">
<span class="{% if task.analysis_status == 'insufficient' %}text-warning{% else %}text-muted{% endif %}">AI 成功率 {{ ai_percent }}%</span>
{% if task.analysis_status == "insufficient" %}
<span class="badge text-bg-warning ms-1">AI 样本不足</span>
{% endif %}
</div>
{% if task.error_summary %}<div class="alert alert-danger">{{ task.error_summary }}</div>{% endif %}
</div></section>
{% endfor %}
</section>
{% if task.status == "running" and not hotspots %}
<div class="text-center text-muted py-5"><div class="spinner-border text-warning mb-3"></div><p>正在抓取热点数据,请稍候...</p></div>
<div class="empty-state text-muted py-5"><div class="spinner-border text-warning mb-3"></div><p class="mb-0">正在抓取热点数据,请稍候...</p></div>
{% endif %}
<div class="accordion" id="hotspot-list">
{% for hotspot in hotspots %}
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button {% if not loop.first %}collapsed{% endif %}" type="button" data-bs-toggle="collapse" data-bs-target="#hotspot-{{ hotspot.id }}">
热点 {{ hotspot.rank }}{{ hotspot.title }}
</button>
</h2>
<div id="hotspot-{{ hotspot.id }}" class="accordion-collapse collapse {% if loop.first %}show{% endif %}" data-bs-parent="#hotspot-list">
<div class="accordion-body">
<a class="btn btn-sm btn-outline-primary mb-2" href="/hotspots/{{ hotspot.id }}/report">查看热点级汇总报告</a>
<ul class="list-group">
{% for item in hotspot.content_items %}
<li class="list-group-item d-flex justify-content-between">
<span>{{ item.title or item.summary or item.source_item_id }} <small class="text-muted">{{ item.status }}</small></span>
<a class="btn btn-sm btn-outline-secondary" href="/items/{{ item.id }}">查看详情</a>
</li>
{% endfor %}
</ul>
<div class="detail-workspace">
<section class="hotspot-card-list">
<div class="panel-title-row">
<div>
<p class="section-kicker">执行阶段</p>
<h2>热点结果</h2>
</div>
<span class="panel-note">按热度与评论完成度查看</span>
</div>
</div>
{% endfor %}
{% for hotspot in hotspots %}
<article class="hotspot-result-card">
<div class="hotspot-rank">{{ "%02d"|format(hotspot.rank or loop.index) }}</div>
<div class="hotspot-result-body">
<h3>热点 {{ hotspot.rank or loop.index }}{{ hotspot.title }}</h3>
<div class="hotspot-chip-row">
<span>{{ hotspot.content_items | length }} 内容</span>
<span>{{ hotspot.comments | length }} 评论</span>
<span>{{ task.platform | platform_label }}</span>
</div>
</div>
<a class="btn btn-sm btn-primary" href="/hotspots/{{ hotspot.id }}/report">查看报告</a>
</article>
{% else %}
<div class="empty-state text-muted py-5">暂无热点结果</div>
{% endfor %}
</section>
<aside class="detail-sidepanel">
<section class="sidepanel-card">
<h2>采集规模</h2>
<div class="stat-line"><span>目标上限</span><strong>{{ task.hotspot_limit }} 热点 × {{ task.item_limit_per_hotspot }} 内容 × {{ task.comment_limit_per_item }} 评论</strong></div>
<div class="stat-line"><span>实际结果</span><strong>实际评论 {{ task.comments_count or 0 }}</strong></div>
<div class="stat-line"><span>热点完成度</span><strong>已获取热点 {{ task.hotspots_count or 0 }} / 目标 {{ task.hotspot_limit }}</strong></div>
<div class="stat-line"><span>热点数</span><strong>{{ task.hotspot_limit }}</strong></div>
<div class="stat-line"><span>内容数</span><strong>{{ task.total_items_count }}</strong></div>
<div class="stat-line"><span>评论数</span><strong>{{ task.comments_count or 0 }}</strong></div>
<div class="stat-line"><span>报告数</span><strong>{{ task.reports_count or 0 }}</strong></div>
<div class="stat-line"><span>运行时长</span><strong>{{ task.running_duration_label or "刚刚" }}</strong></div>
<div class="stat-line"><span>最近进度</span><strong>{{ task.last_progress_ago_label or "暂无记录" }}</strong></div>
</section>
<section class="sidepanel-card sidepanel-card-danger">
<h2>错误会被隔离</h2>
<p>单条内容或评论失败不会让整个任务崩溃,失败原因会保留在对应结果和报告数据中。</p>
{% if task.error_summary %}<p class="task-card-error">{{ task.error_summary }}</p>{% endif %}
</section>
</aside>
</div>
{% if task.is_progress_stale %}
<div class="alert alert-warning mt-3">
超过 {{ task.stale_threshold_minutes }} 分钟没有进度更新,可能仍在等待外部接口或 AI 响应;如果长时间不恢复,请刷新状态或查看后端日志。
</div>
{% endif %}
{% if task.status == "success" and (task.comments_count or 0) < target_comments %}
<div class="alert alert-info mt-3">少于理论上限通常是内容本身评论不足或平台返回不足,不直接代表任务失败。若失败内容数大于 0,请结合失败原因判断。</div>
{% endif %}
{% if task.analysis_status == "insufficient" %}
<div class="alert alert-warning mt-3">AI 成功率 {{ ai_percent }}%,当前样本可能不足,报告结论请结合评论明细判断。</div>
{% endif %}
{% if task.status == "running" %}
<script>document.addEventListener("DOMContentLoaded", () => pollTaskDetailStatus("{{ task.id }}"));</script>
{% endif %}
+112 -12
View File
@@ -18,10 +18,34 @@ def test_index_page_renders_task_form_empty_state_and_default_scale():
assert 'name="item_limit_per_hotspot"' in response.text
assert 'name="comment_limit_per_item"' in response.text
assert "1250" in response.text
assert 'onclick="window.location.href=\'/\'"' in response.text
assert "手动刷新" not in response.text
assert 'onclick="window.location.href=\'/\'"' not in response.text
assert 'href="#create-task"' not in response.text
assert 'href="#task-history"' not in response.text
assert "还没有任何任务" in response.text
def test_index_page_uses_pencil_control_console_layout_for_ui_polish():
with make_test_client() as (client, _engine):
response = client.get("/")
assert response.status_code == 200
soup = BeautifulSoup(response.text, "html.parser")
visible_text = soup.get_text(" ")
hero = soup.select_one(".control-hero")
assert hero is not None
assert "用默认配置快速启动一次评论分析" in visible_text
assert "内容平台评论分析控制台" in visible_text
assert soup.select_one(".quick-launch-panel") is not None
assert soup.select_one(".default-scale-grid") is not None
assert soup.select_one(".overview-grid") is not None
assert soup.select_one(".task-card-list") is not None
assert soup.select_one(".task-status-sidepanel") is not None
assert soup.select_one("table.app-table.data-grid-table") is None
assert "卡片化展示,减少表格压迫感" in visible_text
assert "运行中任务自动刷新" in visible_text
def test_index_page_lists_existing_tasks():
with make_test_client() as (client, engine):
from sqlalchemy.orm import Session
@@ -104,10 +128,13 @@ def test_index_page_uses_short_task_numbers_compact_fields_and_friendly_error_co
assert "#" not in visible_text
assert first_id not in visible_text
assert second_id not in visible_text
assert "2026-07-03 10:30" in visible_text
assert "2026-07-03 10:35" in visible_text
assert "小红书 · 5热点 × 5内容 × 50评论" in visible_text
assert "抖音 · 1热点 × 1内容 × 10评论" in visible_text
assert "2026-07-03 18:30" in visible_text
assert "2026-07-03 18:35" in visible_text
assert "Demo 数据" not in visible_text
assert "5热点 × 5内容 × 50评论" in visible_text
assert "1热点 × 1内容 × 10评论" in visible_text
assert "小红书 · 5热点" not in visible_text
assert "抖音 · 1热点" not in visible_text
assert "系统重启,任务被中断" in visible_text
assert "system / unexpected_restart" not in visible_text
assert "unexpected_restart" not in visible_text
@@ -167,12 +194,13 @@ def test_running_task_detail_page_auto_polls_current_task():
assert response.status_code == 200
assert 'data-task-id="task-running"' in response.text
assert "pollTaskDetailStatus" in response.text
assert 'onclick="window.location.reload()"' in response.text
assert "手动刷新" not in response.text
assert 'onclick="window.location.reload()"' not in response.text
assert "已处理 1 / 共 2 条内容" in response.text
assert "AI 成功率 100%" in response.text
def test_task_detail_uses_short_number_title_and_keeps_full_uuid_for_diagnostics():
def test_task_detail_uses_short_number_title_and_hides_full_uuid():
task_id = "33333333-3333-4333-8333-333333333333"
with make_test_client() as (client, engine):
from sqlalchemy.orm import Session
@@ -197,11 +225,42 @@ def test_task_detail_uses_short_number_title_and_keeps_full_uuid_for_diagnostics
visible_text = BeautifulSoup(response.text, "html.parser").get_text(" ")
assert "<title>任务 1 - 热榜评论分析工具</title>" in response.text
assert "任务 1" in visible_text
assert "完整 ID" in visible_text
assert task_id in visible_text
assert "完整 ID" not in visible_text
assert task_id not in visible_text
assert "任务 #33333333-3333-4333-8333-333333333333" not in visible_text
assert "#" not in visible_text
assert "创建时间 2026-07-03 10:30" in visible_text
assert "创建时间 2026-07-03 18:30" in visible_text
assert "Demo 数据" not in visible_text
def test_demo_task_flag_is_kept_but_demo_copy_is_hidden_from_pages():
with make_test_client() as (client, engine):
from sqlalchemy.orm import Session
with Session(engine) as session:
session.add(
Task(
id="demo-task-hidden-copy",
platform="xiaohongshu",
status="success",
created_at=datetime(2026, 7, 3, 10, 30, tzinfo=UTC),
hotspot_limit=1,
item_limit_per_hotspot=1,
comment_limit_per_item=10,
)
)
session.commit()
index_response = client.get("/")
detail_response = client.get("/tasks/demo-task-hidden-copy")
api_response = client.get("/api/tasks/demo-task-hidden-copy")
assert index_response.status_code == 200
assert detail_response.status_code == 200
assert api_response.status_code == 200
assert api_response.json()["is_demo"] is True
assert "Demo 数据" not in BeautifulSoup(index_response.text, "html.parser").get_text(" ")
assert "Demo 数据" not in BeautifulSoup(detail_response.text, "html.parser").get_text(" ")
def test_running_task_detail_page_keeps_polling_after_hotspots_exist():
@@ -296,7 +355,7 @@ def test_running_task_detail_page_shows_stage_runtime_and_stale_progress_warning
response = client.get("/tasks/task-stale")
assert response.status_code == 200
assert "阶段AI 分析中" in response.text
assert "阶段 AI 分析中" in response.text
assert "运行时长" in response.text
assert "2小时5分钟" in response.text
assert "最近进度" in response.text
@@ -419,7 +478,8 @@ def test_result_pages_render_seeded_data():
assert task_response.status_code == 200
assert "热点标题" in task_response.text
assert "热点 1:热点标题" in BeautifulSoup(task_response.text, "html.parser").get_text(" ")
assert 'onclick="window.location.reload()"' in task_response.text
assert 'onclick="window.location.reload()"' not in task_response.text
assert "手动刷新" not in task_response.text
assert hotspot_response.status_code == 200
assert "热点总结" in hotspot_response.text
assert "downloadExport(" not in hotspot_response.text
@@ -664,6 +724,46 @@ def test_task_detail_page_explains_target_and_actual_counts():
assert "少于理论上限通常是内容本身评论不足或平台返回不足" in response.text
def test_task_detail_page_uses_pencil_execution_view_layout():
with make_test_client() as (client, engine):
seed_result_data(engine)
response = client.get("/tasks/task-result")
assert response.status_code == 200
soup = BeautifulSoup(response.text, "html.parser")
visible_text = soup.get_text(" ")
assert soup.select_one(".task-detail-hero") is not None
assert soup.select_one(".execution-timeline") is not None
assert soup.select_one(".hotspot-card-list") is not None
assert soup.select_one(".detail-sidepanel") is not None
assert "TASK DETAIL" in visible_text
assert "执行阶段" in visible_text
assert "热点结果" in visible_text
assert "错误会被隔离" in visible_text
assert "查看报告" in visible_text
def test_hotspot_report_page_uses_pencil_analysis_reading_layout():
with make_test_client() as (client, engine):
seed_result_data(engine)
response = client.get("/hotspots/hot-result/report")
assert response.status_code == 200
soup = BeautifulSoup(response.text, "html.parser")
visible_text = soup.get_text(" ")
assert soup.select_one(".report-reading-hero") is not None
assert soup.select_one(".report-reading-layout") is not None
assert soup.select_one(".insight-card") is not None
assert soup.select_one(".sentiment-sidepanel") is not None
assert "HOTSPOT REPORT" in visible_text
assert "核心结论" in visible_text
assert "代表评论" in visible_text
assert "内容表现" in visible_text
assert "同一份报告数据" in visible_text
def test_task_api_includes_runtime_and_stale_progress_fields(monkeypatch):
now = datetime(2026, 7, 3, 10, 30, tzinfo=UTC)
monkeypatch.setattr("app.services.task_service.utc_now", lambda: now)
+55
View File
@@ -53,6 +53,61 @@ def test_tikhub_client_raises_structured_error_after_retries(monkeypatch):
assert sleeps == [1, 2, 4]
def test_tikhub_client_retries_transient_api_error(monkeypatch):
sleeps = []
monkeypatch.setattr("app.platforms.base.time.sleep", sleeps.append)
transport = SequenceTransport(
[
httpx.Response(400, json={"message_zh": "临时请求失败"}),
httpx.Response(200, json={"ok": True}),
]
)
http_client = httpx.Client(transport=httpx.MockTransport(transport))
client = TikHubClient(base_url="https://api.test", api_key="secret-token", http_client=http_client)
result = client.get("/demo")
assert result == {"ok": True}
assert sleeps == [1]
assert len(transport.requests) == 2
def test_tikhub_client_includes_response_summary_after_api_error_retries(monkeypatch):
sleeps = []
monkeypatch.setattr("app.platforms.base.time.sleep", sleeps.append)
transport = SequenceTransport(
[httpx.Response(400, json={"message_zh": "笔记评论暂不可用"}) for _ in range(4)]
)
http_client = httpx.Client(transport=httpx.MockTransport(transport))
client = TikHubClient(base_url="https://api.test", api_key="secret-token", http_client=http_client)
with pytest.raises(PlatformAPIError) as exc_info:
client.get("/demo")
assert exc_info.value.error_type == "api_error"
assert exc_info.value.status_code == 400
assert "External API returned HTTP 400" in str(exc_info.value)
assert "笔记评论暂不可用" in str(exc_info.value)
assert "secret-token" not in str(exc_info.value)
assert sleeps == [1, 2, 4]
def test_tikhub_client_includes_text_response_summary_after_api_error_retries(monkeypatch):
sleeps = []
monkeypatch.setattr("app.platforms.base.time.sleep", sleeps.append)
transport = SequenceTransport([httpx.Response(502, text="upstream temporary failure") for _ in range(4)])
http_client = httpx.Client(transport=httpx.MockTransport(transport))
client = TikHubClient(base_url="https://api.test", api_key="secret-token", http_client=http_client)
with pytest.raises(PlatformAPIError) as exc_info:
client.get("/demo")
assert exc_info.value.error_type == "api_error"
assert exc_info.value.status_code == 502
assert str(exc_info.value) == "External API returned HTTP 502: upstream temporary failure"
assert sleeps == [1, 2, 4]
def test_tikhub_client_reports_401_as_auth_error_without_leaking_token():
transport = SequenceTransport([httpx.Response(401, json={"message": "Unauthorized"})])
http_client = httpx.Client(transport=httpx.MockTransport(transport))
+9
View File
@@ -1,4 +1,5 @@
import pytest
from sqlalchemy.pool import NullPool
from app.db import check_database_integrity, checkpoint_sqlite_wal, create_sqlite_engine, ensure_sqlite_schema_compat
@@ -11,6 +12,14 @@ def test_check_database_integrity_returns_ok_for_valid_sqlite_database():
engine.dispose()
def test_file_sqlite_engine_does_not_reuse_connections_after_operational_errors(tmp_path):
engine = create_sqlite_engine(f"sqlite:///{tmp_path / 'app.db'}")
try:
assert isinstance(engine.pool, NullPool)
finally:
engine.dispose()
def test_check_database_integrity_raises_when_sqlite_reports_problem(monkeypatch):
class FakeCursor:
def execute(self, _sql):