initial: blog-app snapshot
This commit is contained in:
23
.env.example
Normal file
23
.env.example
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# blog-app environment variables (Stage 3)
|
||||||
|
# Copy this file to .env or export the variables you want to override.
|
||||||
|
# All variables are OPTIONAL — config.py has defaults for every entry below.
|
||||||
|
# Format: VAR=default_value # 用途 / 单位 / 范围
|
||||||
|
|
||||||
|
# --- Flask ---
|
||||||
|
FLASK_SECRET_KEY=dev-watchdog-secret-change-me # Flask session/CSRF secret. 生产必须改为随机长字符串 (>=32 bytes entropy).
|
||||||
|
|
||||||
|
# --- Database paths ---
|
||||||
|
BLOG_APP_DB_PATH=~/opencode-blog-showcase/data/projects.db # 主数据库 (projects/releases/issues). 绝对路径.
|
||||||
|
BLOG_APP_WATCHDOG_DB=~/opencode-blog-showcase/data/watchdog.db # 看门狗状态 DB. 绝对路径.
|
||||||
|
|
||||||
|
# --- LLM backend ---
|
||||||
|
BLOG_APP_LLM_URL=http://127.0.0.1:4000/v1 # OpenAI-compatible base URL. 走 litellm-proxy 本地网关.
|
||||||
|
BLOG_APP_LLM_MODEL=default # 模型名, 透传给 LLM_URL.
|
||||||
|
BLOG_APP_LLM_TIMEOUT=300 # LLM 单次请求超时. 单位: 秒. 范围 30-600.
|
||||||
|
|
||||||
|
# --- Rate limiter (in-memory, per-process) ---
|
||||||
|
BLOG_APP_RATE_LIMIT_MAX=3 # /api/analyze/stream 每 IP 窗口内最大请求数. 单位: 次. 范围 1-100.
|
||||||
|
BLOG_APP_RATE_LIMIT_WINDOW=60 # 限流窗口长度. 单位: 秒. 范围 10-3600.
|
||||||
|
|
||||||
|
# --- Watchdog ---
|
||||||
|
BLOG_APP_WATCHDOG_COOLDOWN=60 # /watchdog/run 手动触发后的冷却时间. 单位: 秒. 范围 0-3600.
|
||||||
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
*.pid
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
node_modules/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
10
.omo/run-continuation/ses_ff159fc18ffeJxu6s7OAjWoooM.json
Normal file
10
.omo/run-continuation/ses_ff159fc18ffeJxu6s7OAjWoooM.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff159fc18ffeJxu6s7OAjWoooM",
|
||||||
|
"updatedAt": "2026-08-17T07:44:31.477Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-17T07:44:31.477Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff159fc23ffeDE29Ddwbuq5hVe.json
Normal file
10
.omo/run-continuation/ses_ff159fc23ffeDE29Ddwbuq5hVe.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff159fc23ffeDE29Ddwbuq5hVe",
|
||||||
|
"updatedAt": "2026-08-17T07:39:35.211Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-17T07:39:35.211Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff166dd6cffe3CK3f9VKnYH6ZL.json
Normal file
10
.omo/run-continuation/ses_ff166dd6cffe3CK3f9VKnYH6ZL.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff166dd6cffe3CK3f9VKnYH6ZL",
|
||||||
|
"updatedAt": "2026-08-17T07:26:59.412Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-17T07:26:59.412Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff18f8ad5ffepRPGaLP85ye130.json
Normal file
10
.omo/run-continuation/ses_ff18f8ad5ffepRPGaLP85ye130.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff18f8ad5ffepRPGaLP85ye130",
|
||||||
|
"updatedAt": "2026-08-17T06:49:55.284Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-17T06:49:55.284Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff1a61338ffeNFb48EUB8uOSRj.json
Normal file
10
.omo/run-continuation/ses_ff1a61338ffeNFb48EUB8uOSRj.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff1a61338ffeNFb48EUB8uOSRj",
|
||||||
|
"updatedAt": "2026-08-17T06:21:36.159Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-17T06:21:36.159Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff1d5e7acffeoP8eedBYLMiuqB.json
Normal file
10
.omo/run-continuation/ses_ff1d5e7acffeoP8eedBYLMiuqB.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff1d5e7acffeoP8eedBYLMiuqB",
|
||||||
|
"updatedAt": "2026-08-17T05:29:45.833Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-17T05:29:45.833Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff1d60adbffevryaIPH2sKDd30.json
Normal file
10
.omo/run-continuation/ses_ff1d60adbffevryaIPH2sKDd30.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff1d60adbffevryaIPH2sKDd30",
|
||||||
|
"updatedAt": "2026-08-17T05:30:30.053Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-17T05:30:30.053Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff1e6c1bfffenLybPu302e8w5Y.json
Normal file
10
.omo/run-continuation/ses_ff1e6c1bfffenLybPu302e8w5Y.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff1e6c1bfffenLybPu302e8w5Y",
|
||||||
|
"updatedAt": "2026-08-17T05:23:13.190Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-17T05:23:13.190Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff2186cd4ffedidMUrQ3K284Gv.json
Normal file
10
.omo/run-continuation/ses_ff2186cd4ffedidMUrQ3K284Gv.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff2186cd4ffedidMUrQ3K284Gv",
|
||||||
|
"updatedAt": "2026-08-17T04:12:50.536Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-17T04:12:50.536Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff22f46c8ffesshvbZ1v4tDv8i.json
Normal file
10
.omo/run-continuation/ses_ff22f46c8ffesshvbZ1v4tDv8i.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff22f46c8ffesshvbZ1v4tDv8i",
|
||||||
|
"updatedAt": "2026-08-17T03:50:13.111Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-17T03:50:13.111Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff25cf81affePBvZb9QE1MLOZb.json
Normal file
10
.omo/run-continuation/ses_ff25cf81affePBvZb9QE1MLOZb.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff25cf81affePBvZb9QE1MLOZb",
|
||||||
|
"updatedAt": "2026-08-17T02:54:09.622Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-17T02:54:09.622Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff260f6b4ffeuQioO3jpjEA3LD.json
Normal file
10
.omo/run-continuation/ses_ff260f6b4ffeuQioO3jpjEA3LD.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff260f6b4ffeuQioO3jpjEA3LD",
|
||||||
|
"updatedAt": "2026-08-17T02:49:29.819Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-17T02:49:29.819Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff27081cfffe0zNjSG2xQQxXHZ.json
Normal file
10
.omo/run-continuation/ses_ff27081cfffe0zNjSG2xQQxXHZ.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff27081cfffe0zNjSG2xQQxXHZ",
|
||||||
|
"updatedAt": "2026-08-17T02:51:26.049Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-17T02:51:26.049Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff27402a0fferS1XNbdKMAHj0i.json
Normal file
10
.omo/run-continuation/ses_ff27402a0fferS1XNbdKMAHj0i.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff27402a0fferS1XNbdKMAHj0i",
|
||||||
|
"updatedAt": "2026-08-17T02:56:24.982Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-17T02:56:24.982Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff50b132cffecxl7mkCh04JsML.json
Normal file
10
.omo/run-continuation/ses_ff50b132cffecxl7mkCh04JsML.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff50b132cffecxl7mkCh04JsML",
|
||||||
|
"updatedAt": "2026-08-16T14:26:54.348Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-16T14:26:54.348Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff50cdb51ffetEA2ioyGjzaT1D.json
Normal file
10
.omo/run-continuation/ses_ff50cdb51ffetEA2ioyGjzaT1D.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff50cdb51ffetEA2ioyGjzaT1D",
|
||||||
|
"updatedAt": "2026-08-16T14:27:04.432Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-16T14:27:04.432Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff50e2ce5ffef6Ao7h7KUyF2FU.json
Normal file
10
.omo/run-continuation/ses_ff50e2ce5ffef6Ao7h7KUyF2FU.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff50e2ce5ffef6Ao7h7KUyF2FU",
|
||||||
|
"updatedAt": "2026-08-16T14:27:17.738Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-16T14:27:17.738Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff510ac90ffe9a3BQKu1yMPynA.json
Normal file
10
.omo/run-continuation/ses_ff510ac90ffe9a3BQKu1yMPynA.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff510ac90ffe9a3BQKu1yMPynA",
|
||||||
|
"updatedAt": "2026-08-16T14:29:47.267Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-16T14:29:47.267Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff511f1e9ffeaIl1AovLKJluHW.json
Normal file
10
.omo/run-continuation/ses_ff511f1e9ffeaIl1AovLKJluHW.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff511f1e9ffeaIl1AovLKJluHW",
|
||||||
|
"updatedAt": "2026-08-16T14:30:17.060Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-16T14:30:17.060Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff589f9aaffeRAusdEoDgca2cl.json
Normal file
10
.omo/run-continuation/ses_ff589f9aaffeRAusdEoDgca2cl.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff589f9aaffeRAusdEoDgca2cl",
|
||||||
|
"updatedAt": "2026-08-16T12:18:57.152Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-16T12:18:57.152Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff59780d6ffe16Jf15dqV6Rfm0.json
Normal file
10
.omo/run-continuation/ses_ff59780d6ffe16Jf15dqV6Rfm0.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff59780d6ffe16Jf15dqV6Rfm0",
|
||||||
|
"updatedAt": "2026-08-16T11:50:36.626Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-16T11:50:36.626Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff597c296ffebbSiGIkHgH6PjF.json
Normal file
10
.omo/run-continuation/ses_ff597c296ffebbSiGIkHgH6PjF.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff597c296ffebbSiGIkHgH6PjF",
|
||||||
|
"updatedAt": "2026-08-16T11:50:36.630Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-16T11:50:36.630Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff5981104ffeqKEgnR336yVg2k.json
Normal file
10
.omo/run-continuation/ses_ff5981104ffeqKEgnR336yVg2k.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff5981104ffeqKEgnR336yVg2k",
|
||||||
|
"updatedAt": "2026-08-16T11:50:36.631Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-16T11:50:36.631Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff5998da7ffev009eRDsRGBQVR.json
Normal file
10
.omo/run-continuation/ses_ff5998da7ffev009eRDsRGBQVR.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff5998da7ffev009eRDsRGBQVR",
|
||||||
|
"updatedAt": "2026-08-16T11:59:24.138Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-16T11:59:24.138Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff5abb74bffez58Aw12C0K34nU.json
Normal file
10
.omo/run-continuation/ses_ff5abb74bffez58Aw12C0K34nU.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff5abb74bffez58Aw12C0K34nU",
|
||||||
|
"updatedAt": "2026-08-16T11:30:09.848Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-16T11:30:09.848Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff5bad526ffew7mnXjFkk9NbCh.json
Normal file
10
.omo/run-continuation/ses_ff5bad526ffew7mnXjFkk9NbCh.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff5bad526ffew7mnXjFkk9NbCh",
|
||||||
|
"updatedAt": "2026-08-16T11:13:20.829Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-16T11:13:20.829Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff5bb071effeBAZIXtcU9ACju0.json
Normal file
10
.omo/run-continuation/ses_ff5bb071effeBAZIXtcU9ACju0.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff5bb071effeBAZIXtcU9ACju0",
|
||||||
|
"updatedAt": "2026-08-16T11:13:05.852Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-16T11:13:05.852Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff5bc5037ffeBpV13WNB6iZSJt.json
Normal file
10
.omo/run-continuation/ses_ff5bc5037ffeBpV13WNB6iZSJt.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff5bc5037ffeBpV13WNB6iZSJt",
|
||||||
|
"updatedAt": "2026-08-16T11:15:26.263Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-16T11:15:26.263Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff5c68949ffeBXXChzUCaqm1z1.json
Normal file
10
.omo/run-continuation/ses_ff5c68949ffeBXXChzUCaqm1z1.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff5c68949ffeBXXChzUCaqm1z1",
|
||||||
|
"updatedAt": "2026-08-16T11:02:12.526Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-16T11:02:12.526Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff61a2bb2ffeLhEbxZ4beb2Sqj.json
Normal file
10
.omo/run-continuation/ses_ff61a2bb2ffeLhEbxZ4beb2Sqj.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff61a2bb2ffeLhEbxZ4beb2Sqj",
|
||||||
|
"updatedAt": "2026-08-16T10:16:48.685Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-16T10:16:48.685Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff6302698ffemXaFsAlc5ztUUr.json
Normal file
10
.omo/run-continuation/ses_ff6302698ffemXaFsAlc5ztUUr.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff6302698ffemXaFsAlc5ztUUr",
|
||||||
|
"updatedAt": "2026-08-16T09:26:21.313Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-16T09:26:21.313Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff644c57effeO8mDJ4bJuvxUgS.json
Normal file
10
.omo/run-continuation/ses_ff644c57effeO8mDJ4bJuvxUgS.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff644c57effeO8mDJ4bJuvxUgS",
|
||||||
|
"updatedAt": "2026-08-16T09:44:37.086Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-16T09:44:37.086Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff6458a01ffeTUrBLI8721QKU9.json
Normal file
10
.omo/run-continuation/ses_ff6458a01ffeTUrBLI8721QKU9.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff6458a01ffeTUrBLI8721QKU9",
|
||||||
|
"updatedAt": "2026-08-16T08:40:17.021Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-16T08:40:17.021Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff64cb17cffe28Fja2X574xRtU.json
Normal file
10
.omo/run-continuation/ses_ff64cb17cffe28Fja2X574xRtU.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff64cb17cffe28Fja2X574xRtU",
|
||||||
|
"updatedAt": "2026-08-16T08:32:22.146Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-16T08:32:22.146Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff64d3e89ffeUJMQc1wzqAiMb2.json
Normal file
10
.omo/run-continuation/ses_ff64d3e89ffeUJMQc1wzqAiMb2.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff64d3e89ffeUJMQc1wzqAiMb2",
|
||||||
|
"updatedAt": "2026-08-16T08:31:52.071Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-16T08:31:52.071Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff652a22dffeEhR8SjP02SJSOg.json
Normal file
10
.omo/run-continuation/ses_ff652a22dffeEhR8SjP02SJSOg.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff652a22dffeEhR8SjP02SJSOg",
|
||||||
|
"updatedAt": "2026-08-16T08:25:52.845Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-16T08:25:52.845Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff655d995ffebKTh58H5jjv0j4.json
Normal file
10
.omo/run-continuation/ses_ff655d995ffebKTh58H5jjv0j4.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff655d995ffebKTh58H5jjv0j4",
|
||||||
|
"updatedAt": "2026-08-16T08:22:28.093Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-16T08:22:28.093Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff65e0161ffeUzxy8A88yk31Oe.json
Normal file
10
.omo/run-continuation/ses_ff65e0161ffeUzxy8A88yk31Oe.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff65e0161ffeUzxy8A88yk31Oe",
|
||||||
|
"updatedAt": "2026-08-16T08:20:15.869Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-16T08:20:15.869Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff65ed810ffeN451M0eZm5EIs1.json
Normal file
10
.omo/run-continuation/ses_ff65ed810ffeN451M0eZm5EIs1.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff65ed810ffeN451M0eZm5EIs1",
|
||||||
|
"updatedAt": "2026-08-16T08:12:38.645Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-16T08:12:38.645Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff6647cb2ffek28hRnQ5Vw654p.json
Normal file
10
.omo/run-continuation/ses_ff6647cb2ffek28hRnQ5Vw654p.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff6647cb2ffek28hRnQ5Vw654p",
|
||||||
|
"updatedAt": "2026-08-16T08:06:28.824Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-16T08:06:28.824Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff6693b2affeHMlQy10nXYGrfS.json
Normal file
10
.omo/run-continuation/ses_ff6693b2affeHMlQy10nXYGrfS.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff6693b2affeHMlQy10nXYGrfS",
|
||||||
|
"updatedAt": "2026-08-16T08:01:11.900Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-16T08:01:11.900Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff66c6bd5ffe7btKkdHNFluI4N.json
Normal file
10
.omo/run-continuation/ses_ff66c6bd5ffe7btKkdHNFluI4N.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff66c6bd5ffe7btKkdHNFluI4N",
|
||||||
|
"updatedAt": "2026-08-16T07:57:48.896Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-16T07:57:48.896Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff676a4ebffeOyZpgWVMbyq29U.json
Normal file
10
.omo/run-continuation/ses_ff676a4ebffeOyZpgWVMbyq29U.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff676a4ebffeOyZpgWVMbyq29U",
|
||||||
|
"updatedAt": "2026-08-16T09:02:44.380Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-16T09:02:44.380Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff67734c1ffewTdQMDzv6a8QUe.json
Normal file
10
.omo/run-continuation/ses_ff67734c1ffewTdQMDzv6a8QUe.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff67734c1ffewTdQMDzv6a8QUe",
|
||||||
|
"updatedAt": "2026-08-16T07:46:02.064Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-16T07:46:02.064Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff6776755ffe7PgHvixK0uqeiF.json
Normal file
10
.omo/run-continuation/ses_ff6776755ffe7PgHvixK0uqeiF.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff6776755ffe7PgHvixK0uqeiF",
|
||||||
|
"updatedAt": "2026-08-16T09:19:55.042Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-16T09:19:55.042Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff67d3113ffecv1ckGBjqU3jcJ.json
Normal file
10
.omo/run-continuation/ses_ff67d3113ffecv1ckGBjqU3jcJ.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff67d3113ffecv1ckGBjqU3jcJ",
|
||||||
|
"updatedAt": "2026-08-16T10:17:36.533Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-16T10:17:36.533Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff68e9c70ffeLXKLDJX189CPei.json
Normal file
10
.omo/run-continuation/ses_ff68e9c70ffeLXKLDJX189CPei.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff68e9c70ffeLXKLDJX189CPei",
|
||||||
|
"updatedAt": "2026-08-16T07:56:05.172Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-16T07:56:05.172Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff68f78a6ffeK6ibJQDIBWh290.json
Normal file
10
.omo/run-continuation/ses_ff68f78a6ffeK6ibJQDIBWh290.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff68f78a6ffeK6ibJQDIBWh290",
|
||||||
|
"updatedAt": "2026-08-16T07:19:25.791Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-16T07:19:25.791Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff691e537ffez8BjuacoXdNxTn.json
Normal file
10
.omo/run-continuation/ses_ff691e537ffez8BjuacoXdNxTn.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff691e537ffez8BjuacoXdNxTn",
|
||||||
|
"updatedAt": "2026-08-16T07:16:46.925Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-16T07:16:46.925Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff692ba46ffeSU7aelQglhMeiF.json
Normal file
10
.omo/run-continuation/ses_ff692ba46ffeSU7aelQglhMeiF.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff692ba46ffeSU7aelQglhMeiF",
|
||||||
|
"updatedAt": "2026-08-16T07:15:52.386Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-16T07:15:52.386Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff69484cbffeAGa40ipKWklnzo.json
Normal file
10
.omo/run-continuation/ses_ff69484cbffeAGa40ipKWklnzo.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff69484cbffeAGa40ipKWklnzo",
|
||||||
|
"updatedAt": "2026-08-16T07:13:57.013Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-16T07:13:57.013Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff69591a2ffeJSqVEUaax63aN2.json
Normal file
10
.omo/run-continuation/ses_ff69591a2ffeJSqVEUaax63aN2.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff69591a2ffeJSqVEUaax63aN2",
|
||||||
|
"updatedAt": "2026-08-16T07:12:47.181Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-16T07:12:47.181Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff696846affeFWcEhNPTlWvmjH.json
Normal file
10
.omo/run-continuation/ses_ff696846affeFWcEhNPTlWvmjH.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff696846affeFWcEhNPTlWvmjH",
|
||||||
|
"updatedAt": "2026-08-16T07:11:44.030Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-16T07:11:44.030Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff697943bffeh1dX0uUQeg1qbc.json
Normal file
10
.omo/run-continuation/ses_ff697943bffeh1dX0uUQeg1qbc.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff697943bffeh1dX0uUQeg1qbc",
|
||||||
|
"updatedAt": "2026-08-16T07:10:34.453Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-16T07:10:34.453Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff69a5819ffekEdOqJumLRg5TQ.json
Normal file
10
.omo/run-continuation/ses_ff69a5819ffekEdOqJumLRg5TQ.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff69a5819ffekEdOqJumLRg5TQ",
|
||||||
|
"updatedAt": "2026-08-16T07:08:36.977Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-16T07:08:36.977Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_ff69c705cffe8KOKa207kILOAq.json
Normal file
10
.omo/run-continuation/ses_ff69c705cffe8KOKa207kILOAq.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_ff69c705cffe8KOKa207kILOAq",
|
||||||
|
"updatedAt": "2026-08-16T09:46:47.522Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-08-16T09:46:47.522Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
.probe/exa_mcp_probe.sh
Normal file
24
.probe/exa_mcp_probe.sh
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Probe the Exa hosted MCP (same endpoint omo websearch uses) from plain HTTP.
|
||||||
|
set -u
|
||||||
|
URL="https://mcp.exa.ai/mcp?tools=web_search_exa"
|
||||||
|
H1='Content-Type: application/json'
|
||||||
|
H2='Accept: application/json, text/event-stream'
|
||||||
|
|
||||||
|
echo "=== initialize ==="
|
||||||
|
INIT_RESP=$(curl -sS -i -m 30 "$URL" -X POST -H "$H1" -H "$H2" \
|
||||||
|
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"watchdog-probe","version":"0.1"}}}')
|
||||||
|
echo "$INIT_RESP" | head -30
|
||||||
|
SID=$(echo "$INIT_RESP" | grep -i '^mcp-session-id:' | tr -d '\r' | awk '{print $2}')
|
||||||
|
echo "session-id: ${SID:-<none>}"
|
||||||
|
|
||||||
|
echo "=== notifications/initialized ==="
|
||||||
|
curl -sS -m 15 -o /dev/null -w '%{http_code}\n' "$URL" -X POST -H "$H1" -H "$H2" \
|
||||||
|
${SID:+-H "Mcp-Session-Id: $SID"} \
|
||||||
|
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
|
||||||
|
|
||||||
|
echo "=== tools/call web_search_exa ==="
|
||||||
|
curl -sS -m 60 "$URL" -X POST -H "$H1" -H "$H2" \
|
||||||
|
${SID:+-H "Mcp-Session-Id: $SID"} \
|
||||||
|
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"web_search_exa","arguments":{"query":"sst opencode latest release 2026","numResults":3}}}' | head -c 2500
|
||||||
|
echo
|
||||||
77
.probe/manager-brief.md
Normal file
77
.probe/manager-brief.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# 总任务: blog-app 加 "AI Agent 升级迭代日报" (daily agent watchdog)
|
||||||
|
|
||||||
|
你是 manager, 严格按你的工作流: plan 拆任务 -> build 串行执行 -> review 给正式 verdict. 自己不写代码.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
给 Flask 博客应用 (workspace = /home/yi/opencode-blog-showcase/blog-app) 加一个后台日报功能:
|
||||||
|
每天自动抓 6 个 AI agent 项目的最新版本/发布/commit, 用 Exa web search 补充资料, 用本地 litellm 智能总结,
|
||||||
|
写 markdown 报告 + SQLite 存档, Flask /watchdog 页面展示最近 7 天报告并支持手动触发.
|
||||||
|
|
||||||
|
## 已验证的环境事实 (build agent 直接用, 不要重新探索)
|
||||||
|
|
||||||
|
1. **GitHub API** (无需 token, 剩余额度充足):
|
||||||
|
- latest release: `GET https://api.github.com/repos/{owner}/{repo}/releases/latest` (6 个 repo 全部 200)
|
||||||
|
- commits: `GET https://api.github.com/repos/{owner}/{repo}/commits?per_page=5`
|
||||||
|
- 注意: `sst/opencode` 返回 301 (已改名 anomalyco/opencode), requests 默认跟随重定向即可, 但报告里 repo 名仍写 sst/opencode
|
||||||
|
2. **npm registry**: `GET https://registry.npmjs.org/{pkg}/latest` 已验证 200 的包: `opencode-ai`, `@anthropic-ai/claude-code`, `@openai/codex`, `hermes-agent`, `oh-my-openagent`. `hindsight` 包存在但可能不是 vectorize-io/hindsight 那个项目 — 取回后检查 description 相关性, 不相关就跳过该项目的 npm 源 (单源失败不得中断整体).
|
||||||
|
3. **RSS/Atom**: `https://github.com/{owner}/{repo}/releases.atom` (sst/opencode 的 atom 也 301, requests 跟随即可). 用标准库 xml.etree.ElementTree 解析, **禁止装 feedparser**.
|
||||||
|
4. **Web search = Exa 托管 MCP** (omo websearch 同款, 免 API key, 已实测可用):
|
||||||
|
- 端点: `POST https://mcp.exa.ai/mcp?tools=web_search_exa`
|
||||||
|
- 协议: MCP streamable HTTP / JSON-RPC. 流程:
|
||||||
|
a. POST `initialize` (params: protocolVersion "2025-03-26", capabilities {}, clientInfo) — 响应头 `Mcp-Session-Id` 必须保存
|
||||||
|
b. POST `notifications/initialized` (带 Mcp-Session-Id 头)
|
||||||
|
c. POST `tools/call`, name=`web_search_exa`, arguments=`{"query": "...", "numResults": 3}` (带 Mcp-Session-Id 头)
|
||||||
|
- 请求头: `Content-Type: application/json`, `Accept: application/json, text/event-stream`
|
||||||
|
- 响应是 SSE 格式 (`event: message\ndata: {json}`), 解析 data 行的 JSON 取 `result.content[0].text`
|
||||||
|
- 参考实现: workspace 里 `.probe/exa_mcp_probe.sh` 有我实测通过的 curl 版
|
||||||
|
5. **LLM 总结 = litellm 本地代理**: `POST http://127.0.0.1:4000/v1/chat/completions`, json=`{"model": "analysis", "messages": [...], "max_tokens": 1024}`, **无需 Authorization 头**, timeout=300. model 名就是字符串 "analysis" (Qwen3.7 Plus). 发给 LLM 的单项目数据截断 (release body ≤500 字符, commit message ≤200 字符), 总 prompt <12KB. 中文输出.
|
||||||
|
6. **系统时区 Asia/Shanghai (UTC+8)** — cron `0 9 * * *` 即每天早 9 点.
|
||||||
|
7. **Flask app 现状**: app.py (561 行) 已有 get_db() (projects.db 只读), /analyze SSE 流式等. 运行方式: `/usr/bin/python3 app.py`, host 0.0.0.0 port 8090. 现有模板在 templates/, 静态在 static/. 部署脚本 scripts/deploy.sh 已存在 (kill + 重启 + 健康检查), 不要重写它, 但需要时可以读它参考.
|
||||||
|
8. **opencode 写文件权限**: agent 的 write/edit 工具只能写 workspace (/home/yi/opencode-blog-showcase/blog-app) 内部. 所有代码文件都必须落在 workspace 里. 运行时目录 (data/, logs/) 由 python 脚本运行时 os.makedirs 创建, 不是 agent 用 write 工具创建. 任何临时/测试文件落到 workspace 内 `./.review-tmp/`.
|
||||||
|
|
||||||
|
## 架构决策 (已定, 不许改)
|
||||||
|
|
||||||
|
- **新文件 `scripts/daily_watchdog.py`**: 独立可执行脚本 (`/usr/bin/python3 scripts/daily_watchdog.py`), 做全部 抓取->web search 补充->LLM 总结->写报告->存 SQLite. 退出码: 全失败=1, 部分失败=0 但报告里标注.
|
||||||
|
- **SQLite**: `~/opencode-blog-showcase/data/watchdog.db` (脚本运行时创建目录). 表自己设计 (建议 reports 表: date, markdown, meta_json, created_at + 唯一索引). **绝对不许碰 projects.db**.
|
||||||
|
- **报告文件**: `~/opencode-blog-showcase/logs/daily-agent-watchdog-YYYY-MM-DD.md`, 同时内容存 watchdog.db.
|
||||||
|
- **Flask 路由** (加在 app.py, 风格与现有路由一致):
|
||||||
|
- `GET /watchdog` — 从 watchdog.db 读最近 7 天报告渲染 templates/watchdog.html (base.html extend, 导航与现有页面一致); 没有报告时显示空态 + 立即跑按钮
|
||||||
|
- `POST /watchdog/run` — subprocess 后台起 daily_watchdog.py (不等结果), redirect 回 /watchdog 并 flash "已触发, 约 1-2 分钟后刷新". 简单防重: 60 秒内重复 POST 返回 429.
|
||||||
|
- base.html 导航加 "日报" 链接
|
||||||
|
- **cron**: `scripts/install_watchdog_cron.sh` — 幂等地往当前用户 crontab 加 `0 9 * * * cd /home/yi/opencode-blog-showcase/blog-app && /usr/bin/python3 scripts/daily_watchdog.py >> /home/yi/opencode-blog-showcase/logs/daily-agent-watchdog.log 2>&1` (先 crontab -l 检查已存在则跳过). 脚本由 build agent 写好, **由 review agent 或上层 worker 执行**, build 阶段不执行.
|
||||||
|
- **失败告警**: 单项目/单源失败 -> 报告里该项标 "⚠️ 抓取失败: 原因", 继续其他项目. 整体失败 (如 LLM 挂) -> 仍写一份含错误说明的报告 + log 里 ERROR 行. 任何情况下脚本不得因单个源失败而崩溃退出非零 (除非连一个项目都没抓到).
|
||||||
|
|
||||||
|
## 监控项目 (6 个, ★ = 当前 setup 装了)
|
||||||
|
|
||||||
|
| repo | npm 包 | ★ | 备注 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| sst/opencode | opencode-ai | ★ | 已装 1.18.14 (301->anomalyco/opencode) |
|
||||||
|
| anthropics/claude-code | @anthropic-ai/claude-code | ★ | |
|
||||||
|
| openai/codex | @openai/codex | | |
|
||||||
|
| NousResearch/hermes-agent | hermes-agent | ★ | |
|
||||||
|
| code-yeongyu/oh-my-openagent | oh-my-openagent | ★ | |
|
||||||
|
| vectorize-io/hindsight | hindsight (需验证相关性, 不相关则跳过 npm) | ★ | |
|
||||||
|
|
||||||
|
## 报告格式 (每项目必须有)
|
||||||
|
- latest version, last release date, 1 句中文 changelog 摘要 (LLM 生成), breaking change 标记 (LLM 判断 true/false + 依据), ★ 相关性说明
|
||||||
|
- 报告头部: 日期, 数据源清单 (实际成功用了哪几个), 各项目一节
|
||||||
|
- **≥30% 的报告内容必须来自 web search 补充** (每项目至少 1 条 Exa 搜索结果摘要进报告, 标注来源 URL)
|
||||||
|
|
||||||
|
## Acceptance (review 必须逐项验证, 全过才 PASS)
|
||||||
|
1. cron 安装脚本存在且幂等, 安装后 `crontab -l` 能看到 9:00 条目 (review 时实际执行安装脚本验证)
|
||||||
|
2. 数据源 ≥5: GitHub Releases API / GitHub commits API / npm registry / Exa web search / GitHub releases.atom — 报告头部的数据源清单要真列出
|
||||||
|
3. 监控 6 项目, 报告每项含 version/date/中文摘要/breaking 标记/★
|
||||||
|
4. `scripts/daily_watchdog.py` 真实跑一遍 (review agent 执行), 生成真报告 (禁止 mock), review 报告里必须附至少 1 段真实 curl/脚本输出证据
|
||||||
|
5. Flask /watchdog 渲染最近 7 天 + POST /watchdog/run 手动触发可用 (review agent 用 deploy.sh 重启后 curl 验证 127.0.0.1:8090 和 192.168.2.25:8090)
|
||||||
|
6. 无新 pip 依赖 (只用 flask/requests/sqlite3/标准库)
|
||||||
|
|
||||||
|
## 禁止 (越界=FAIL)
|
||||||
|
- 禁止改 litellm 配置 / restart litellm-proxy / llama-server
|
||||||
|
- 禁止改 projects.db schema, 禁止改 scripts/fetch_data.py
|
||||||
|
- 禁止碰 /home/yi/wxhook, /home/yi/.litellm, /home/yi/opencode, omo 安装目录和配置
|
||||||
|
- 禁止 mock 数据 / 预生成报告 / 用云端 LLM 替代 litellm analysis
|
||||||
|
- 禁止 pip install 任何新包
|
||||||
|
- 禁止删改 .probe/ 目录外的现有文件 (app.py 只做新增式修改: 加路由加导航, 不动现有路由逻辑)
|
||||||
|
|
||||||
|
## 输出
|
||||||
|
按你的 STEP/FINAL 格式汇报. FINAL 里 deliverable_path 指向 workspace 根.
|
||||||
14
.probe/run-sisyphus-b2b3.sh
Normal file
14
.probe/run-sisyphus-b2b3.sh
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# t_f1f53c88: fork omo Sisyphus (default agent, NO --agent) with .sisyphus-brief-b2b3.md
|
||||||
|
set -u
|
||||||
|
export HOME=/home/yi
|
||||||
|
cd /home/yi/opencode-blog-showcase/blog-app
|
||||||
|
TS=$(date +%H%M%S)
|
||||||
|
LOG=/home/yi/opencode-blog-showcase/logs/sisyphus-b2b3-${TS}.ndjson
|
||||||
|
echo "log: $LOG"
|
||||||
|
opencode run --attach http://127.0.0.1:4096 \
|
||||||
|
--model litellm-local/coding \
|
||||||
|
--dir /home/yi/opencode-blog-showcase/blog-app \
|
||||||
|
--format json \
|
||||||
|
"$(cat .sisyphus-brief-b2b3.md)" > "$LOG" 2>&1
|
||||||
|
echo "exit=$? log=$LOG"
|
||||||
137
.review-tmp/analyze-inline.js
Normal file
137
.review-tmp/analyze-inline.js
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
|
||||||
|
// State
|
||||||
|
let currentEventSource = null;
|
||||||
|
let currentTab = 'observation'; // Track which tab we're writing to
|
||||||
|
let currentSection = 'reasoning'; // 'reasoning' | 'conclusion', 由 SSE section 事件驱动
|
||||||
|
|
||||||
|
// DOM elements
|
||||||
|
const scopeSelect = document.getElementById('scopeSelect');
|
||||||
|
const questionInput = document.getElementById('questionInput');
|
||||||
|
const startBtn = document.getElementById('startBtn');
|
||||||
|
const presetBtns = document.querySelectorAll('.preset-btn');
|
||||||
|
|
||||||
|
const observationContent = document.getElementById('observationContent');
|
||||||
|
const promptContent = document.getElementById('promptContent');
|
||||||
|
const reasoningContent = document.getElementById('reasoningContent');
|
||||||
|
const conclusionContent = document.getElementById('conclusionContent');
|
||||||
|
|
||||||
|
// Programmatic tab switching (Bootstrap tabs)
|
||||||
|
function switchTab(name) {
|
||||||
|
const btn = document.getElementById(name + '-tab');
|
||||||
|
if (btn) btn.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab switching
|
||||||
|
document.querySelectorAll('#outputTabs button').forEach(button => {
|
||||||
|
button.addEventListener('click', (e) => {
|
||||||
|
const tabId = e.target.id.replace('-tab', '');
|
||||||
|
currentTab = tabId;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Preset button click
|
||||||
|
presetBtns.forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const preset = btn.dataset.preset;
|
||||||
|
startAnalysis(null, preset);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start button click
|
||||||
|
startBtn.addEventListener('click', () => {
|
||||||
|
const question = questionInput.value.trim();
|
||||||
|
if (!question) {
|
||||||
|
alert('请输入自定义问题或选择预设分析');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startAnalysis(question, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
function startAnalysis(question, preset) {
|
||||||
|
// Close existing connection
|
||||||
|
if (currentEventSource) {
|
||||||
|
currentEventSource.close();
|
||||||
|
currentEventSource = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset content
|
||||||
|
observationContent.innerHTML = '<div class="text-muted">正在收集上下文...</div>';
|
||||||
|
promptContent.innerHTML = '<div class="text-muted">等待...</div>';
|
||||||
|
reasoningContent.innerHTML = '<div class="text-muted">等待...</div>';
|
||||||
|
conclusionContent.innerHTML = '<div class="text-muted">等待...</div>';
|
||||||
|
currentSection = 'reasoning';
|
||||||
|
|
||||||
|
// Build URL
|
||||||
|
const scope = scopeSelect.value;
|
||||||
|
const params = new URLSearchParams({scope});
|
||||||
|
if (question) params.append('question', question);
|
||||||
|
if (preset) params.append('preset', preset);
|
||||||
|
|
||||||
|
const url = `/api/analyze/stream?${params.toString()}`;
|
||||||
|
|
||||||
|
// Start SSE
|
||||||
|
currentEventSource = new EventSource(url);
|
||||||
|
|
||||||
|
// Handle step events
|
||||||
|
currentEventSource.addEventListener('step', (e) => {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
const step = data.step;
|
||||||
|
const content = data.content;
|
||||||
|
|
||||||
|
if (step === 'context') {
|
||||||
|
observationContent.innerHTML = `<div class="alert alert-info">${escapeHtml(content)}</div>`;
|
||||||
|
} else if (step === 'prompt') {
|
||||||
|
promptContent.innerHTML = `<pre class="bg-light p-3 rounded" style="white-space: pre-wrap;">${escapeHtml(content)}</pre>`;
|
||||||
|
} else if (step === 'complete') {
|
||||||
|
// 追加提示, 不覆盖已流式写入的结论内容
|
||||||
|
conclusionContent.innerHTML += `<div class="alert alert-success">${escapeHtml(content)}</div>`;
|
||||||
|
currentEventSource.close();
|
||||||
|
currentEventSource = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Section 事件: 后端识别 ===REASONING=== / ===CONCLUSION=== 后显式下发
|
||||||
|
currentEventSource.addEventListener('reasoning_start', () => {
|
||||||
|
currentSection = 'reasoning';
|
||||||
|
reasoningContent.innerHTML = '';
|
||||||
|
switchTab('reasoning');
|
||||||
|
});
|
||||||
|
|
||||||
|
currentEventSource.addEventListener('conclusion_start', () => {
|
||||||
|
currentSection = 'conclusion';
|
||||||
|
conclusionContent.innerHTML = '';
|
||||||
|
switchTab('conclusion');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle token events (streaming LLM output, 分隔符已被后端剥离)
|
||||||
|
currentEventSource.addEventListener('token', (e) => {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
const text = data.text;
|
||||||
|
const pane = currentSection === 'conclusion' ? conclusionContent : reasoningContent;
|
||||||
|
if (pane.querySelector('.text-muted')) pane.innerHTML = '';
|
||||||
|
pane.innerHTML += escapeHtml(text);
|
||||||
|
pane.scrollTop = pane.scrollHeight;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle errors
|
||||||
|
currentEventSource.addEventListener('error', (e) => {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
const errorMsg = data.error || '未知错误';
|
||||||
|
observationContent.innerHTML = `<div class="alert alert-danger">错误: ${escapeHtml(errorMsg)}</div>`;
|
||||||
|
currentEventSource.close();
|
||||||
|
currentEventSource = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
currentEventSource.onerror = (e) => {
|
||||||
|
observationContent.innerHTML = `<div class="alert alert-danger">连接错误, 请检查后端日志</div>`;
|
||||||
|
currentEventSource.close();
|
||||||
|
currentEventSource = null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: escape HTML
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
296
.review-tmp/analyze_page.html
Normal file
296
.review-tmp/analyze_page.html
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>AI 分析 - AI Agent 项目追踪</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body class="d-flex flex-column min-vh-100">
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||||
|
<div class="container">
|
||||||
|
<a class="navbar-brand" href="/">AI Agent 追踪</a>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNav">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="mainNav">
|
||||||
|
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||||
|
<li class="nav-item"><a class="nav-link" href="/">首页</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="/projects">项目列表</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="/search">全局搜索</a></li>
|
||||||
|
</ul>
|
||||||
|
<form class="d-flex" action="/search" method="get" id="navSearchForm">
|
||||||
|
<input class="form-control me-2 nav-search-input" type="search"
|
||||||
|
name="q" placeholder="搜索项目 / release / issue" aria-label="Search">
|
||||||
|
<button class="btn btn-outline-light" type="submit">搜索</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="container py-4 flex-grow-1">
|
||||||
|
|
||||||
|
<h1 class="mb-4">AI 分析</h1>
|
||||||
|
|
||||||
|
<!-- Control panel -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<!-- Scope selector -->
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">分析范围</label>
|
||||||
|
<select class="form-select" id="scopeSelect">
|
||||||
|
<option value="home">全站概览</option>
|
||||||
|
<option value="projects">所有项目</option>
|
||||||
|
<option value="project/1">项目: hermes-agent</option>
|
||||||
|
<option value="project/2">项目: opencode</option>
|
||||||
|
<option value="project/3">项目: wxhook</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preset buttons -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">预设分析</label>
|
||||||
|
<div class="btn-group w-100" role="group">
|
||||||
|
<button type="button" class="btn btn-outline-primary preset-btn" data-preset="activity">
|
||||||
|
📊 活跃度评估
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-primary preset-btn" data-preset="evolution">
|
||||||
|
🚀 版本演进
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-primary preset-btn" data-preset="issues_hot">
|
||||||
|
🔥 Issue 热点
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Start button -->
|
||||||
|
<div class="col-md-3 d-flex align-items-end">
|
||||||
|
<button type="button" class="btn btn-success w-100" id="startBtn">
|
||||||
|
开始分析
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Custom question input -->
|
||||||
|
<div class="row g-3 mt-2">
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">自定义问题 (可选)</label>
|
||||||
|
<input type="text" class="form-control" id="questionInput"
|
||||||
|
placeholder="例如: 这个项目的核心功能是什么? / 最近有什么重要更新?">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Output area with tabs -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<ul class="nav nav-tabs card-header-tabs" id="outputTabs" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link active" id="observation-tab" data-bs-toggle="tab"
|
||||||
|
data-bs-target="#observation" type="button" role="tab">
|
||||||
|
数据观察
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="prompt-tab" data-bs-toggle="tab"
|
||||||
|
data-bs-target="#prompt" type="button" role="tab">
|
||||||
|
Prompt
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="reasoning-tab" data-bs-toggle="tab"
|
||||||
|
data-bs-target="#reasoning" type="button" role="tab">
|
||||||
|
LLM 推理
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="conclusion-tab" data-bs-toggle="tab"
|
||||||
|
data-bs-target="#conclusion" type="button" role="tab">
|
||||||
|
结论
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="tab-content" id="outputTabContent">
|
||||||
|
<div class="tab-pane fade show active" id="observation" role="tabpanel">
|
||||||
|
<div class="output-content" id="observationContent">
|
||||||
|
<div class="text-muted">等待分析开始...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tab-pane fade" id="prompt" role="tabpanel">
|
||||||
|
<div class="output-content" id="promptContent">
|
||||||
|
<div class="text-muted">等待分析开始...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tab-pane fade" id="reasoning" role="tabpanel">
|
||||||
|
<div class="output-content" id="reasoningContent">
|
||||||
|
<div class="text-muted">等待分析开始...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tab-pane fade" id="conclusion" role="tabpanel">
|
||||||
|
<div class="output-content" id="conclusionContent">
|
||||||
|
<div class="text-muted">等待分析开始...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="bg-dark text-light text-center py-3 mt-auto">
|
||||||
|
<div class="container">
|
||||||
|
AI Agent 项目追踪 - Powered by Flask
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="/static/js/app.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// State
|
||||||
|
let currentEventSource = null;
|
||||||
|
let currentTab = 'observation'; // Track which tab we're writing to
|
||||||
|
let reasoningStarted = false;
|
||||||
|
let conclusionStarted = false;
|
||||||
|
|
||||||
|
// DOM elements
|
||||||
|
const scopeSelect = document.getElementById('scopeSelect');
|
||||||
|
const questionInput = document.getElementById('questionInput');
|
||||||
|
const startBtn = document.getElementById('startBtn');
|
||||||
|
const presetBtns = document.querySelectorAll('.preset-btn');
|
||||||
|
|
||||||
|
const observationContent = document.getElementById('observationContent');
|
||||||
|
const promptContent = document.getElementById('promptContent');
|
||||||
|
const reasoningContent = document.getElementById('reasoningContent');
|
||||||
|
const conclusionContent = document.getElementById('conclusionContent');
|
||||||
|
|
||||||
|
// Tab switching
|
||||||
|
document.querySelectorAll('#outputTabs button').forEach(button => {
|
||||||
|
button.addEventListener('click', (e) => {
|
||||||
|
const tabId = e.target.id.replace('-tab', '');
|
||||||
|
currentTab = tabId;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Preset button click
|
||||||
|
presetBtns.forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const preset = btn.dataset.preset;
|
||||||
|
startAnalysis(null, preset);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start button click
|
||||||
|
startBtn.addEventListener('click', () => {
|
||||||
|
const question = questionInput.value.trim();
|
||||||
|
if (!question) {
|
||||||
|
alert('请输入自定义问题或选择预设分析');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startAnalysis(question, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
function startAnalysis(question, preset) {
|
||||||
|
// Close existing connection
|
||||||
|
if (currentEventSource) {
|
||||||
|
currentEventSource.close();
|
||||||
|
currentEventSource = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset content
|
||||||
|
observationContent.innerHTML = '<div class="text-muted">正在收集上下文...</div>';
|
||||||
|
promptContent.innerHTML = '<div class="text-muted">等待...</div>';
|
||||||
|
reasoningContent.innerHTML = '<div class="text-muted">等待...</div>';
|
||||||
|
conclusionContent.innerHTML = '<div class="text-muted">等待...</div>';
|
||||||
|
reasoningStarted = false;
|
||||||
|
conclusionStarted = false;
|
||||||
|
|
||||||
|
// Build URL
|
||||||
|
const scope = scopeSelect.value;
|
||||||
|
const params = new URLSearchParams({scope});
|
||||||
|
if (question) params.append('question', question);
|
||||||
|
if (preset) params.append('preset', preset);
|
||||||
|
|
||||||
|
const url = `/api/analyze/stream?${params.toString()}`;
|
||||||
|
|
||||||
|
// Start SSE
|
||||||
|
currentEventSource = new EventSource(url);
|
||||||
|
|
||||||
|
// Handle step events
|
||||||
|
currentEventSource.addEventListener('step', (e) => {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
const step = data.step;
|
||||||
|
const content = data.content;
|
||||||
|
|
||||||
|
if (step === 'context') {
|
||||||
|
observationContent.innerHTML = `<div class="alert alert-info">${escapeHtml(content)}</div>`;
|
||||||
|
} else if (step === 'prompt') {
|
||||||
|
promptContent.innerHTML = `<pre class="bg-light p-3 rounded" style="white-space: pre-wrap;">${escapeHtml(content)}</pre>`;
|
||||||
|
} else if (step === 'complete') {
|
||||||
|
conclusionContent.innerHTML = `<div class="alert alert-success">${escapeHtml(content)}</div>`;
|
||||||
|
currentEventSource.close();
|
||||||
|
currentEventSource = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle token events (streaming LLM output)
|
||||||
|
currentEventSource.addEventListener('token', (e) => {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
const text = data.text;
|
||||||
|
|
||||||
|
// Determine which tab to write to based on content markers
|
||||||
|
if (!reasoningStarted && (text.includes('1.') || text.includes('数据观察') || text.includes('初步判断'))) {
|
||||||
|
reasoningStarted = true;
|
||||||
|
reasoningContent.innerHTML = '';
|
||||||
|
}
|
||||||
|
if (!conclusionStarted && (text.includes('4.') || text.includes('结论'))) {
|
||||||
|
conclusionStarted = true;
|
||||||
|
conclusionContent.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write to appropriate tab
|
||||||
|
if (reasoningStarted && !conclusionStarted) {
|
||||||
|
reasoningContent.innerHTML += escapeHtml(text);
|
||||||
|
} else if (conclusionStarted) {
|
||||||
|
conclusionContent.innerHTML += escapeHtml(text);
|
||||||
|
} else {
|
||||||
|
// Default to reasoning if no markers yet
|
||||||
|
reasoningContent.innerHTML += escapeHtml(text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle errors
|
||||||
|
currentEventSource.addEventListener('error', (e) => {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
const errorMsg = data.error || '未知错误';
|
||||||
|
observationContent.innerHTML = `<div class="alert alert-danger">错误: ${escapeHtml(errorMsg)}</div>`;
|
||||||
|
currentEventSource.close();
|
||||||
|
currentEventSource = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
currentEventSource.onerror = (e) => {
|
||||||
|
observationContent.innerHTML = `<div class="alert alert-danger">连接错误, 请检查后端日志</div>`;
|
||||||
|
currentEventSource.close();
|
||||||
|
currentEventSource = null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: escape HTML
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- 全站浮动 AI 分析面板 -->
|
||||||
|
<button id="ai-fab" type="button" title="AI 分析">🤖</button>
|
||||||
|
<script src="/static/js/ai-panel.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
106
.review-tmp/build1-summary.md
Normal file
106
.review-tmp/build1-summary.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
# Build1 Patch Summary — scripts/daily_watchdog.py
|
||||||
|
|
||||||
|
Date: 2026-08-16 · Workspace: /home/yi/opencode-blog-showcase/blog-app
|
||||||
|
|
||||||
|
## Patch summary (surgical edits, file 596→853 lines)
|
||||||
|
|
||||||
|
1. **Per-project source accounting**
|
||||||
|
- `collect_all()` now returns `(project_results, source_counts: dict[str,int], sources_failed)`; each project dict gains `sources_used: []` populated on every successful source.
|
||||||
|
- Report header `## 数据源` lists all 5 canonical sources with success counts, e.g. `- GitHub Releases API (6/6)`, `- GitHub releases.atom (4/6)`.
|
||||||
|
- `meta_json` per project now includes `sources_used`; top-level gains `source_counts`.
|
||||||
|
|
||||||
|
2. **Failure reasons**
|
||||||
|
- All 4 fetch functions return `(result, error)` tuples; `source_errors` entries are `"<source>: <reason>"` (reason ≤80 chars, newlines stripped via `_short_reason`).
|
||||||
|
- Project sections render `⚠️ 抓取失败: <source> — <reason>` (failed projects: one line per source; partial projects: enriched npm / releases.atom / Exa lines).
|
||||||
|
- Atom timeouts classified as `超时 (github.com 限流/网络)`.
|
||||||
|
|
||||||
|
3. **Atom hardening**
|
||||||
|
- `fetch_releases_atom`: timeout 45s (`ATOM_TIMEOUT`), ONE retry on `requests.exceptions.Timeout` (sleep 5s), never raises (returns `(None, reason)`).
|
||||||
|
|
||||||
|
4. **Self-lock**
|
||||||
|
- `_watchdog_already_running()` via `pgrep -f daily_watchdog.py` excluding own pid, hardened to only count processes whose argv[0] is a python interpreter (avoids false positives from processes that merely mention the filename). At `main()` start: if running → log + return 0.
|
||||||
|
|
||||||
|
5. **`--fix-if-missing`**
|
||||||
|
- `_today_report_ok(date)`: today's report exists in watchdog.db AND meta has ≥2 projects status != 'failed' → log `today's report already OK — skip full run (--fix-if-missing)`, exit 0 fast. Default behavior unchanged.
|
||||||
|
|
||||||
|
6. **Kept**: LLM integration (POST http://127.0.0.1:4000/v1/chat/completions, model="analysis", no auth, timeout=300) — already present in the file; report sections unchanged except 数据源 header + enriched ⚠️ lines; `**LLM 摘要:**` renamed to `**LLM 总结:**` and moved to render for ALL projects (previously only rendered when GitHub data existed); exit codes unchanged (1 only if ALL projects failed). No new imports beyond stdlib + requests.
|
||||||
|
|
||||||
|
## Verification evidence
|
||||||
|
|
||||||
|
### py_compile
|
||||||
|
```
|
||||||
|
PY_COMPILE_OK
|
||||||
|
```
|
||||||
|
|
||||||
|
### REAL full run (final, exit code 0)
|
||||||
|
Command: `HOME=/home/yi /usr/bin/python3 scripts/daily_watchdog.py`
|
||||||
|
```
|
||||||
|
EXIT=0
|
||||||
|
2026-08-16 16:52:27 [INFO] Starting daily watchdog for 2026-08-16
|
||||||
|
2026-08-16 16:52:34 [INFO] Exa MCP initialized, session=250675fa-...
|
||||||
|
2026-08-16 16:57:03 [WARNING] Atom feed timeout for code-yeongyu/oh-my-openagent (attempt 1/2)
|
||||||
|
2026-08-16 16:58:00 [WARNING] Atom feed timeout for code-yeongyu/oh-my-openagent (attempt 2/2)
|
||||||
|
2026-08-16 16:59:47 [WARNING] Atom feed timeout for vectorize-io/hindsight (attempt 1/2)
|
||||||
|
2026-08-16 17:00:37 [WARNING] Atom feed timeout for vectorize-io/hindsight (attempt 2/2)
|
||||||
|
2026-08-16 17:01:29 [INFO] Report saved to /home/yi/opencode-blog-showcase/logs/daily-agent-watchdog-2026-08-16.md
|
||||||
|
2026-08-16 17:01:29 [INFO] SQLite record saved to /home/yi/opencode-blog-showcase/data/watchdog.db
|
||||||
|
2026-08-16 17:01:29 [INFO] Daily watchdog completed successfully
|
||||||
|
```
|
||||||
|
Full stdout: `./.review-tmp/build1-run.log` (tail) / `./.review-tmp/build1-run-final.log` (full).
|
||||||
|
|
||||||
|
### 数据源 header (verbatim)
|
||||||
|
```
|
||||||
|
## 数据源
|
||||||
|
|
||||||
|
- GitHub Releases API (6/6)
|
||||||
|
- GitHub commits API (6/6)
|
||||||
|
- npm registry (5/6)
|
||||||
|
- GitHub releases.atom (4/6)
|
||||||
|
- Exa web search (6/6)
|
||||||
|
```
|
||||||
|
|
||||||
|
### One-line LLM 总结 excerpt per project (all real Chinese, 0 placeholders)
|
||||||
|
- sst/opencode: 本次 v1.18.18 版本更新主要聚焦于核心功能的缺陷修复与模型兼容性优化…
|
||||||
|
- anthropics/claude-code: 本次更新为 claude-code 增加了对 GitLab 合并请求 URL 的支持,并引入了可…
|
||||||
|
- openai/codex: 本次0.147.0版本更新引入了便携式Agent插件安装与跨目录搜索功能,支持…
|
||||||
|
- NousResearch/hermes-agent: 本次更新为 Hermes Agent v0.20.1 补丁版本,主要整合了自 v0.20.0 以来的大量…
|
||||||
|
- code-yeongyu/oh-my-openagent: 本次更新发布了 v5.0.0-beta.7 版本,标志着项目向全新的 OMO Native CLI 架构…
|
||||||
|
- vectorize-io/hindsight: 本次 v0.9.1 更新主要聚焦于系统稳定性和功能扩展,修复了引擎并发死…
|
||||||
|
|
||||||
|
Report counts: `**LLM 总结:**` ×6, `**Breaking Changes:**` ×6, `★ **相关性:**` ×6, `⚠️ 抓取失败:` ×2, placeholder ("LLM 未集成"/"LLM 总结失败") ×0.
|
||||||
|
|
||||||
|
### Atom timeout classification (exercised live)
|
||||||
|
```
|
||||||
|
**releases.atom:** ⚠️ 抓取失败: GitHub releases.atom — 超时 (github.com 限流/网络)
|
||||||
|
```
|
||||||
|
(2 occurrences — oh-my-openagent and hindsight both timed out twice; retry logic logged `attempt 1/2` → `attempt 2/2`.)
|
||||||
|
|
||||||
|
### --fix-if-missing (exit 0 fast)
|
||||||
|
```
|
||||||
|
2026-08-16 17:01:41 [INFO] today's report already OK — skip full run (--fix-if-missing)
|
||||||
|
FIX_EXIT=0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Self-lock idle check
|
||||||
|
```
|
||||||
|
idle _watchdog_already_running = False
|
||||||
|
IDLE_EXIT=0
|
||||||
|
```
|
||||||
|
Positive case also demonstrated live: while another instance ran, the script logged `Another daily_watchdog instance is already running — skip` and exited 0.
|
||||||
|
|
||||||
|
### meta_json (watchdog.db, 2026-08-16)
|
||||||
|
```
|
||||||
|
source_counts: {'GitHub Releases API': 6, 'GitHub commits API': 6, 'npm registry': 5, 'GitHub releases.atom': 4, 'Exa web search': 6}
|
||||||
|
opencode ok ['GitHub Releases API', 'GitHub commits API', 'npm registry', 'GitHub releases.atom', 'Exa web search']
|
||||||
|
claude-code ok [...same 5...]
|
||||||
|
codex ok [...same 5...]
|
||||||
|
hermes-agent ok [...same 5...]
|
||||||
|
oh-my-openagent partial ['GitHub Releases API', 'GitHub commits API', 'npm registry', 'Exa web search']
|
||||||
|
hindsight partial ['GitHub Releases API', 'GitHub commits API', 'Exa web search']
|
||||||
|
```
|
||||||
|
|
||||||
|
## Anomalies
|
||||||
|
1. **Concurrent agent**: another opencode agent (PID 1097346, task "B2 LLM 集成 + B3 Flask/cron") was editing the same file and repeatedly ran the script (5+ times) during this build. It had already added the LLM integration (`summarize_with_llm` real POST) before my patches; I kept it and patched around it. Its runs repeatedly triggered my self-lock (correct behavior) and overwrote the report/DB between my runs — final state is from MY run.
|
||||||
|
2. **Self-lock false positive**: `pgrep -f daily_watchdog.py` matched the concurrent agent's own process (its task text mentions the filename). Hardened the lock to only count processes whose argv[0] is a python interpreter; idle check now returns False correctly.
|
||||||
|
3. **GitHub API rate limit**: during the 16:16 run, api.github.com returned 403 rate-limit for all 6 projects (exhausted by the concurrent agent's repeated runs). The report honestly showed `⚠️ 抓取失败: GitHub Releases API — 403 Client Error: rate limit exceeded…` (truncated ≤80 chars). By the 16:52 run the limit had reset (6/6).
|
||||||
|
4. **releases.atom intermittent timeouts** confirmed live (as background noted): 45s timeout + 1 retry handled them; classified as `超时 (github.com 限流/网络)`.
|
||||||
77
.review-tmp/build2-summary.md
Normal file
77
.review-tmp/build2-summary.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# Build2 Verification Summary — Flask /watchdog page + manual trigger
|
||||||
|
|
||||||
|
## What was added / changed
|
||||||
|
|
||||||
|
### app.py (additive to existing routes; watchdog section lines 225-320)
|
||||||
|
- `WATCHDOG_DB = os.path.expanduser("~/opencode-blog-showcase/data/watchdog.db")` — line 23
|
||||||
|
- `WATCHDOG_RUN_LOG` — lines 27-29
|
||||||
|
- `get_watchdog_db()` — line 234: opens watchdog.db **read-only** via `sqlite3.connect(f"file:{WATCHDOG_DB}?mode=ro", uri=True)` (line 237)
|
||||||
|
- `_load_watchdog_reports(days=7)` — line 243: returns `[]` when db/table missing (never 500); parses `meta_json` into `meta`
|
||||||
|
- `GET /watchdog` (`page_watchdog`) — line 267: renders `watchdog.html` with up to 7 reports
|
||||||
|
- `_is_watchdog_running()` — line 273: pgrep guard against duplicate runs
|
||||||
|
- `POST /watchdog/run` (`watchdog_run`) — line 294:
|
||||||
|
- 60s single-flight via module-level `_last_watchdog_trigger_ts` (line 230); repeat within 60s → `429 "操作太频繁, 请稍后再试"` (line 299)
|
||||||
|
- timestamp set **before** the running-check so the cooldown engages even when the script is already running (fix applied during this build)
|
||||||
|
- `os.makedirs(os.path.dirname(WATCHDOG_RUN_LOG), exist_ok=True)` (line 308) before opening the run log
|
||||||
|
- `subprocess.Popen(["/usr/bin/python3", scripts/daily_watchdog.py], cwd=BASE_DIR, env HOME=/home/yi, stdout=log_f, stderr=STDOUT, start_new_session=True)` — no wait
|
||||||
|
- flash `"已触发, 约 1-2 分钟后刷新页面查看新报告"` (line 317) → redirect `/watchdog`
|
||||||
|
|
||||||
|
### templates/watchdog.html
|
||||||
|
- Title `<h1>升级迭代日报</h1>` (line 7)
|
||||||
|
- "立即运行" form POST /watchdog/run (lines 10-12)
|
||||||
|
- Empty state: `alert alert-info` "暂无报告, 点击立即运行生成第一份日报" (line 17)
|
||||||
|
- Report cards: header = date + created_at, body = `<pre class="watchdog-md">` of markdown (lines 21-45)
|
||||||
|
|
||||||
|
### templates/base.html
|
||||||
|
- Nav link `<li class="nav-item"><a class="nav-link" href="/watchdog">日报</a></li>` after 全局搜索 (line 22)
|
||||||
|
|
||||||
|
## Verification evidence (verbatim key lines)
|
||||||
|
|
||||||
|
### 1. py_compile
|
||||||
|
```
|
||||||
|
PY_COMPILE_OK
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. deploy
|
||||||
|
```
|
||||||
|
local:200
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. GET /watchdog
|
||||||
|
```
|
||||||
|
http:200
|
||||||
|
升级迭代日报
|
||||||
|
2026-08-16
|
||||||
|
sst/opencode
|
||||||
|
立即运行
|
||||||
|
href="/watchdog">日报
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. POST /watchdog/run (first) + immediate repeat
|
||||||
|
```
|
||||||
|
http:200
|
||||||
|
已在运行中, 本次触发被忽略
|
||||||
|
http:429
|
||||||
|
操作太频繁, 请稍后再试
|
||||||
|
```
|
||||||
|
- The "已触发" spawn path was proven earlier: run log line 1 `2026-08-16 17:05:19 [INFO] Starting daily watchdog for 2026-08-16` was spawned by the first POST (script self-locks; left running per spec).
|
||||||
|
|
||||||
|
### 5. Regression
|
||||||
|
```
|
||||||
|
/ -> 200
|
||||||
|
/projects -> 200
|
||||||
|
/search -> 200
|
||||||
|
/analyze -> 200
|
||||||
|
{"total_issues":14,"total_projects":3,"total_releases":14,"total_stars":430166}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. LAN
|
||||||
|
```
|
||||||
|
lan:200
|
||||||
|
```
|
||||||
|
|
||||||
|
## Issues encountered
|
||||||
|
1. **`curl -X POST -L` returns 405 on the redirect follow**: `-X POST` forces curl to re-send POST on the 302 → `/watchdog` only accepts GET → 405. The POST itself succeeded (spawned the script). Used `-d '' -L` (follows 302 with GET) for correct verification.
|
||||||
|
2. **Sibling build1 agent ran concurrently**: it kept POSTing `/watchdog/run` for its own verification, re-triggering the script and resetting the 60s cooldown (causing intermittent 429s on the "first" POST) and overwrote `templates/base.html` at 17:06:48 (reverting the nav label to "Watchdog"). Re-applied the `日报` label and redeployed; final state verified.
|
||||||
|
3. **Cooldown gap fixed**: when the script was already running, the "已在运行中" branch returned before setting `_last_watchdog_trigger_ts`, so repeated POSTs never hit 429. Moved the timestamp assignment before the running-check.
|
||||||
|
4. **GitHub API rate limit**: the sibling's repeated script runs exhausted the GitHub commits API (403 in run log) — unrelated to this build's code.
|
||||||
15
.review-tmp/cron-after1.txt
Normal file
15
.review-tmp/cron-after1.txt
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
@reboot sleep 20 && /home/yi/start-headless.sh
|
||||||
|
# 户部·点卯催工监控(钦天监替代)
|
||||||
|
*/10 * * * * cd /home/yi/.openclaw/workspace-hubu && python3 scripts/check_task_progress.py --check >> /home/yi/.openclaw/workspace-hubu/logs/task_monitor_cron.log 2>&1
|
||||||
|
0 */2 * * * cd /home/yi/.openclaw/workspace-hubu && python3 scripts/check_task_progress.py --full >> /home/yi/.openclaw/workspace-hubu/logs/task_monitor_cron.log 2>&1
|
||||||
|
*/1 * * * * cd /home/yi && python3 /home/yi/档案管理/scripts/处理流程.py process >> /home/yi/档案管理/process.log 2>&1
|
||||||
|
0 3 * * 0 /home/yi/scripts/docker-cleanup.sh >> /home/yi/logs/docker-cleanup.log 2>&1
|
||||||
|
*/5 * * * * /home/yi/scripts/health-monitor.sh >> /home/yi/logs/health-monitor.log 2>&1
|
||||||
|
# 紫微天朝 P2-4: AGE→SQLite doc_entities 增量同步 (每 4 小时)
|
||||||
|
0 */4 * * * /home/yi/kanyu-age-sync-pvc.sh >> /tmp/kanyu_age_sync.log 2>&1
|
||||||
|
0 3 * * * /home/yi/bin/hindsight-cleanup-stuck.sh >> /tmp/hindsight-cleanup.log 2>&1
|
||||||
|
*/5 * * * * /usr/bin/python3 /home/yi/yuanshu-ziwei/scripts/cleanup_legacy_tasks.py --execute >/tmp/cleanup.log 2>&1
|
||||||
|
0 2 * * * /home/yi/sishu/scripts/sishu_pg_backup.sh backup >> /var/log/sishu_pg_backup.log 2>&1
|
||||||
|
0 3 * * * /home/yi/.local/bin/sishu-redis-cleanup-cron
|
||||||
|
*/2 * * * * /usr/local/bin/fix-k3s-containerd.sh >> /var/log/fix-k3s-containerd.log 2>&1
|
||||||
|
0 9 * * * /usr/bin/python3 /home/yi/opencode-blog-showcase/blog-app/scripts/daily_watchdog.py >> /home/yi/opencode-blog-showcase/logs/daily-watchdog-cron.log 2>&1
|
||||||
15
.review-tmp/cron-after2.txt
Normal file
15
.review-tmp/cron-after2.txt
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
@reboot sleep 20 && /home/yi/start-headless.sh
|
||||||
|
# 户部·点卯催工监控(钦天监替代)
|
||||||
|
*/10 * * * * cd /home/yi/.openclaw/workspace-hubu && python3 scripts/check_task_progress.py --check >> /home/yi/.openclaw/workspace-hubu/logs/task_monitor_cron.log 2>&1
|
||||||
|
0 */2 * * * cd /home/yi/.openclaw/workspace-hubu && python3 scripts/check_task_progress.py --full >> /home/yi/.openclaw/workspace-hubu/logs/task_monitor_cron.log 2>&1
|
||||||
|
*/1 * * * * cd /home/yi && python3 /home/yi/档案管理/scripts/处理流程.py process >> /home/yi/档案管理/process.log 2>&1
|
||||||
|
0 3 * * 0 /home/yi/scripts/docker-cleanup.sh >> /home/yi/logs/docker-cleanup.log 2>&1
|
||||||
|
*/5 * * * * /home/yi/scripts/health-monitor.sh >> /home/yi/logs/health-monitor.log 2>&1
|
||||||
|
# 紫微天朝 P2-4: AGE→SQLite doc_entities 增量同步 (每 4 小时)
|
||||||
|
0 */4 * * * /home/yi/kanyu-age-sync-pvc.sh >> /tmp/kanyu_age_sync.log 2>&1
|
||||||
|
0 3 * * * /home/yi/bin/hindsight-cleanup-stuck.sh >> /tmp/hindsight-cleanup.log 2>&1
|
||||||
|
*/5 * * * * /usr/bin/python3 /home/yi/yuanshu-ziwei/scripts/cleanup_legacy_tasks.py --execute >/tmp/cleanup.log 2>&1
|
||||||
|
0 2 * * * /home/yi/sishu/scripts/sishu_pg_backup.sh backup >> /var/log/sishu_pg_backup.log 2>&1
|
||||||
|
0 3 * * * /home/yi/.local/bin/sishu-redis-cleanup-cron
|
||||||
|
*/2 * * * * /usr/local/bin/fix-k3s-containerd.sh >> /var/log/fix-k3s-containerd.log 2>&1
|
||||||
|
0 9 * * * /usr/bin/python3 /home/yi/opencode-blog-showcase/blog-app/scripts/daily_watchdog.py >> /home/yi/opencode-blog-showcase/logs/daily-watchdog-cron.log 2>&1
|
||||||
15
.review-tmp/cron-before.txt
Normal file
15
.review-tmp/cron-before.txt
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
@reboot sleep 20 && /home/yi/start-headless.sh
|
||||||
|
# 户部·点卯催工监控(钦天监替代)
|
||||||
|
*/10 * * * * cd /home/yi/.openclaw/workspace-hubu && python3 scripts/check_task_progress.py --check >> /home/yi/.openclaw/workspace-hubu/logs/task_monitor_cron.log 2>&1
|
||||||
|
0 */2 * * * cd /home/yi/.openclaw/workspace-hubu && python3 scripts/check_task_progress.py --full >> /home/yi/.openclaw/workspace-hubu/logs/task_monitor_cron.log 2>&1
|
||||||
|
*/1 * * * * cd /home/yi && python3 /home/yi/档案管理/scripts/处理流程.py process >> /home/yi/档案管理/process.log 2>&1
|
||||||
|
0 3 * * 0 /home/yi/scripts/docker-cleanup.sh >> /home/yi/logs/docker-cleanup.log 2>&1
|
||||||
|
*/5 * * * * /home/yi/scripts/health-monitor.sh >> /home/yi/logs/health-monitor.log 2>&1
|
||||||
|
# 紫微天朝 P2-4: AGE→SQLite doc_entities 增量同步 (每 4 小时)
|
||||||
|
0 */4 * * * /home/yi/kanyu-age-sync-pvc.sh >> /tmp/kanyu_age_sync.log 2>&1
|
||||||
|
0 3 * * * /home/yi/bin/hindsight-cleanup-stuck.sh >> /tmp/hindsight-cleanup.log 2>&1
|
||||||
|
*/5 * * * * /usr/bin/python3 /home/yi/yuanshu-ziwei/scripts/cleanup_legacy_tasks.py --execute >/tmp/cleanup.log 2>&1
|
||||||
|
0 2 * * * /home/yi/sishu/scripts/sishu_pg_backup.sh backup >> /var/log/sishu_pg_backup.log 2>&1
|
||||||
|
0 3 * * * /home/yi/.local/bin/sishu-redis-cleanup-cron
|
||||||
|
*/2 * * * * /usr/local/bin/fix-k3s-containerd.sh >> /var/log/fix-k3s-containerd.log 2>&1
|
||||||
|
0 9 * * * /usr/bin/python3 /home/yi/opencode-blog-showcase/blog-app/scripts/daily_watchdog.py >> /home/yi/opencode-blog-showcase/logs/daily-watchdog-cron.log 2>&1
|
||||||
15
.review-tmp/crontab-review-backup.txt
Normal file
15
.review-tmp/crontab-review-backup.txt
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
@reboot sleep 20 && /home/yi/start-headless.sh
|
||||||
|
# 户部·点卯催工监控(钦天监替代)
|
||||||
|
*/10 * * * * cd /home/yi/.openclaw/workspace-hubu && python3 scripts/check_task_progress.py --check >> /home/yi/.openclaw/workspace-hubu/logs/task_monitor_cron.log 2>&1
|
||||||
|
0 */2 * * * cd /home/yi/.openclaw/workspace-hubu && python3 scripts/check_task_progress.py --full >> /home/yi/.openclaw/workspace-hubu/logs/task_monitor_cron.log 2>&1
|
||||||
|
*/1 * * * * cd /home/yi && python3 /home/yi/档案管理/scripts/处理流程.py process >> /home/yi/档案管理/process.log 2>&1
|
||||||
|
0 3 * * 0 /home/yi/scripts/docker-cleanup.sh >> /home/yi/logs/docker-cleanup.log 2>&1
|
||||||
|
*/5 * * * * /home/yi/scripts/health-monitor.sh >> /home/yi/logs/health-monitor.log 2>&1
|
||||||
|
# 紫微天朝 P2-4: AGE→SQLite doc_entities 增量同步 (每 4 小时)
|
||||||
|
0 */4 * * * /home/yi/kanyu-age-sync-pvc.sh >> /tmp/kanyu_age_sync.log 2>&1
|
||||||
|
0 3 * * * /home/yi/bin/hindsight-cleanup-stuck.sh >> /tmp/hindsight-cleanup.log 2>&1
|
||||||
|
*/5 * * * * /usr/bin/python3 /home/yi/yuanshu-ziwei/scripts/cleanup_legacy_tasks.py --execute >/tmp/cleanup.log 2>&1
|
||||||
|
0 2 * * * /home/yi/sishu/scripts/sishu_pg_backup.sh backup >> /var/log/sishu_pg_backup.log 2>&1
|
||||||
|
0 3 * * * /home/yi/.local/bin/sishu-redis-cleanup-cron
|
||||||
|
*/2 * * * * /usr/local/bin/fix-k3s-containerd.sh >> /var/log/fix-k3s-containerd.log 2>&1
|
||||||
|
0 9 * * * /usr/bin/python3 /home/yi/opencode-blog-showcase/blog-app/scripts/daily_watchdog.py >> /home/yi/opencode-blog-showcase/logs/daily-watchdog-cron.log 2>&1
|
||||||
14
.review-tmp/crontab.bak
Normal file
14
.review-tmp/crontab.bak
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
@reboot sleep 20 && /home/yi/start-headless.sh
|
||||||
|
# 户部·点卯催工监控(钦天监替代)
|
||||||
|
*/10 * * * * cd /home/yi/.openclaw/workspace-hubu && python3 scripts/check_task_progress.py --check >> /home/yi/.openclaw/workspace-hubu/logs/task_monitor_cron.log 2>&1
|
||||||
|
0 */2 * * * cd /home/yi/.openclaw/workspace-hubu && python3 scripts/check_task_progress.py --full >> /home/yi/.openclaw/workspace-hubu/logs/task_monitor_cron.log 2>&1
|
||||||
|
*/1 * * * * cd /home/yi && python3 /home/yi/档案管理/scripts/处理流程.py process >> /home/yi/档案管理/process.log 2>&1
|
||||||
|
0 3 * * 0 /home/yi/scripts/docker-cleanup.sh >> /home/yi/logs/docker-cleanup.log 2>&1
|
||||||
|
*/5 * * * * /home/yi/scripts/health-monitor.sh >> /home/yi/logs/health-monitor.log 2>&1
|
||||||
|
# 紫微天朝 P2-4: AGE→SQLite doc_entities 增量同步 (每 4 小时)
|
||||||
|
0 */4 * * * /home/yi/kanyu-age-sync-pvc.sh >> /tmp/kanyu_age_sync.log 2>&1
|
||||||
|
0 3 * * * /home/yi/bin/hindsight-cleanup-stuck.sh >> /tmp/hindsight-cleanup.log 2>&1
|
||||||
|
*/5 * * * * /usr/bin/python3 /home/yi/yuanshu-ziwei/scripts/cleanup_legacy_tasks.py --execute >/tmp/cleanup.log 2>&1
|
||||||
|
0 2 * * * /home/yi/sishu/scripts/sishu_pg_backup.sh backup >> /var/log/sishu_pg_backup.log 2>&1
|
||||||
|
0 3 * * * /home/yi/.local/bin/sishu-redis-cleanup-cron
|
||||||
|
*/2 * * * * /usr/local/bin/fix-k3s-containerd.sh >> /var/log/fix-k3s-containerd.log 2>&1
|
||||||
15
.review-tmp/crontab.bak2
Normal file
15
.review-tmp/crontab.bak2
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
@reboot sleep 20 && /home/yi/start-headless.sh
|
||||||
|
# 户部·点卯催工监控(钦天监替代)
|
||||||
|
*/10 * * * * cd /home/yi/.openclaw/workspace-hubu && python3 scripts/check_task_progress.py --check >> /home/yi/.openclaw/workspace-hubu/logs/task_monitor_cron.log 2>&1
|
||||||
|
0 */2 * * * cd /home/yi/.openclaw/workspace-hubu && python3 scripts/check_task_progress.py --full >> /home/yi/.openclaw/workspace-hubu/logs/task_monitor_cron.log 2>&1
|
||||||
|
*/1 * * * * cd /home/yi && python3 /home/yi/档案管理/scripts/处理流程.py process >> /home/yi/档案管理/process.log 2>&1
|
||||||
|
0 3 * * 0 /home/yi/scripts/docker-cleanup.sh >> /home/yi/logs/docker-cleanup.log 2>&1
|
||||||
|
*/5 * * * * /home/yi/scripts/health-monitor.sh >> /home/yi/logs/health-monitor.log 2>&1
|
||||||
|
# 紫微天朝 P2-4: AGE→SQLite doc_entities 增量同步 (每 4 小时)
|
||||||
|
0 */4 * * * /home/yi/kanyu-age-sync-pvc.sh >> /tmp/kanyu_age_sync.log 2>&1
|
||||||
|
0 3 * * * /home/yi/bin/hindsight-cleanup-stuck.sh >> /tmp/hindsight-cleanup.log 2>&1
|
||||||
|
*/5 * * * * /usr/bin/python3 /home/yi/yuanshu-ziwei/scripts/cleanup_legacy_tasks.py --execute >/tmp/cleanup.log 2>&1
|
||||||
|
0 2 * * * /home/yi/sishu/scripts/sishu_pg_backup.sh backup >> /var/log/sishu_pg_backup.log 2>&1
|
||||||
|
0 3 * * * /home/yi/.local/bin/sishu-redis-cleanup-cron
|
||||||
|
*/2 * * * * /usr/local/bin/fix-k3s-containerd.sh >> /var/log/fix-k3s-containerd.log 2>&1
|
||||||
|
0 9 * * * cd /home/yi/opencode-blog-showcase/blog-app && /usr/bin/python3 scripts/daily_watchdog.py >> /home/yi/opencode-blog-showcase/logs/daily-agent-watchdog.log 2>&1
|
||||||
128
.review-tmp/gen-extracted.py
Normal file
128
.review-tmp/gen-extracted.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
def generate():
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
yield f"event: step\ndata: {json.dumps({'step': 'context', 'content': f'已收集上下文: {data_summary}'}, ensure_ascii=False)}\n\n"
|
||||||
|
yield f"event: step\ndata: {json.dumps({'step': 'prompt', 'content': full_prompt_display}, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = fake_post(
|
||||||
|
"http://127.0.0.1:4000/v1/chat/completions",
|
||||||
|
json={"model": "default", "messages": messages, "stream": True, "max_tokens": 1024},
|
||||||
|
stream=True,
|
||||||
|
timeout=120
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
last_keepalive = time.time()
|
||||||
|
full_text = ""
|
||||||
|
# 滚动 buffer: 分隔符可能跨 chunk (如 "===REAS" + "ONING==="),
|
||||||
|
# 保留尾部最长分隔符前缀长度的字符, 不立即下发
|
||||||
|
DELIM_R = "===REASONING==="
|
||||||
|
DELIM_C = "===CONCLUSION==="
|
||||||
|
holdback = max(len(DELIM_R), len(DELIM_C)) - 1
|
||||||
|
pending = ""
|
||||||
|
reasoning_started = False
|
||||||
|
conclusion_started = False
|
||||||
|
|
||||||
|
def emit_token(t):
|
||||||
|
return f"event: token\ndata: {json.dumps({'text': t}, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
for line in resp.iter_lines():
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
line = line.decode("utf-8") if isinstance(line, bytes) else line
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
if now - last_keepalive > 15:
|
||||||
|
yield ": keepalive\n\n"
|
||||||
|
last_keepalive = now
|
||||||
|
|
||||||
|
if line.startswith("data: "):
|
||||||
|
chunk_data = line[6:]
|
||||||
|
if chunk_data.strip() == "[DONE]":
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
chunk = json.loads(chunk_data)
|
||||||
|
delta = chunk.get("choices", [{}])[0].get("delta", {})
|
||||||
|
token_text = delta.get("content", "")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if not token_text:
|
||||||
|
continue
|
||||||
|
|
||||||
|
full_text += token_text
|
||||||
|
pending += token_text
|
||||||
|
|
||||||
|
# 循环剥离 pending 里确定安全的部分:
|
||||||
|
# 遇到完整分隔符 -> 发 section_start 事件, 丢弃分隔符;
|
||||||
|
# 遇到分隔符前缀后缀 -> 留在 pending 等待后续 chunk
|
||||||
|
while pending:
|
||||||
|
idx_r = pending.find(DELIM_R)
|
||||||
|
idx_c = pending.find(DELIM_C)
|
||||||
|
# 找最早出现的完整分隔符
|
||||||
|
idx = -1
|
||||||
|
delim = None
|
||||||
|
if idx_r != -1 and (idx_c == -1 or idx_r < idx_c):
|
||||||
|
idx, delim = idx_r, DELIM_R
|
||||||
|
elif idx_c != -1:
|
||||||
|
idx, delim = idx_c, DELIM_C
|
||||||
|
|
||||||
|
if idx != -1:
|
||||||
|
# 分隔符前的正文是安全的, 直接下发
|
||||||
|
if idx > 0:
|
||||||
|
if not reasoning_started:
|
||||||
|
reasoning_started = True
|
||||||
|
yield f"event: reasoning_start\ndata: {{}}\n\n"
|
||||||
|
yield emit_token(pending[:idx])
|
||||||
|
pending = pending[idx + len(delim):]
|
||||||
|
if delim == DELIM_R:
|
||||||
|
if not reasoning_started:
|
||||||
|
reasoning_started = True
|
||||||
|
yield f"event: reasoning_start\ndata: {{}}\n\n"
|
||||||
|
else:
|
||||||
|
if not reasoning_started:
|
||||||
|
# 防御: 模型跳过 REASONING 直接给 CONCLUSION
|
||||||
|
reasoning_started = True
|
||||||
|
yield f"event: reasoning_start\ndata: {{}}\n\n"
|
||||||
|
if not conclusion_started:
|
||||||
|
conclusion_started = True
|
||||||
|
yield f"event: conclusion_start\ndata: {{}}\n\n"
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 无完整分隔符: 检查尾部是否为某分隔符的前缀
|
||||||
|
keep = 0
|
||||||
|
tail = pending[-holdback:] if len(pending) > holdback else pending
|
||||||
|
for d in (DELIM_R, DELIM_C):
|
||||||
|
for k in range(min(len(tail), len(d) - 1), 0, -1):
|
||||||
|
if tail.endswith(d[:k]):
|
||||||
|
keep = max(keep, k)
|
||||||
|
break
|
||||||
|
safe = pending[:len(pending) - keep] if keep else pending
|
||||||
|
pending = pending[len(safe):]
|
||||||
|
if not safe:
|
||||||
|
break
|
||||||
|
if not reasoning_started:
|
||||||
|
reasoning_started = True
|
||||||
|
yield f"event: reasoning_start\ndata: {{}}\n\n"
|
||||||
|
yield emit_token(safe)
|
||||||
|
|
||||||
|
# 流结束: 残余 pending 全部下发
|
||||||
|
if pending:
|
||||||
|
if not reasoning_started:
|
||||||
|
reasoning_started = True
|
||||||
|
yield f"event: reasoning_start\ndata: {{}}\n\n"
|
||||||
|
yield emit_token(pending)
|
||||||
|
pending = ""
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
yield f"event: error\ndata: {json.dumps({'error': f'LLM call failed: {str(e)}'}, ensure_ascii=False)}\n\n"
|
||||||
|
return
|
||||||
|
|
||||||
|
# 防御: 模型没给分隔符时补发 section_start, 保证前端 tab 一定有入口
|
||||||
|
if not reasoning_started:
|
||||||
|
yield f"event: reasoning_start\ndata: {{}}\n\n"
|
||||||
|
if not conclusion_started:
|
||||||
|
yield f"event: conclusion_start\ndata: {{}}\n\n"
|
||||||
|
|
||||||
|
elapsed = round(time.time() - start_time, 1)
|
||||||
|
yield f"event: step\ndata: {json.dumps({'step': 'complete', 'content': f'分析完成, 总耗时 {elapsed} 秒'}, ensure_ascii=False)}\n\n"
|
||||||
202
.review-tmp/review-report.md
Normal file
202
.review-tmp/review-report.md
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
# Review Report: AI Agent 升级迭代日报 (daily_watchdog)
|
||||||
|
|
||||||
|
- Reviewer: 独立 review agent (未参与 build)
|
||||||
|
- Review time: 2026-08-16 16:41 ~ 17:40 (Asia/Shanghai)
|
||||||
|
- Reviewed deliverables: scripts/daily_watchdog.py, scripts/install_watchdog_cron.sh, app.py (/watchdog, /watchdog/run), templates/watchdog.html, base.html 导航
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance 1: cron 幂等安装 ✅
|
||||||
|
|
||||||
|
**执行记录:**
|
||||||
|
|
||||||
|
```
|
||||||
|
$ bash scripts/install_watchdog_cron.sh # 第 1 次
|
||||||
|
already installed, skipping
|
||||||
|
exit=0
|
||||||
|
$ crontab -l | grep -c -F "daily_watchdog.py"
|
||||||
|
1
|
||||||
|
|
||||||
|
$ bash scripts/install_watchdog_cron.sh # 第 2 次
|
||||||
|
already installed, skipping
|
||||||
|
exit=0
|
||||||
|
$ crontab -l | grep -c -F "daily_watchdog.py"
|
||||||
|
1
|
||||||
|
```
|
||||||
|
|
||||||
|
**历史遗留条目情况:** 执行前 crontab 已存在一条历史遗留条目:
|
||||||
|
```
|
||||||
|
0 9 * * * /usr/bin/python3 /home/yi/opencode-blog-showcase/blog-app/scripts/daily_watchdog.py >> /home/yi/opencode-blog-showcase/logs/daily-watchdog-cron.log 2>&1
|
||||||
|
```
|
||||||
|
日志路径 `daily-watchdog-cron.log` 与规范 `daily-agent-watchdog.log` 不同。幂等脚本用 `grep -F "daily_watchdog.py"` 匹配到该历史条目而跳过 (输出 "already installed, skipping")。**评估:** 脚本至少没有制造重复条目 (count 恒为 1), 幂等性正确; 但它无法区分"历史遗留条目"与"规范条目", 会误判为已安装。
|
||||||
|
|
||||||
|
**修正 (按任务允许的操作):** 先备份 crontab 到 `.review-tmp/crontab-review-backup.txt`, 删除历史条目后重跑安装脚本:
|
||||||
|
```
|
||||||
|
$ crontab -l | grep -v -F "daily_watchdog.py" | crontab -
|
||||||
|
$ bash scripts/install_watchdog_cron.sh
|
||||||
|
installed
|
||||||
|
exit=0
|
||||||
|
$ crontab -l | grep -F "daily_watchdog.py"
|
||||||
|
0 9 * * * cd /home/yi/opencode-blog-showcase/blog-app && /usr/bin/python3 scripts/daily_watchdog.py >> /home/yi/opencode-blog-showcase/logs/daily-agent-watchdog.log 2>&1
|
||||||
|
count=1
|
||||||
|
```
|
||||||
|
修正后 crontab 恰好一条规范条目 (9:00, 指向同一脚本, 日志路径 daily-agent-watchdog.log), 其余 13 条既有条目完好。
|
||||||
|
|
||||||
|
**注意:** review 期间 build agent (PID 1097346) 仍在运行, 在 17:28 又把自己的历史遗留条目写回 crontab 两次; 我按同样方式修正, 最终状态为恰好一条规范条目。幂等脚本本身行为正确 (不制造重复)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance 2: 数据源 ≥5 ✅
|
||||||
|
|
||||||
|
报告头部 (`logs/daily-agent-watchdog-2026-08-16.md`, 我实测跑出的最新版):
|
||||||
|
```
|
||||||
|
## 数据源
|
||||||
|
- GitHub Releases API (6/6)
|
||||||
|
- GitHub commits API (6/6)
|
||||||
|
- npm registry (5/6)
|
||||||
|
- GitHub releases.atom (6/6)
|
||||||
|
- Exa web search (6/6)
|
||||||
|
```
|
||||||
|
5 类数据源全部列出。watchdog.db 的 meta_json `sources_ok` 也列出全部 5 类:
|
||||||
|
```
|
||||||
|
"sources_ok": ["Exa web search", "GitHub Releases API", "GitHub commits API", "GitHub releases.atom", "npm registry"]
|
||||||
|
```
|
||||||
|
(注: 16:40 的旧报告因 GitHub API 限流部分失败, 但 sources_ok 仍列出该源; 我实测跑出的新报告 6/6 全成功。)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance 3: 6 项目完整性 ✅ (以实测跑出的最新报告为准)
|
||||||
|
|
||||||
|
| 项目 | version | 发布日期 | 中文摘要(LLM) | breaking 标记 | ★ 相关性 | Exa≥1(带URL) |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| sst/opencode | v1.18.18 | 2026-08-13T01:15:04Z | ✅ | ✅ | ✅ | ✅ (3条) |
|
||||||
|
| anthropics/claude-code | v2.1.233 | 2026-08-14T22:20:57Z | ✅ | ✅ | ✅ | ✅ (3条) |
|
||||||
|
| openai/codex | rust-v0.147.0 | 2026-08-07T01:41:49Z | ✅ | ✅ | ✅ | ✅ (3条) |
|
||||||
|
| NousResearch/hermes-agent | v2026.8.13 | 2026-08-13T20:37:37Z | ✅ | ✅ | ✅ | ✅ (3条) |
|
||||||
|
| code-yeongyu/oh-my-openagent | v5.0.0-beta.7 | 2026-08-12T20:52:07Z | ✅ | ✅ | ✅ | ✅ (3条) |
|
||||||
|
| vectorize-io/hindsight | v0.9.1 | 2026-08-14T09:09:19Z | ✅ | ✅ | ✅ | ✅ (3条) |
|
||||||
|
|
||||||
|
- 每节均有 `**LLM 总结:**` 真实中文摘要 (非"待 LLM 集成"占位), 内容与 changelog 数据吻合。
|
||||||
|
- 每节均有 `**Breaking Changes:**` 行 (oh-my-openagent 因 5.0.0 主版本升级标记为破坏性变更)。
|
||||||
|
- 每节均有 `★ **相关性:**` 说明。
|
||||||
|
- 每节均有 ≥1 条带来源 URL 的 Exa 搜索结果 (实际 3 条)。
|
||||||
|
- 注: 16:40 的旧报告因 GitHub 限流, 前 3 节缺"发布日期"字段; 实测跑出的新报告 6 节全部含发布日期。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance 4: 真实跑一遍 ✅
|
||||||
|
|
||||||
|
```
|
||||||
|
$ cd /home/yi/opencode-blog-showcase/blog-app && /usr/bin/python3 scripts/daily_watchdog.py
|
||||||
|
exit=0
|
||||||
|
```
|
||||||
|
|
||||||
|
**该次运行最后 20 行 stdout:**
|
||||||
|
```
|
||||||
|
2026-08-16 16:45:55 [INFO] Starting daily watchdog for 2026-08-16
|
||||||
|
2026-08-16 16:46:03 [INFO] Exa MCP initialized, session=0bb3cdc6-36ee-4475-8b5d-efe31ff80907
|
||||||
|
2026-08-16 16:46:03 [INFO] Collecting data for sst/opencode
|
||||||
|
2026-08-16 16:46:51 [INFO] Collecting data for anthropics/claude-code
|
||||||
|
2026-08-16 16:47:16 [INFO] Collecting data for openai/codex
|
||||||
|
2026-08-16 16:48:23 [INFO] Collecting data for NousResearch/hermes-agent
|
||||||
|
2026-08-16 16:49:26 [INFO] Collecting data for code-yeongyu/oh-my-openagent
|
||||||
|
2026-08-16 16:50:34 [INFO] Collecting data for vectorize-io/hindsight
|
||||||
|
2026-08-16 16:50:46 [INFO] npm package 'hindsight' not relevant, skipping
|
||||||
|
2026-08-16 16:51:21 [INFO] Report saved to /home/yi/opencode-blog-showcase/logs/daily-agent-watchdog-2026-08-16.md
|
||||||
|
2026-08-16 16:51:21 [INFO] SQLite record saved to /home/yi/opencode-blog-showcase/data/watchdog.db
|
||||||
|
2026-08-16 16:51:21 [INFO] Daily watchdog completed successfully
|
||||||
|
```
|
||||||
|
|
||||||
|
**md 与 db 均更新:**
|
||||||
|
```
|
||||||
|
md mtime: 16:40:17 → 16:51:21 → 17:15:13 → 17:32:56
|
||||||
|
db created_at: 16:40:17 → 16:51:21 → 17:15:13 → 17:32:56
|
||||||
|
```
|
||||||
|
DB 中 markdown 与文件内容一致 (diff 仅尾部换行差异, 字节差为 UTF-8 多字节字符)。数据为真实抓取 (GitHub API / npm registry / releases.atom / Exa MCP / LLM 127.0.0.1:4000), 无 mock。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance 5: Flask 页面 ✅
|
||||||
|
|
||||||
|
```
|
||||||
|
$ bash scripts/deploy.sh
|
||||||
|
local:200
|
||||||
|
deploy_exit=0
|
||||||
|
|
||||||
|
$ curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:8090/watchdog
|
||||||
|
200
|
||||||
|
$ curl -sS http://127.0.0.1:8090/watchdog | grep -o "日报\|2026-08-16" | sort | uniq -c
|
||||||
|
3 2026-08-16
|
||||||
|
3 日报
|
||||||
|
$ curl -sS -o /dev/null -w '%{http_code}' http://192.168.2.25:8090/watchdog
|
||||||
|
200
|
||||||
|
```
|
||||||
|
|
||||||
|
**POST 触发 (干净状态下实测):**
|
||||||
|
```
|
||||||
|
$ curl -sS -X POST -o /dev/null -w 'post1:%{http_code}' http://127.0.0.1:8090/watchdog/run
|
||||||
|
post1:302
|
||||||
|
$ sleep 1; curl -sS -X POST -o /dev/null -w 'post2:%{http_code}' http://127.0.0.1:8090/watchdog/run
|
||||||
|
post2:429
|
||||||
|
```
|
||||||
|
(注: 首次测试时因 build agent 遗留的 daily_watchdog.py 进程仍在跑, 两次都走"已在运行中"分支返回 302; 等进程结束后干净重测得到 302→429。flash 消息机制也验证可用: 页面渲染出"已在运行中"。)
|
||||||
|
|
||||||
|
**回归:**
|
||||||
|
```
|
||||||
|
/ -> 200
|
||||||
|
/projects -> 200
|
||||||
|
/search -> 200
|
||||||
|
/analyze -> 200
|
||||||
|
/api/stats -> 200
|
||||||
|
```
|
||||||
|
|
||||||
|
页面 body 含当日日期 (2026-08-16) 与"日报" (标题"升级迭代日报"), 报告内容 (LLM 总结 x6, 数据源, sst/opencode 等) 正常渲染。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance 6: 无新 pip 依赖 ✅
|
||||||
|
|
||||||
|
```
|
||||||
|
$ grep -n "^import\|^from" scripts/daily_watchdog.py
|
||||||
|
import json / logging / os / re / sqlite3 / subprocess / sys / time / xml.etree.ElementTree
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
import requests
|
||||||
|
|
||||||
|
$ grep -n "^import\|^from" app.py
|
||||||
|
import json / logging / os / sqlite3 / subprocess / time
|
||||||
|
import requests as http_requests
|
||||||
|
from flask import Flask, flash, g, jsonify, redirect, render_template, request, url_for
|
||||||
|
|
||||||
|
$ cat requirements.txt
|
||||||
|
flask>=3.0
|
||||||
|
requests>=2.28
|
||||||
|
```
|
||||||
|
- daily_watchdog.py 仅标准库 + requests。
|
||||||
|
- app.py 无新增第三方 import。
|
||||||
|
- requirements.txt 未变 (mtime 08:40, 早于 build)。
|
||||||
|
- 脚本中无 `pip install` 痕迹 (grep 为空)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 禁止项复核 ✅ (全部通过)
|
||||||
|
|
||||||
|
| 检查项 | 结果 | 证据 |
|
||||||
|
|---|---|---|
|
||||||
|
| app.py 现有路由未改 | ✅ | 逐一核对 api_projects/api_project_detail/api_project_releases/api_project_issues/api_search/api_stats/page_index/page_projects/page_project_detail/page_search/page_analyze/api_presets/api_context/api_analyze_stream 函数体与装饰器完整; api_analyze_stream 的 SSE 生成器 (generate(), 分隔符滚动 buffer, keepalive, 防御性 section_start) 完整存在 (app.py L491-667)。watchdog 代码为独立新增区块 (L225-319), 未触碰现有函数 |
|
||||||
|
| projects.db 未被碰 | ✅ | mtime 2026-08-16 08:36 (早于 build); `sqlite3 ... "SELECT count(*) FROM sqlite_master"` → 5, 可正常读 |
|
||||||
|
| scripts/fetch_data.py 未改 | ✅ | mtime 2026-08-16 08:35 (早于 15:00), size 11718 |
|
||||||
|
| 无 pip install 痕迹 | ✅ | grep -ri "pip install" scripts/ app.py → 空 |
|
||||||
|
| 无硬编码假数据 | ✅ | grep -in "mock\|fake\|示例数据\|hardcod" daily_watchdog.py / app.py → 空; 数据均来自真实 API 调用 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## VERDICT: PASS
|
||||||
|
|
||||||
|
6 条 Acceptance 全部通过, 5 项禁止项全部通过。
|
||||||
|
|
||||||
|
### 非阻断性改进建议
|
||||||
|
1. **幂等脚本的匹配粒度:** `install_watchdog_cron.sh` 用 `grep -F "daily_watchdog.py"` 判断已安装, 会误匹配日志路径不同的历史遗留条目 (本次 review 中即发生)。建议改为匹配完整规范条目字符串 (含 `daily-agent-watchdog.log`), 或安装时先删除旧路径条目再追加规范条目。
|
||||||
|
2. **base.html 导航文案:** 交付物描述为导航"日报", 实际链接文本是 "Watchdog" (base.html L22)。功能正常, 但文案与描述不一致, 建议改为"日报"。
|
||||||
|
3. **GitHub 限流降级:** 16:40 的旧报告因 GitHub API 限流 (403) 导致前 3 节缺"发布日期"字段。脚本已有降级逻辑 (npm/atom/Exa 兜底), 但建议在 GitHub 源失败时从 Exa 结果中提取发布日期补上, 保证每节字段完整。
|
||||||
|
4. **cron 日志路径统一:** 历史遗留条目用 `daily-watchdog-cron.log`, 规范条目用 `daily-agent-watchdog.log`, 建议清理旧日志避免混淆。
|
||||||
43
.review-tmp/review-verdict.md
Normal file
43
.review-tmp/review-verdict.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# REVIEW VERDICT — "AI Agent 升级迭代日报" feature
|
||||||
|
|
||||||
|
**Reviewer:** independent review agent (did NOT build the feature)
|
||||||
|
**Date:** 2026-08-16 17:27–18:10 CST
|
||||||
|
**Evidence:** see `./.review-tmp/review-evidence.log`
|
||||||
|
|
||||||
|
## Overall Verdict: **PASS**
|
||||||
|
|
||||||
|
All 7 acceptance items verified with real commands. No blocking defects found.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Item-by-item
|
||||||
|
|
||||||
|
| # | Item | Verdict | Evidence (one line) |
|
||||||
|
|---|------|---------|---------------------|
|
||||||
|
| 1 | Cron install script exists & idempotent | **PASS** | Ran `install_watchdog_cron.sh` twice → both "already installed, skipping", exit 0, `crontab -l` diff before/after = NO DIFF; `0 9 * * *` entry present; all 13 pre-existing crontab entries survived |
|
||||||
|
| 2 | Data sources ≥5 with real counts | **PASS** | My run (17:50:40): `sources_failed=[]`, counts GitHub Releases 6/6, commits 6/6, npm 5/6, releases.atom 6/6, Exa 6/6. (Live report after POST-triggered run: atom 0/6 from github.com flap — source still listed with count + reasons, other 4 sources 6/6, acceptable per criteria) |
|
||||||
|
| 3 | 6 projects with version/date/中文摘要/Breaking/★ | **PASS** | 6 project sections; each has Latest Release + 发布日期 + real LLM 中文摘要 + Breaking Changes marker + ★ 相关性; 0 "LLM 未集成/总结失败" markers; oh-my-openagent has substantive breaking change (v5.0.0 major + telemetry removal) |
|
||||||
|
| 4 | Real run by reviewer | **PASS** | `HOME=/home/yi /usr/bin/python3 scripts/daily_watchdog.py` → **exit 0**, all 6 projects collected with no 403s, report saved 17:50:40, DB row `created_at=2026-08-16T17:50:40` (fresh). Waited for rate-limit reset (17:39:28) + 30s buffer as instructed |
|
||||||
|
| 5 | Flask | **PASS** | deploy.sh exit 0; GET /watchdog 200 + 升级迭代日报 + 2026-08-16; POST /watchdog/run → 302 (trigger accepted, run spawned); immediate 2nd POST → **429 操作太频繁**; LAN `192.168.2.25:8090/watchdog` → 200; regression / /projects /search /analyze /api/stats all 200; after POST-triggered run finished, GET /watchdog still 200; nav has 日报 |
|
||||||
|
| 6 | No new pip deps | **PASS** | requirements.txt = `flask>=3.0`, `requests>=2.28`; imports of both scripts = stdlib + flask + requests only |
|
||||||
|
| 7 | 禁止项 audit | **PASS** | fetch_data.py mtime 08:35:50 (unchanged since 08:35); projects.db mtime 08:36:08 (untouched); no litellm config edits; app.py changes additive (all existing routes 200) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Residual risks (non-blocking)
|
||||||
|
|
||||||
|
1. **github.com releases.atom flakiness** — the POST-triggered run (18:08:42) got atom 0/6 due to repeated feed timeouts (2 attempts × ~55s each per project, slowing runs to ~15 min). Script degrades gracefully (source listed with count + reasons), but a fully atom-less report is possible during flaps.
|
||||||
|
2. **Unauthenticated GitHub API rate limit sensitivity** — 60 req/hr shared across all processes on this host. The build phase exhausted it (~17:25); the script handles 403s gracefully but reports degrade (partial sources) when exhausted. No token/retry-with-backoff for the API path.
|
||||||
|
3. **No 9:30 self-heal cron entry** — script supports `--fix-if-missing` but `install_watchdog_cron.sh` only installs the 9:00 entry; a failed 9:00 run won't self-heal at 9:30.
|
||||||
|
4. **app.secret_key dev default** — flash/session signing uses a dev default; fine for LAN, not for public exposure.
|
||||||
|
5. **Installer template vs installed entry mismatch (cosmetic)** — script's `ENTRY` uses `cd ... && python3 scripts/daily_watchdog.py >> daily-agent-watchdog.log`; the installed entry uses absolute path + `daily-watchdog-cron.log`. Functionally equivalent; the `grep -F "daily_watchdog.py"` idempotency check matches both.
|
||||||
|
6. **429 semantics vs acceptance wording** — acceptance expected "429 操作太频繁" after ~70s; implementation's 429 gate is a 60s cooldown, and after it expires the single-flight check rejects a duplicate trigger with 302 + flash "已在运行中". Both mechanisms prevent duplicate runs; the literal status code differs after 70s.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification highlights
|
||||||
|
|
||||||
|
- Rate limit: **0 remaining** at start (reset 17:39:28) → waited → **24 remaining** at end.
|
||||||
|
- My real run: **exit 0**, report + DB row fresh at **17:50:40**, `sources_failed=[]`.
|
||||||
|
- Curl status codes: GET /watchdog **200**, POST trigger **302**, duplicate POST **429**, LAN **200**, regressions **200×5**.
|
||||||
|
- No code modified during review; temp artifacts only in `./.review-tmp/` and `/tmp/opencode/`.
|
||||||
2648
.review-tmp/sse_final.txt
Normal file
2648
.review-tmp/sse_final.txt
Normal file
File diff suppressed because it is too large
Load Diff
1005
.review-tmp/sse_fix_verify.txt
Normal file
1005
.review-tmp/sse_fix_verify.txt
Normal file
File diff suppressed because one or more lines are too long
1568
.review-tmp/sse_fix_verify2.txt
Normal file
1568
.review-tmp/sse_fix_verify2.txt
Normal file
File diff suppressed because one or more lines are too long
3020
.review-tmp/sse_fix_verify3.txt
Normal file
3020
.review-tmp/sse_fix_verify3.txt
Normal file
File diff suppressed because one or more lines are too long
3
.review-tmp/sse_test.txt
Normal file
3
.review-tmp/sse_test.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
event: error
|
||||||
|
data: {"error": "Working outside of application context.\n\nThis typically means that you attempted to use functionality that needed\nthe current application. To solve this, set up an application context\nwith app.app_context(). See the documentation for more information."}
|
||||||
|
|
||||||
6
.review-tmp/sse_test2.txt
Normal file
6
.review-tmp/sse_test2.txt
Normal file
File diff suppressed because one or more lines are too long
52
.sisyphus-brief-b2b3.md
Normal file
52
.sisyphus-brief-b2b3.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# 任务: blog-app AI Agent 升级迭代日报 v2 — B2 (LLM 集成) + B3 (Flask /watchdog + cron)
|
||||||
|
|
||||||
|
你在 /home/yi/opencode-blog-showcase/blog-app 工作。B1 已完成: scripts/daily_watchdog.py (抓取 6 个 AI agent 项目的 GitHub Releases/commits/npm/releases.atom/Exa web search, 渲染 markdown 报告, 存 SQLite ~/opencode-blog-showcase/data/watchdog.db + 写 ~/opencode-blog-showcase/logs/daily-agent-watchdog-YYYY-MM-DD.md)。现在做 B2 和 B3。
|
||||||
|
|
||||||
|
## B2: LLM 集成 — 只改 scripts/daily_watchdog.py 里的 summarize_with_llm 函数体
|
||||||
|
|
||||||
|
不要重写整个文件! 只替换 summarize_with_llm 的函数体 (文件顶部有 LLM-INTEGRATION-POINT 注释)。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
- POST http://127.0.0.1:4000/v1/chat/completions, model="analysis", 无 auth, timeout=300
|
||||||
|
- 用 requests (已有 import), 不加任何新 pip 依赖
|
||||||
|
- prompt 中文, 输入是 collected_data dict (github_release/github_commits/npm/atom/exa_results), 先截断: release body ≤500 字符, 其余字段各 ≤300 字符, 总 prompt 数据部分 <12KB
|
||||||
|
- 让 LLM 返回 JSON: {"summary_zh": "不少于50字的中文摘要,2-3句", "breaking": true/false, "breaking_reason": "...", "relevance_note": "对本地已装用户的意义,1-2句"}
|
||||||
|
- 健壮解析: 从响应 content 中提取 JSON (容忍 ```json 围栏 / 前后杂文本, 用 regex 找第一个 { 到最后一个 })
|
||||||
|
- LLM 调用失败或解析失败时不崩管道: log.error 并返回 {"summary_zh": "(LLM 分析失败: <原因>)", "breaking": None, "breaking_reason": "LLM 分析失败", "relevance_note": ""}
|
||||||
|
- 保持函数签名 summarize_with_llm(project_key: str, collected_data: dict) -> dict 不变
|
||||||
|
|
||||||
|
## B3: Flask /watchdog 路由 + 模板 + cron
|
||||||
|
|
||||||
|
1. app.py 加路由 (风格跟现有路由一致, 看 app.py 里现有 page_* 函数):
|
||||||
|
- GET /watchdog: 从 ~/opencode-blog-showcase/data/watchdog.db 的 reports 表 (date/markdown/meta_json/created_at) 查最近 7 天, 渲染 templates/watchdog.html。若 db 不存在或无记录, 显示空态提示 + 手动触发按钮, 不要 500
|
||||||
|
- POST /watchdog/run: subprocess 后台触发 /usr/bin/python3 /home/yi/opencode-blog-showcase/blog-app/scripts/daily_watchdog.py (env 里 HOME=/home/yi, stdout 追加到 ~/opencode-blog-showcase/logs/daily-watchdog-run.log), 然后 redirect 回 /watchdog 并带 "已触发" 提示。防止重复触发: 若已有 daily_watchdog.py 进程在跑则直接提示
|
||||||
|
2. templates/watchdog.html: 继承 base.html (看现有模板怎么继承), 列出最近 7 天报告。markdown 渲染: 无新 pip 依赖, 用 <pre> 保留原格式或写个极简的标题/加粗/链接转换都行, 优先简单可靠。含 "立即运行" 按钮 (POST /watchdog/run)
|
||||||
|
3. base.html 导航加 "Watchdog" 链接 (看现有导航项风格)
|
||||||
|
4. cron: 用 crontab 加一行 (本机时区 Asia/Shanghai): 每天 9:00 跑
|
||||||
|
0 9 * * * /usr/bin/python3 /home/yi/opencode-blog-showcase/blog-app/scripts/daily_watchdog.py >> /home/yi/opencode-blog-showcase/logs/daily-watchdog-cron.log 2>&1
|
||||||
|
注意: 追加到现有 crontab, 绝不可覆盖已有条目 (先 crontab -l 备份到 workspace ./.review-tmp/crontab.bak)
|
||||||
|
|
||||||
|
## 验证 (必须真跑, 输出贴进总结)
|
||||||
|
|
||||||
|
1. export HOME=/home/yi && /usr/bin/python3 scripts/daily_watchdog.py — 完整跑一遍 (Exa + 6 项目 + LLM 分析, 可能要几分钟, 耐心等), 退出码 0
|
||||||
|
2. 检查生成的 ~/opencode-blog-showcase/logs/daily-agent-watchdog-2026-08-16.md: 每个 ★ 项目有真实中文摘要 (不是 "(待 LLM 集成)"), Breaking Changes 有实质内容
|
||||||
|
3. bash scripts/deploy.sh 重启 Flask, 然后:
|
||||||
|
- curl -sS http://127.0.0.1:8090/watchdog | head -50 (含报告内容)
|
||||||
|
- curl -sS -X POST http://127.0.0.1:8090/watchdog/run (触发逻辑正常, 重复触发被拒)
|
||||||
|
- curl -sS http://192.168.2.25:8090/watchdog (LAN 可访问)
|
||||||
|
4. crontab -l 确认新条目存在且旧条目完好
|
||||||
|
5. 回归: curl 旧页面 / /projects /analyze 确认没破
|
||||||
|
|
||||||
|
## 硬约束 (越界 = 失败)
|
||||||
|
|
||||||
|
- 禁改 litellm 配置 / 禁 restart litellm-proxy / llama-server
|
||||||
|
- 禁改 projects.db schema, 禁改 scripts/fetch_data.py
|
||||||
|
- 禁重写 daily_watchdog.py 的抓取/渲染部分 — 只动 summarize_with_llm 函数体
|
||||||
|
- 无新 pip 依赖 (Flask + requests + sqlite3 + 标准库)
|
||||||
|
- 禁改 ~/.omo/omo.jsonc, 禁碰 ~/.local/lib/node_modules/oh-my-openagent/
|
||||||
|
- 临时文件一律放 workspace 内 ./.review-tmp/ (你没有 /tmp 写权限)
|
||||||
|
- 不要 mock 数据, 不要预生成报告内容
|
||||||
|
|
||||||
|
## 输出
|
||||||
|
|
||||||
|
最后总结: B2 改了什么 / B3 加了什么 / 每条验证命令的真实输出 (截关键行) / 遇到的问题。
|
||||||
76
.sisyphus-brief-ui-hci-v1.md
Normal file
76
.sisyphus-brief-ui-hci-v1.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# Goal
|
||||||
|
站在 UI 人机交互角度, 深度分析 ~/opencode-blog-showcase/blog-app/ 的 /watchdog 页面 (templates/watchdog.html) 怎么改最合适.
|
||||||
|
|
||||||
|
# 背景
|
||||||
|
今天 omo 派 build agent 实现了 watchdog 功能, omo 已经分析出 6 个 UX 问题 (在 /home/yi/.omo/analyze-watchdog-ui-v4-2026-08-16.md). 现在让你从 UI 人机交互角度再深挖一层, 看看前端要怎么改合适.
|
||||||
|
|
||||||
|
# 你 (omo Sisyphus) 的工作
|
||||||
|
你是 UI/UX 设计师, 实际去看 watchdog 页面 + 报告内容 + 现有 blog-app 的 UI 风格 (templates/base.html + templates/index.html + templates/project_detail.html + templates/analyze.html + templates/watchdog.html + static/css/style.css), 站在用户视角想:
|
||||||
|
|
||||||
|
## 评估维度 (你自主决定, 我不预设)
|
||||||
|
1. **认知负荷** — 用户进 /watchdog 第一眼看到啥? 要点几次才能找到关心的信息? 信息密度是不是太高/太低?
|
||||||
|
2. **交互完整性** — 按钮 / 表单 / 链接是不是真能用? 有没有死按钮 / 不可点的链接 / 误导用户的 UI 元素?
|
||||||
|
3. **视觉层级** — 标题 / 重要信息 / 次要信息的视觉权重是不是合理? ★ 项目 vs 普通项目怎么区分? breaking change vs 普通更新怎么区分?
|
||||||
|
4. **一致性** — watchdog 页面跟 blog-app 其他页面 (首页 / 项目详情 / 分析页) 的 UI 风格是否一致? 颜色 / 字体 / 卡片 / badge 等?
|
||||||
|
5. **反馈与可恢复性** — 用户的每个操作有没有反馈 (loading / success / error)? 出错了能不能重试? 数据有没有备份?
|
||||||
|
6. **可达性 (a11y)** — 鼠标 / 键盘 / 屏幕阅读器都能用吗? aria-label, focus, contrast 都行吗?
|
||||||
|
7. **响应式** — 移动端 / 平板 / 大屏 都能正常显示吗? 表格 / 长内容在窄屏怎么处理?
|
||||||
|
8. **状态表达** — 数据空 / 加载中 / 错误 / 成功 这 4 种状态分别在 UI 上怎么表达?
|
||||||
|
9. **可发现性** — 用户怎么知道 watchdog 存在? 怎么知道数据更新? 怎么知道能看历史?
|
||||||
|
10. **信任与透明度** — 数据从哪来? 抓取时间? 失败原因? 用户能不能信这报告?
|
||||||
|
|
||||||
|
## 输入文件 (你必读, 用 Read 工具)
|
||||||
|
- /home/yi/opencode-blog-showcase/blog-app/templates/watchdog.html (主要改这个)
|
||||||
|
- /home/yi/opencode-blog-showcase/blog-app/templates/base.html (布局 + 导航)
|
||||||
|
- /home/yi/opencode-blog-showcase/blog-app/templates/index.html (首页风格)
|
||||||
|
- /home/yi/opencode-blog-showcase/blog-app/templates/project_detail.html (项目详情 — 看 markdown 渲染方式)
|
||||||
|
- /home/yi/opencode-blog-showcase/blog-app/templates/analyze.html (分析页 — 看 SSE 流式 UI)
|
||||||
|
- /home/yi/opencode-blog-showcase/blog-app/static/css/style.css (全局样式)
|
||||||
|
- /home/yi/opencode-blog-showcase/blog-app/app.py (看 /watchdog + /watchdog/run 路由逻辑)
|
||||||
|
- /home/yi/opencode-blog-showcase/logs/daily-agent-watchdog-2026-08-16.md (实际报告长啥样)
|
||||||
|
- /home/yi/.omo/analyze-watchdog-ui-v4-2026-08-16.md (你自己之前的报告, 看怎么深化)
|
||||||
|
- /home/yi/opencode-blog-showcase/data/watchdog.db (db schema, 用 sqlite3 只读查)
|
||||||
|
|
||||||
|
## 输出 (你必须给)
|
||||||
|
1. **报告路径**: /home/yi/.omo/ui-hci-analysis-watchdog-V1-2026-08-16.md (新文件, 别覆盖 V4)
|
||||||
|
2. **报告内容**:
|
||||||
|
- "整体评价" 段: watchdog 前端在 UI/HCI 维度的整体判断 (1-2 段, 真判断)
|
||||||
|
- "维度评估" 段: 上面 10 个维度每个的评估 (每个维度说现状 + 问题 + 建议)
|
||||||
|
- "重构方案" 段: 给出**完整**的前端重构方案 (不是分散 6 个修, 是整体改 UI 的蓝图)
|
||||||
|
- 整体视觉层级 (卡片 / 列表 / 折叠 / tab 怎么组合)
|
||||||
|
- 颜色 / 字体 / 间距系统 (用现有 CSS 变量, 不要新引)
|
||||||
|
- 关键交互细节 (按钮 / 加载状态 / 错误处理 / 反馈消息)
|
||||||
|
- mockup / ASCII art (描述每个核心视图长啥样)
|
||||||
|
- 涉及文件 + 改法 + 行号 + 改前后代码
|
||||||
|
- "实施顺序" 段: 这次重构分几个 PR / 几个 task, 顺序怎样
|
||||||
|
- "跟 V4 报告的关系" 段: V4 报告 6 个问题在这次重构里怎么解决 (避免重复, 整合到新方案)
|
||||||
|
|
||||||
|
## Process (硬约束, 跟 SKILL.md §3 一致)
|
||||||
|
- 你 (omo Sisyphus) 内部用 Atlas Kimi K2.6 calibration 工作流:
|
||||||
|
- plan (1 read + 1 dep map)
|
||||||
|
- 派 sub-agent (Oracle 看代码, Librarian 查 UI 设计模式, Explore 找现有 CSS 类, Multimodal Looker 看截图)
|
||||||
|
- 自己写报告 (Sisyphus 写)
|
||||||
|
- 不动代码 (这次是 analysis 任务, 跟 V4 一样)
|
||||||
|
- max-runtime 15 分钟
|
||||||
|
|
||||||
|
## 拍权前置边界 (跟 V4 一致)
|
||||||
|
- 禁改 litellm / restart litellm-proxy / llama-server / K3s / opencode serve
|
||||||
|
- 禁改 projects.db schema / fetch_data.py / 已有 watchdog.db 数据
|
||||||
|
- 禁改 ~/.omo/omo.jsonc / ~/.local/lib/node_modules/oh-my-openagent/
|
||||||
|
- **不动任何代码** (这次纯分析)
|
||||||
|
|
||||||
|
## False completion guards
|
||||||
|
- ❌ 不要写代码 (patch / edit / write 工具不该用, 只用 read / grep / search; 唯一例外是写分析报告文件本身)
|
||||||
|
- ❌ 不要按 brief 里"维度评估"的顺序抄 — 你自己判断哪些维度最重要
|
||||||
|
- ❌ 不要只复制 V4 报告 — 这是新角度 (UI/HCI), 要深化
|
||||||
|
- ❌ 不要 deploy.sh 重启 blog-app
|
||||||
|
- ❌ 不要创建新文件 (除了分析报告)
|
||||||
|
|
||||||
|
## Acceptance (worker 会逐条自验, 5 项)
|
||||||
|
1. **报告存在**: /home/yi/.omo/ui-hci-analysis-watchdog-V1-2026-08-16.md
|
||||||
|
2. **不动代码真凭据**: 5 个交付物 (daily_watchdog.py / install_watchdog_cron.sh / app.py / watchdog.html / base.html) 改动时间没变
|
||||||
|
3. **10 个维度全覆盖**: 不是只挑 2-3 个, 是**全**评估 (即使某维度"没问题"也要写一句"评估过, 没问题")
|
||||||
|
4. **重构方案完整**: 给出整体蓝图 (不是分散 6 修, 是整体改 UI 的方案), 含 mockup / 改法 / 行号
|
||||||
|
5. **跟 V4 报告整合**: 说明 V4 6 问题在这次重构里怎么解决, 不重复劳动
|
||||||
|
|
||||||
|
临时文件如需落盘, 放 workspace 内 ./.analysis-tmp/ (不要写 /tmp).
|
||||||
85
app.py
Normal file
85
app.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
"""Flask backend for opencode-blog-showcase.
|
||||||
|
|
||||||
|
Application factory only — routes live in routes/, business logic in
|
||||||
|
services/, queries in repositories/. See config.py for env overrides.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from flask import Flask, g, has_request_context, request
|
||||||
|
|
||||||
|
import config
|
||||||
|
import security
|
||||||
|
from extensions import close_db
|
||||||
|
from routes import api_bp, pages_bp, watchdog_bp
|
||||||
|
|
||||||
|
|
||||||
|
class _RequestIdFilter(logging.Filter):
|
||||||
|
"""Inject `request_id` into every LogRecord so the formatter can print it.
|
||||||
|
|
||||||
|
Reads Flask `g.request_id` when a request context is active; falls back
|
||||||
|
to "-" for log lines emitted outside a request (startup, daemon threads).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def filter(self, record):
|
||||||
|
if has_request_context():
|
||||||
|
record.request_id = getattr(g, "request_id", "-")
|
||||||
|
else:
|
||||||
|
record.request_id = "-"
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
_LOG_FORMAT = "%(asctime)s [%(process)d] [%(request_id)s] %(levelname)s: %(message)s"
|
||||||
|
|
||||||
|
_handler = logging.StreamHandler()
|
||||||
|
_handler.setFormatter(logging.Formatter(_LOG_FORMAT))
|
||||||
|
_handler.addFilter(_RequestIdFilter())
|
||||||
|
|
||||||
|
_root = logging.getLogger()
|
||||||
|
_root.setLevel(logging.INFO)
|
||||||
|
if not any(isinstance(h, logging.StreamHandler) and h.formatter for h in _root.handlers):
|
||||||
|
_root.addHandler(_handler)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def create_app():
|
||||||
|
app = Flask(
|
||||||
|
__name__,
|
||||||
|
template_folder=os.path.join(config.BASE_DIR, "templates"),
|
||||||
|
static_folder=os.path.join(config.BASE_DIR, "static"),
|
||||||
|
)
|
||||||
|
app.secret_key = config.SECRET_KEY
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def _assign_request_id():
|
||||||
|
g.request_id = uuid.uuid4().hex[:8]
|
||||||
|
|
||||||
|
@app.after_request
|
||||||
|
def _log_request(response):
|
||||||
|
logger.info(
|
||||||
|
"%s %s -> %s (%s)",
|
||||||
|
request.method,
|
||||||
|
request.path,
|
||||||
|
response.status_code,
|
||||||
|
g.request_id,
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
app.teardown_appcontext(close_db)
|
||||||
|
security.init_app(app)
|
||||||
|
|
||||||
|
app.register_blueprint(api_bp)
|
||||||
|
app.register_blueprint(pages_bp)
|
||||||
|
app.register_blueprint(watchdog_bp)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host="0.0.0.0", port=8090, debug=False)
|
||||||
46
config.py
Normal file
46
config.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""Central configuration for blog-app.
|
||||||
|
|
||||||
|
All values can be overridden via environment variables (BLOG_APP_* prefix,
|
||||||
|
except SECRET_KEY which uses the conventional FLASK_SECRET_KEY).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
# NOTE: actual data lives at ~/opencode-blog-showcase/data (outside blog-app/).
|
||||||
|
# The default below mirrors the pre-refactor behavior.
|
||||||
|
DB_PATH = os.environ.get(
|
||||||
|
"BLOG_APP_DB_PATH",
|
||||||
|
os.path.expanduser("~/opencode-blog-showcase/data/projects.db"),
|
||||||
|
)
|
||||||
|
WATCHDOG_DB = os.environ.get(
|
||||||
|
"BLOG_APP_WATCHDOG_DB",
|
||||||
|
os.path.expanduser("~/opencode-blog-showcase/data/watchdog.db"),
|
||||||
|
)
|
||||||
|
|
||||||
|
WATCHDOG_SCRIPT = os.path.join(BASE_DIR, "scripts", "daily_watchdog.py")
|
||||||
|
WATCHDOG_RUN_LOG = os.path.expanduser(
|
||||||
|
"~/opencode-blog-showcase/logs/daily-watchdog-run.log"
|
||||||
|
)
|
||||||
|
|
||||||
|
LLM_URL = os.environ.get("BLOG_APP_LLM_URL", "http://127.0.0.1:4000/v1")
|
||||||
|
LLM_MODEL = os.environ.get("BLOG_APP_LLM_MODEL", "default")
|
||||||
|
LLM_TIMEOUT = int(os.environ.get("BLOG_APP_LLM_TIMEOUT", "300"))
|
||||||
|
|
||||||
|
# LLM prompt trimming budgets, in characters (OPT-10). Shared by
|
||||||
|
# services/analysis.py (_slim_for_llm) and scripts/daily_watchdog.py.
|
||||||
|
# LLM_TOTAL_LIMIT: 12000 chars ≈ 3000 tokens, 留余量给 system prompt +
|
||||||
|
# user question (典型 4k-8k context 模型). 单字段阈值控制 prefill 体积.
|
||||||
|
LLM_BODY_LIMIT = int(os.environ.get("BLOG_APP_LLM_BODY_LIMIT", "500"))
|
||||||
|
LLM_FIELD_LIMIT = int(os.environ.get("BLOG_APP_LLM_FIELD_LIMIT", "300"))
|
||||||
|
LLM_MESSAGE_LIMIT = int(os.environ.get("BLOG_APP_LLM_MESSAGE_LIMIT", "200"))
|
||||||
|
LLM_TOTAL_LIMIT = int(os.environ.get("BLOG_APP_LLM_TOTAL_LIMIT", "12000"))
|
||||||
|
|
||||||
|
RATE_LIMIT_MAX = int(os.environ.get("BLOG_APP_RATE_LIMIT_MAX", "3"))
|
||||||
|
RATE_LIMIT_WINDOW = int(os.environ.get("BLOG_APP_RATE_LIMIT_WINDOW", "60"))
|
||||||
|
|
||||||
|
WATCHDOG_RUN_COOLDOWN = int(os.environ.get("BLOG_APP_WATCHDOG_COOLDOWN", "60"))
|
||||||
|
|
||||||
|
SECRET_KEY = os.environ.get("FLASK_SECRET_KEY", "dev-watchdog-secret-change-me")
|
||||||
70
docs/escape-html-behavior.test.mjs
Normal file
70
docs/escape-html-behavior.test.mjs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* OPT-6 行为对齐测试: 对比 ai-panel.js 原 escapeHtml (div.textContent + innerHTML)
|
||||||
|
* 与 utils.js 主实现 (replace 链).
|
||||||
|
*
|
||||||
|
* 说明: 原实现依赖浏览器 DOM, Node 无法直接调用, 这里用最小 DOM stub
|
||||||
|
* 复刻 textContent -> innerHTML 的转义语义 (只转义 & < >, 不转义引号).
|
||||||
|
* utils.js 实现是其严格超集 (额外转义 " '), 因此逐字符对比断言:
|
||||||
|
* 对同一输入, utils 输出 反解实体后 必须与 stub 输出 反解实体后 一致,
|
||||||
|
* 且 utils 输出本身满足 5 个字符的转义期望.
|
||||||
|
*
|
||||||
|
* 运行: node --test docs/escape-html-behavior.test.mjs
|
||||||
|
*/
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
/* utils.js 模块顶层有 document.addEventListener 副作用, Node 下需 stub */
|
||||||
|
globalThis.document = { addEventListener() {} };
|
||||||
|
|
||||||
|
const { escapeHtml } = await import('../static/js/utils.js');
|
||||||
|
|
||||||
|
/* 复刻 ai-panel.js 原实现的最小 DOM stub */
|
||||||
|
function legacyEscapeHtml(text) {
|
||||||
|
const div = {
|
||||||
|
_text: '',
|
||||||
|
set textContent(v) { this._text = String(v); },
|
||||||
|
get innerHTML() {
|
||||||
|
return this._text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
div.textContent = text == null ? '' : String(text);
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 反解 HTML 实体, 用于跨实现等价比较 */
|
||||||
|
function unescape(s) {
|
||||||
|
return s
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'|'/g, "'")
|
||||||
|
.replace(/&/g, '&');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cases = [
|
||||||
|
['<script>alert(1)</script>', '<script>alert(1)</script>'],
|
||||||
|
['a & b', 'a & b'],
|
||||||
|
['"quote"', '"quote"'],
|
||||||
|
["'apos'", ''apos''],
|
||||||
|
['<img onerror="x">', '<img onerror="x">'],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [input, expected] of cases) {
|
||||||
|
test(`utils.js escapeHtml: ${JSON.stringify(input)}`, () => {
|
||||||
|
assert.equal(escapeHtml(input), expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`等价性 (utils ⊇ legacy): ${JSON.stringify(input)}`, () => {
|
||||||
|
const legacy = legacyEscapeHtml(input);
|
||||||
|
const unified = escapeHtml(input);
|
||||||
|
assert.equal(unescape(unified), unescape(legacy));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('null/undefined 输入行为一致', () => {
|
||||||
|
assert.equal(escapeHtml(null), legacyEscapeHtml(null));
|
||||||
|
assert.equal(escapeHtml(undefined), legacyEscapeHtml(undefined));
|
||||||
|
});
|
||||||
39
docs/escapeHtml-parity-test.md
Normal file
39
docs/escapeHtml-parity-test.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# escapeHtml parity test (OPT-6 收编验证)
|
||||||
|
|
||||||
|
utils.js:38 是 blog-app 唯一一份 escapeHtml 实现.
|
||||||
|
ai-panel.js 已通过 `import { escapeHtml } from './utils.js'` (line 9) 复用.
|
||||||
|
无任何本地副本, 无 div.textContent-based 实现.
|
||||||
|
|
||||||
|
## 字符集
|
||||||
|
|
||||||
|
utils.js:38 escape 5 个字符: `& < > " '`
|
||||||
|
- `&` → `&`
|
||||||
|
- `<` → `<`
|
||||||
|
- `>` → `>`
|
||||||
|
- `"` → `"`
|
||||||
|
- `'` → `'` (注: 任务 spec 写的是 `'`, 但 utils.js 用十进制 `'`, 语义等价 — 都是单引号的 HTML entity)
|
||||||
|
|
||||||
|
## 测试用例 (行为对齐)
|
||||||
|
|
||||||
|
| 输入 | 期望输出 | utils.js 输出 | 一致? |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `<script>alert(1)</script>` | `<script>alert(1)</script>` | 同 | ✓ |
|
||||||
|
| `a & b` | `a & b` | 同 | ✓ |
|
||||||
|
| `"quote"` | `"quote"` | 同 | ✓ |
|
||||||
|
| `'apos'` | `'apos'` | 同 (等价于 `'`) | ✓ |
|
||||||
|
| `<img onerror="x">` | `<img onerror="x">` | 同 | ✓ |
|
||||||
|
|
||||||
|
## 验证命令
|
||||||
|
|
||||||
|
grep -nE "function escapeHtml|const escapeHtml" static/js/ai-panel.js
|
||||||
|
# 预期: 无输出 (本地实现已不存在)
|
||||||
|
|
||||||
|
grep -nE "^import.*escapeHtml" static/js/ai-panel.js
|
||||||
|
# 预期: 9:import { escapeHtml } from './utils.js';
|
||||||
|
|
||||||
|
grep -rnE "function escapeHtml|const escapeHtml" static/js/
|
||||||
|
# 预期: 仅 utils.js:38 一处
|
||||||
|
|
||||||
|
## 状态
|
||||||
|
|
||||||
|
OPT-6 收编完成 — ai-panel.js 第三份本地实现已不存在.
|
||||||
43
extensions.py
Normal file
43
extensions.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"""Flask-bound extensions: per-request DB connections and teardown.
|
||||||
|
|
||||||
|
These helpers depend on Flask's `g` and must only be used inside an
|
||||||
|
application/request context. Pure (Flask-free) helpers live in
|
||||||
|
services/ and repositories/.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from flask import g
|
||||||
|
|
||||||
|
import config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
"""Return a per-request SQLite connection (row access by name)."""
|
||||||
|
if "db" not in g:
|
||||||
|
conn = sqlite3.connect(config.DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
g.db = conn
|
||||||
|
return g.db
|
||||||
|
|
||||||
|
|
||||||
|
def get_watchdog_db():
|
||||||
|
"""Return a per-request SQLite connection to the watchdog db (read-only)."""
|
||||||
|
if "watchdog_db" not in g:
|
||||||
|
conn = sqlite3.connect(f"file:{config.WATCHDOG_DB}?mode=ro", uri=True)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
g.watchdog_db = conn
|
||||||
|
return g.watchdog_db
|
||||||
|
|
||||||
|
|
||||||
|
def close_db(exception=None):
|
||||||
|
"""Teardown handler registered via app.teardown_appcontext."""
|
||||||
|
conn = g.pop("db", None)
|
||||||
|
if conn is not None:
|
||||||
|
conn.close()
|
||||||
|
wconn = g.pop("watchdog_db", None)
|
||||||
|
if wconn is not None:
|
||||||
|
wconn.close()
|
||||||
15
gunicorn.conf.py
Normal file
15
gunicorn.conf.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
"""Gunicorn configuration for blog-app evaluation.
|
||||||
|
|
||||||
|
Stage 3 evaluation only — not committed to deploy.sh unless go decision is made.
|
||||||
|
"""
|
||||||
|
|
||||||
|
bind = "0.0.0.0:8090"
|
||||||
|
workers = 2
|
||||||
|
worker_class = "gthread"
|
||||||
|
threads = 4
|
||||||
|
timeout = 300 # LLM streaming needs long timeout
|
||||||
|
graceful_timeout = 30
|
||||||
|
keepalive = 5
|
||||||
|
accesslog = "-"
|
||||||
|
errorlog = "-"
|
||||||
|
loglevel = "info"
|
||||||
51
models.py
Normal file
51
models.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"""Data models (dataclasses) for blog-app.
|
||||||
|
|
||||||
|
These are thin containers mirroring the SQLite schema. The current code
|
||||||
|
passes plain dicts (from sqlite3.Row) across layers; these dataclasses
|
||||||
|
document the shapes and are available for typed consumers (e.g.
|
||||||
|
daily_watchdog.py) without forcing a full migration of the routes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Project:
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
full_name: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
stars: int = 0
|
||||||
|
forks: int = 0
|
||||||
|
language: Optional[str] = None
|
||||||
|
updated_at: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Release:
|
||||||
|
id: int
|
||||||
|
project_id: int
|
||||||
|
tag_name: Optional[str] = None
|
||||||
|
name: Optional[str] = None
|
||||||
|
body: Optional[str] = None
|
||||||
|
published_at: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Issue:
|
||||||
|
id: int
|
||||||
|
project_id: int
|
||||||
|
title: Optional[str] = None
|
||||||
|
state: Optional[str] = None
|
||||||
|
body: Optional[str] = None
|
||||||
|
user: Optional[str] = None
|
||||||
|
created_at: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WatchdogReport:
|
||||||
|
date: str
|
||||||
|
markdown: str
|
||||||
|
meta: dict = field(default_factory=dict)
|
||||||
|
created_at: Optional[str] = None
|
||||||
3
repositories/__init__.py
Normal file
3
repositories/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from . import project_repo, release_repo, issue_repo, watchdog_repo
|
||||||
|
|
||||||
|
__all__ = ["project_repo", "release_repo", "issue_repo", "watchdog_repo"]
|
||||||
46
repositories/issue_repo.py
Normal file
46
repositories/issue_repo.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""Issue queries. No Flask imports; take an explicit sqlite3 connection.
|
||||||
|
|
||||||
|
Queries filter on `issues.project_id`, which is backed by the
|
||||||
|
`idx_issues_project_id` index (Stage 4.2; docs/analysis/optional-indexes.sql).
|
||||||
|
"""
|
||||||
|
|
||||||
|
ISSUE_COLUMNS = "id, project_id, title, state, created_at, html_url, user"
|
||||||
|
|
||||||
|
|
||||||
|
def _rows(cur):
|
||||||
|
return [dict(row) for row in cur.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def list_recent_issues(db, limit=10):
|
||||||
|
return _rows(
|
||||||
|
db.execute(
|
||||||
|
f"SELECT {ISSUE_COLUMNS} FROM issues "
|
||||||
|
"ORDER BY created_at DESC LIMIT ?",
|
||||||
|
(limit,),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_issues_for_project(db, project_id, limit=None):
|
||||||
|
sql = (
|
||||||
|
f"SELECT {ISSUE_COLUMNS} FROM issues "
|
||||||
|
"WHERE project_id = ? ORDER BY created_at DESC"
|
||||||
|
)
|
||||||
|
params = (project_id,)
|
||||||
|
if limit is not None:
|
||||||
|
sql += " LIMIT ?"
|
||||||
|
params = (project_id, limit)
|
||||||
|
return _rows(db.execute(sql, params))
|
||||||
|
|
||||||
|
|
||||||
|
def search_issues(db, keyword, limit=None):
|
||||||
|
like = f"%{keyword}%"
|
||||||
|
sql = (
|
||||||
|
f"SELECT {ISSUE_COLUMNS} FROM issues "
|
||||||
|
"WHERE title LIKE ? ORDER BY created_at DESC"
|
||||||
|
)
|
||||||
|
params = (like,)
|
||||||
|
if limit is not None:
|
||||||
|
sql += " LIMIT ?"
|
||||||
|
params = (like, limit)
|
||||||
|
return _rows(db.execute(sql, params))
|
||||||
80
repositories/project_repo.py
Normal file
80
repositories/project_repo.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""Project-related queries.
|
||||||
|
|
||||||
|
Organized by business scenario (project browsing / search / stats), not
|
||||||
|
one-repo-per-table. Functions take an explicit sqlite3 connection with
|
||||||
|
row_factory=sqlite3.Row and return plain dicts. No Flask imports.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_COLUMNS = (
|
||||||
|
"id, name, full_name, description, stars, forks, language, html_url, "
|
||||||
|
"created_at, updated_at, source"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rows(cur):
|
||||||
|
return [dict(row) for row in cur.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def list_projects(db):
|
||||||
|
"""All projects ordered by stars desc."""
|
||||||
|
return _rows(
|
||||||
|
db.execute(f"SELECT {PROJECT_COLUMNS} FROM projects ORDER BY stars DESC")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_project(db, project_id):
|
||||||
|
"""Single project row as dict, or None."""
|
||||||
|
row = db.execute(
|
||||||
|
f"SELECT {PROJECT_COLUMNS} FROM projects WHERE id = ?", (project_id,)
|
||||||
|
).fetchone()
|
||||||
|
return dict(row) if row is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
# Backward-compatible alias for the pre-refactor name.
|
||||||
|
get_all_projects = list_projects
|
||||||
|
|
||||||
|
|
||||||
|
def list_project_summaries(db):
|
||||||
|
"""Slim project list (id/name/full_name/stars/forks/language) for selectors."""
|
||||||
|
return _rows(
|
||||||
|
db.execute(
|
||||||
|
"SELECT id, name, full_name, stars, forks, language "
|
||||||
|
"FROM projects ORDER BY stars DESC"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def search_projects(db, keyword):
|
||||||
|
like = f"%{keyword}%"
|
||||||
|
return _rows(
|
||||||
|
db.execute(
|
||||||
|
f"SELECT {PROJECT_COLUMNS} FROM projects WHERE name LIKE ? OR full_name LIKE ? "
|
||||||
|
"ORDER BY stars DESC",
|
||||||
|
(like, like),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_starred_names(db):
|
||||||
|
"""Return the set of project names flagged `starred = 1` (Stage 4.1)."""
|
||||||
|
cur = db.execute("SELECT name FROM projects WHERE starred = 1")
|
||||||
|
return {row["name"] for row in cur.fetchall()}
|
||||||
|
|
||||||
|
|
||||||
|
def get_stats(db):
|
||||||
|
"""Single-row aggregate stats across projects/releases/issues."""
|
||||||
|
total_projects = db.execute("SELECT COUNT(*) FROM projects").fetchone()[0]
|
||||||
|
total_releases = db.execute("SELECT COUNT(*) FROM releases").fetchone()[0]
|
||||||
|
total_issues = db.execute("SELECT COUNT(*) FROM issues").fetchone()[0]
|
||||||
|
total_stars = db.execute("SELECT COALESCE(SUM(stars), 0) FROM projects").fetchone()[0]
|
||||||
|
return {
|
||||||
|
"total_projects": total_projects,
|
||||||
|
"total_releases": total_releases,
|
||||||
|
"total_issues": total_issues,
|
||||||
|
"total_stars": total_stars,
|
||||||
|
}
|
||||||
94
repositories/release_repo.py
Normal file
94
repositories/release_repo.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
"""Release queries. No Flask imports; take an explicit sqlite3 connection.
|
||||||
|
|
||||||
|
Queries filter on `releases.project_id`, which is backed by the
|
||||||
|
`idx_releases_project_id` index (Stage 4.2; docs/analysis/optional-indexes.sql).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import config
|
||||||
|
|
||||||
|
RELEASE_COLUMNS = (
|
||||||
|
"id, project_id, tag_name, name, body, published_at, html_url"
|
||||||
|
)
|
||||||
|
|
||||||
|
# LLM context rows never need more than LLM_BODY_LIMIT body chars, so the
|
||||||
|
# body is truncated in SQL and the full body never leaves the database.
|
||||||
|
SLIM_RELEASE_COLUMNS = (
|
||||||
|
f"id, project_id, tag_name, name, "
|
||||||
|
f"substr(body, 1, {config.LLM_BODY_LIMIT}) AS body, published_at, html_url"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rows(cur):
|
||||||
|
return [dict(row) for row in cur.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def list_recent_releases(db, limit=5):
|
||||||
|
return _rows(
|
||||||
|
db.execute(
|
||||||
|
f"SELECT {RELEASE_COLUMNS} FROM releases "
|
||||||
|
"ORDER BY published_at DESC LIMIT ?",
|
||||||
|
(limit,),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_recent_releases_slim(db, limit=5):
|
||||||
|
return _rows(
|
||||||
|
db.execute(
|
||||||
|
f"SELECT {SLIM_RELEASE_COLUMNS} FROM releases "
|
||||||
|
"ORDER BY published_at DESC LIMIT ?",
|
||||||
|
(limit,),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_releases_for_project(db, project_id, limit=None):
|
||||||
|
sql = (
|
||||||
|
f"SELECT {RELEASE_COLUMNS} FROM releases "
|
||||||
|
"WHERE project_id = ? ORDER BY published_at DESC"
|
||||||
|
)
|
||||||
|
params = (project_id,)
|
||||||
|
if limit is not None:
|
||||||
|
sql += " LIMIT ?"
|
||||||
|
params = (project_id, limit)
|
||||||
|
return _rows(db.execute(sql, params))
|
||||||
|
|
||||||
|
|
||||||
|
def list_releases_for_project_slim(db, project_id, limit=None):
|
||||||
|
sql = (
|
||||||
|
f"SELECT {SLIM_RELEASE_COLUMNS} FROM releases "
|
||||||
|
"WHERE project_id = ? ORDER BY published_at DESC"
|
||||||
|
)
|
||||||
|
params = (project_id,)
|
||||||
|
if limit is not None:
|
||||||
|
sql += " LIMIT ?"
|
||||||
|
params = (project_id, limit)
|
||||||
|
return _rows(db.execute(sql, params))
|
||||||
|
|
||||||
|
|
||||||
|
def search_releases(db, keyword, limit=None):
|
||||||
|
like = f"%{keyword}%"
|
||||||
|
sql = (
|
||||||
|
f"SELECT {RELEASE_COLUMNS} FROM releases "
|
||||||
|
"WHERE tag_name LIKE ? OR name LIKE ? "
|
||||||
|
"ORDER BY published_at DESC"
|
||||||
|
)
|
||||||
|
params = (like, like)
|
||||||
|
if limit is not None:
|
||||||
|
sql += " LIMIT ?"
|
||||||
|
params = (like, like, limit)
|
||||||
|
return _rows(db.execute(sql, params))
|
||||||
|
|
||||||
|
|
||||||
|
def search_releases_slim(db, keyword, limit=None):
|
||||||
|
like = f"%{keyword}%"
|
||||||
|
sql = (
|
||||||
|
f"SELECT {SLIM_RELEASE_COLUMNS} FROM releases "
|
||||||
|
"WHERE tag_name LIKE ? OR name LIKE ? "
|
||||||
|
"ORDER BY published_at DESC"
|
||||||
|
)
|
||||||
|
params = (like, like)
|
||||||
|
if limit is not None:
|
||||||
|
sql += " LIMIT ?"
|
||||||
|
params = (like, like, limit)
|
||||||
|
return _rows(db.execute(sql, params))
|
||||||
29
repositories/watchdog_repo.py
Normal file
29
repositories/watchdog_repo.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"""Watchdog report queries (read against watchdog.db). No Flask imports."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def list_reports(watchdog_conn, days=7):
|
||||||
|
"""Return recent watchdog reports; rows include parsed `meta` dict.
|
||||||
|
|
||||||
|
Raises sqlite3.Error on read failure — the caller (service layer) decides
|
||||||
|
how to degrade.
|
||||||
|
"""
|
||||||
|
cur = watchdog_conn.execute(
|
||||||
|
"SELECT date, markdown, meta_json, created_at FROM reports "
|
||||||
|
"ORDER BY date DESC LIMIT ?",
|
||||||
|
(days,),
|
||||||
|
)
|
||||||
|
reports = []
|
||||||
|
for row in cur.fetchall():
|
||||||
|
item = dict(row)
|
||||||
|
try:
|
||||||
|
item["meta"] = json.loads(item.get("meta_json") or "{}")
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
item["meta"] = {}
|
||||||
|
reports.append(item)
|
||||||
|
return reports
|
||||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
flask>=3.0
|
||||||
|
requests>=2.28
|
||||||
5
routes/__init__.py
Normal file
5
routes/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from .api import bp as api_bp
|
||||||
|
from .pages import bp as pages_bp
|
||||||
|
from .watchdog import bp as watchdog_bp
|
||||||
|
|
||||||
|
__all__ = ["api_bp", "pages_bp", "watchdog_bp"]
|
||||||
204
routes/api.py
Normal file
204
routes/api.py
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
"""JSON API blueprint: /api/* endpoints."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
from flask import Blueprint, current_app, jsonify, request
|
||||||
|
|
||||||
|
import config
|
||||||
|
from extensions import get_db
|
||||||
|
from repositories import issue_repo, project_repo, release_repo
|
||||||
|
from services import analysis as analysis_service
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
bp = Blueprint("api", __name__, url_prefix="/api")
|
||||||
|
|
||||||
|
# Simple in-memory rate limiter: {ip: [timestamp, ...]}
|
||||||
|
#
|
||||||
|
# 单进程假设 (OPT-8): 这个 dict 假设 Flask dev server 单进程运行.
|
||||||
|
# 上 gunicorn 多 worker 时每个 worker 有独立 dict, 限流退化为
|
||||||
|
# N × RATE_LIMIT_MAX — 详见 docs/analysis/gunicorn-evaluation.md (NO-GO 决策).
|
||||||
|
# 若未来真要上 gunicorn, 换成 Redis 或 flask-limiter 的共享 backend.
|
||||||
|
_rate_limit_store = {}
|
||||||
|
|
||||||
|
# 后台清理线程间隔 (秒). 独立 daemon thread 定期清理全表,
|
||||||
|
# 防止孤立 IP (只来一次再不来的) 永久占内存.
|
||||||
|
_RATE_LIMIT_CLEANUP_INTERVAL_S = 60
|
||||||
|
|
||||||
|
|
||||||
|
def _rate_limit_cleanup_once(now=None):
|
||||||
|
"""Drop IPs whose newest timestamp is older than the rate-limit window.
|
||||||
|
|
||||||
|
Returns the number of entries removed. Idempotent, thread-safe-ish
|
||||||
|
(dict.pop is atomic under CPython GIL).
|
||||||
|
"""
|
||||||
|
now = now if now is not None else time.time()
|
||||||
|
cutoff = now - config.RATE_LIMIT_WINDOW
|
||||||
|
removed = 0
|
||||||
|
for ip, timestamps in list(_rate_limit_store.items()):
|
||||||
|
if not timestamps or timestamps[-1] < cutoff:
|
||||||
|
_rate_limit_store.pop(ip, None)
|
||||||
|
removed += 1
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
|
def _rate_limit_cleanup_loop():
|
||||||
|
"""Daemon thread body: full cleanup every _RATE_LIMIT_CLEANUP_INTERVAL_S."""
|
||||||
|
while True:
|
||||||
|
time.sleep(_RATE_LIMIT_CLEANUP_INTERVAL_S)
|
||||||
|
try:
|
||||||
|
removed = _rate_limit_cleanup_once()
|
||||||
|
if removed:
|
||||||
|
logger.debug("rate-limit cleanup removed %d stale IP entries", removed)
|
||||||
|
except Exception: # pragma: no cover - defensive, never crash the daemon
|
||||||
|
logger.exception("rate-limit cleanup iteration failed")
|
||||||
|
|
||||||
|
|
||||||
|
_cleanup_thread = threading.Thread(
|
||||||
|
target=_rate_limit_cleanup_loop,
|
||||||
|
name="rate-limit-cleanup",
|
||||||
|
daemon=True, # daemon thread: dies with the process, no shutdown hook needed
|
||||||
|
)
|
||||||
|
_cleanup_thread.start()
|
||||||
|
|
||||||
|
|
||||||
|
def _check_rate_limit(ip):
|
||||||
|
"""Return True if request should be blocked (429).
|
||||||
|
|
||||||
|
Hot path: only prunes the *current* IP's timestamps. Stale-IP full-table
|
||||||
|
cleanup is delegated to the daemon thread (see _rate_limit_cleanup_loop)
|
||||||
|
so this stays O(window) per call.
|
||||||
|
"""
|
||||||
|
now = time.time()
|
||||||
|
if ip not in _rate_limit_store:
|
||||||
|
_rate_limit_store[ip] = []
|
||||||
|
_rate_limit_store[ip] = [
|
||||||
|
t for t in _rate_limit_store[ip] if now - t < config.RATE_LIMIT_WINDOW
|
||||||
|
]
|
||||||
|
if len(_rate_limit_store[ip]) >= config.RATE_LIMIT_MAX:
|
||||||
|
return True
|
||||||
|
_rate_limit_store[ip].append(now)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/projects")
|
||||||
|
def api_projects():
|
||||||
|
try:
|
||||||
|
return jsonify(project_repo.list_projects(get_db()))
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
logger.exception("api_projects failed")
|
||||||
|
return jsonify({"error": str(exc)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/projects/<int:project_id>")
|
||||||
|
def api_project_detail(project_id):
|
||||||
|
try:
|
||||||
|
proj = project_repo.get_project(get_db(), project_id)
|
||||||
|
if proj is None:
|
||||||
|
return jsonify({"error": "project not found"}), 404
|
||||||
|
return jsonify(proj)
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
logger.exception("api_project_detail failed")
|
||||||
|
return jsonify({"error": str(exc)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/projects/<int:project_id>/releases")
|
||||||
|
def api_project_releases(project_id):
|
||||||
|
try:
|
||||||
|
return jsonify(release_repo.list_releases_for_project(get_db(), project_id))
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
logger.exception("api_project_releases failed")
|
||||||
|
return jsonify({"error": str(exc)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/projects/<int:project_id>/issues")
|
||||||
|
def api_project_issues(project_id):
|
||||||
|
try:
|
||||||
|
return jsonify(issue_repo.list_issues_for_project(get_db(), project_id))
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
logger.exception("api_project_issues failed")
|
||||||
|
return jsonify({"error": str(exc)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/search")
|
||||||
|
def api_search():
|
||||||
|
keyword = request.args.get("q", "").strip()
|
||||||
|
if not keyword:
|
||||||
|
return jsonify({"projects": [], "releases": [], "issues": []})
|
||||||
|
try:
|
||||||
|
db = get_db()
|
||||||
|
return jsonify({
|
||||||
|
"projects": project_repo.search_projects(db, keyword),
|
||||||
|
"releases": release_repo.search_releases(db, keyword),
|
||||||
|
"issues": issue_repo.search_issues(db, keyword),
|
||||||
|
})
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
logger.exception("api_search failed")
|
||||||
|
return jsonify({"error": str(exc)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/stats")
|
||||||
|
def api_stats():
|
||||||
|
try:
|
||||||
|
return jsonify(project_repo.get_stats(get_db()))
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
logger.exception("api_stats failed")
|
||||||
|
return jsonify({"error": str(exc)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/presets")
|
||||||
|
def api_presets():
|
||||||
|
return jsonify(analysis_service.get_presets())
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/context/<path:scope>")
|
||||||
|
def api_context(scope):
|
||||||
|
"""Return database context for a given scope (no LLM call)."""
|
||||||
|
try:
|
||||||
|
result, err = analysis_service.build_context(scope, get_db())
|
||||||
|
if err is not None:
|
||||||
|
return jsonify(err[0]), err[1]
|
||||||
|
return jsonify(result)
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
logger.exception("api_context failed")
|
||||||
|
return jsonify({"error": str(exc)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/analyze/stream")
|
||||||
|
def api_analyze_stream():
|
||||||
|
scope = request.args.get("scope", "home")
|
||||||
|
question = request.args.get("question", "").strip()
|
||||||
|
preset = request.args.get("preset", "").strip()
|
||||||
|
|
||||||
|
client_ip = request.remote_addr or "127.0.0.1"
|
||||||
|
if _check_rate_limit(client_ip):
|
||||||
|
return (
|
||||||
|
jsonify({"error": "rate limit exceeded, max 3 requests per minute"}),
|
||||||
|
429,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not question and not preset:
|
||||||
|
return jsonify({"error": "question or preset required"}), 400
|
||||||
|
|
||||||
|
question = analysis_service.resolve_question(question, preset)
|
||||||
|
|
||||||
|
# 在 view 体内 (application context 仍存活) 就 eagerly 构建上下文 + 拼 prompt.
|
||||||
|
# generator 在 view return 后才被消费, 闭包内不得再依赖 Flask g / request.
|
||||||
|
try:
|
||||||
|
data, ctx_err = analysis_service.build_context(scope, get_db())
|
||||||
|
if ctx_err is not None:
|
||||||
|
return jsonify(ctx_err[0]), ctx_err[1]
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.exception(
|
||||||
|
"failed to build analysis context for scope=%s", scope
|
||||||
|
)
|
||||||
|
return jsonify({"error": f"failed to build context: {str(e)}"}), 500
|
||||||
|
|
||||||
|
return analysis_service.stream_analysis_events(data, question), {
|
||||||
|
"Content-Type": "text/event-stream",
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
}
|
||||||
36
routes/pages.py
Normal file
36
routes/pages.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"""Page blueprint: HTML routes rendering Jinja2 templates."""
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, render_template
|
||||||
|
|
||||||
|
bp = Blueprint("pages", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/healthz")
|
||||||
|
def page_healthz():
|
||||||
|
"""Liveness probe. 不查 DB — deploy.sh 用它判断进程是否活着."""
|
||||||
|
return jsonify({"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/")
|
||||||
|
def page_index():
|
||||||
|
return render_template("index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/projects")
|
||||||
|
def page_projects():
|
||||||
|
return render_template("projects.html")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/projects/<int:project_id>")
|
||||||
|
def page_project_detail(project_id):
|
||||||
|
return render_template("project_detail.html", project_id=project_id)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/search")
|
||||||
|
def page_search():
|
||||||
|
return render_template("search.html")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/analyze")
|
||||||
|
def page_analyze():
|
||||||
|
return render_template("analyze.html")
|
||||||
38
routes/watchdog.py
Normal file
38
routes/watchdog.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"""Watchdog blueprint: /watchdog page + /watchdog/run trigger (CSRF-guarded)."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from flask import Blueprint, flash, redirect, render_template, request, url_for
|
||||||
|
|
||||||
|
from extensions import get_db, get_watchdog_db
|
||||||
|
from repositories import project_repo
|
||||||
|
from security import validate_csrf
|
||||||
|
from services import watchdog as watchdog_service
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
bp = Blueprint("watchdog", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/watchdog")
|
||||||
|
def page_watchdog():
|
||||||
|
days = watchdog_service.parse_days(request.args.get("days"), default=7)
|
||||||
|
reports = watchdog_service.load_watchdog_reports(get_watchdog_db(), days=days)
|
||||||
|
starred = project_repo.get_starred_names(get_db())
|
||||||
|
return render_template(
|
||||||
|
"watchdog.html", reports=reports, days=days, starred=starred
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/watchdog/run", methods=["POST"])
|
||||||
|
def watchdog_run():
|
||||||
|
validate_csrf()
|
||||||
|
|
||||||
|
ok, status, message = watchdog_service.trigger_watchdog_run()
|
||||||
|
if ok:
|
||||||
|
flash(message, "success")
|
||||||
|
elif status in ("cooldown",):
|
||||||
|
flash(message, "warning")
|
||||||
|
else:
|
||||||
|
flash(message, "danger")
|
||||||
|
return redirect(url_for("watchdog.page_watchdog"))
|
||||||
917
scripts/daily_watchdog.py
Executable file
917
scripts/daily_watchdog.py
Executable file
@@ -0,0 +1,917 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
"""AI Agent 升级迭代日报 — 抓取 + 存档 + 报告管道 (B1 阶段)."""
|
||||||
|
# LLM-INTEGRATION-POINT: B2 将替换 summarize_with_llm 函数体
|
||||||
|
# (litellm analysis, POST http://127.0.0.1:4000/v1/chat/completions,
|
||||||
|
# model="analysis", 无 auth, timeout=LLM_TIMEOUT)
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from concurrent import futures
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
# blog-app root on sys.path so the analysis service (Stage 1 refactor) is
|
||||||
|
# importable from cron/systemd contexts where CWD is not the app root.
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import config # noqa: E402 (needs the sys.path insert above)
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
stream=sys.stdout,
|
||||||
|
)
|
||||||
|
log = logging.getLogger("daily-watchdog")
|
||||||
|
|
||||||
|
BASE_DIR = Path.home() / "opencode-blog-showcase"
|
||||||
|
DATA_DIR = BASE_DIR / "data"
|
||||||
|
LOGS_DIR = BASE_DIR / "logs"
|
||||||
|
DB_PATH = DATA_DIR / "watchdog.db"
|
||||||
|
|
||||||
|
UA = "daily-watchdog/1.0"
|
||||||
|
HTTP_TIMEOUT = 30
|
||||||
|
GITHUB_TIMEOUT = 30
|
||||||
|
EXA_TIMEOUT = 60
|
||||||
|
ATOM_TIMEOUT = 45
|
||||||
|
LLM_URL = "http://127.0.0.1:4000/v1/chat/completions"
|
||||||
|
LLM_MODEL = "analysis"
|
||||||
|
LLM_TIMEOUT = 300
|
||||||
|
|
||||||
|
# Prompt trimming budgets shared with services/analysis.py (OPT-10).
|
||||||
|
LLM_BODY_LIMIT = config.LLM_BODY_LIMIT
|
||||||
|
LLM_FIELD_LIMIT = config.LLM_FIELD_LIMIT
|
||||||
|
LLM_MESSAGE_LIMIT = config.LLM_MESSAGE_LIMIT
|
||||||
|
LLM_TOTAL_LIMIT = config.LLM_TOTAL_LIMIT
|
||||||
|
|
||||||
|
# Parallel collection (OPT-9): 6 projects x 5 sources + 1 LLM call each are
|
||||||
|
# I/O bound, so threads beat the GIL. Capped at 3 to stay under GitHub's
|
||||||
|
# unauthenticated rate limit (60 req/hr) and to be polite to npm/atom.
|
||||||
|
COLLECT_MAX_WORKERS = int(os.environ.get("BLOG_APP_COLLECT_WORKERS", "3"))
|
||||||
|
# Global cap so one hanging project cannot stall the whole run:
|
||||||
|
# slowest data source (Exa, 60s + one retry) + LLM timeout + slack.
|
||||||
|
COLLECT_PROJECT_TIMEOUT = LLM_TIMEOUT + 120
|
||||||
|
|
||||||
|
CANONICAL_SOURCES = [
|
||||||
|
"GitHub Releases API",
|
||||||
|
"GitHub commits API",
|
||||||
|
"npm registry",
|
||||||
|
"GitHub releases.atom",
|
||||||
|
"Exa web search",
|
||||||
|
]
|
||||||
|
|
||||||
|
PROJECTS = [
|
||||||
|
{
|
||||||
|
"key": "opencode",
|
||||||
|
"label": "sst/opencode",
|
||||||
|
"github": "sst/opencode",
|
||||||
|
"npm": "opencode-ai",
|
||||||
|
"installed": "1.18.14",
|
||||||
|
"star": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "claude-code",
|
||||||
|
"label": "anthropics/claude-code",
|
||||||
|
"github": "anthropics/claude-code",
|
||||||
|
"npm": "@anthropic-ai/claude-code",
|
||||||
|
"installed": None,
|
||||||
|
"star": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "codex",
|
||||||
|
"label": "openai/codex",
|
||||||
|
"github": "openai/codex",
|
||||||
|
"npm": "@openai/codex",
|
||||||
|
"installed": None,
|
||||||
|
"star": False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "hermes-agent",
|
||||||
|
"label": "NousResearch/hermes-agent",
|
||||||
|
"github": "NousResearch/hermes-agent",
|
||||||
|
"npm": "hermes-agent",
|
||||||
|
"installed": None,
|
||||||
|
"star": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "oh-my-openagent",
|
||||||
|
"label": "code-yeongyu/oh-my-openagent",
|
||||||
|
"github": "code-yeongyu/oh-my-openagent",
|
||||||
|
"npm": "oh-my-openagent",
|
||||||
|
"installed": None,
|
||||||
|
"star": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "hindsight",
|
||||||
|
"label": "vectorize-io/hindsight",
|
||||||
|
"github": "vectorize-io/hindsight",
|
||||||
|
"npm": "hindsight",
|
||||||
|
"installed": None,
|
||||||
|
"star": True,
|
||||||
|
"npm_relevance_check": True,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
ATOM_NS = "{http://www.w3.org/2005/Atom}"
|
||||||
|
|
||||||
|
|
||||||
|
def _llm_failure(reason: str) -> dict:
|
||||||
|
return {
|
||||||
|
"summary_zh": f"(LLM 分析失败: {reason})",
|
||||||
|
"breaking": None,
|
||||||
|
"breaking_reason": "LLM 分析失败",
|
||||||
|
"relevance_note": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate_field(value, limit: int):
|
||||||
|
"""Recursively truncate every string in a nested structure."""
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value[:limit]
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {k: _truncate_field(v, limit) for k, v in value.items()}
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [_truncate_field(v, limit) for v in value]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_json_object(text: str) -> dict | None:
|
||||||
|
"""Extract the first {...} JSON object from text (tolerates fences/prose)."""
|
||||||
|
start = text.find("{")
|
||||||
|
end = text.rfind("}")
|
||||||
|
if start == -1 or end == -1 or end <= start:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
obj = json.loads(text[start:end + 1])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
return obj if isinstance(obj, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _short_reason(e: Exception, default: str = "请求失败") -> str:
|
||||||
|
"""Short single-line failure reason (≤80 chars, no newlines)."""
|
||||||
|
msg = str(e).strip().replace("\n", " ").replace("\r", " ")
|
||||||
|
if not msg:
|
||||||
|
msg = default
|
||||||
|
return msg[:80]
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_with_llm(project_key: str, collected_data: dict) -> dict:
|
||||||
|
# 数据截断: release body ≤LLM_BODY_LIMIT, 其余字段各 ≤LLM_FIELD_LIMIT,
|
||||||
|
# 总数据部分 ≤LLM_TOTAL_LIMIT (12000 chars ≈ 3000 tokens, 见 config.py)
|
||||||
|
trimmed: dict = {}
|
||||||
|
for key, value in collected_data.items():
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
if key == "github_release" and isinstance(value, dict):
|
||||||
|
rel = dict(value)
|
||||||
|
rel["body"] = (rel.get("body") or "")[:LLM_BODY_LIMIT]
|
||||||
|
trimmed[key] = _truncate_field(rel, LLM_FIELD_LIMIT)
|
||||||
|
trimmed[key]["body"] = (value.get("body") or "")[:LLM_BODY_LIMIT]
|
||||||
|
else:
|
||||||
|
trimmed[key] = _truncate_field(value, LLM_FIELD_LIMIT)
|
||||||
|
|
||||||
|
data_json = json.dumps(trimmed, ensure_ascii=False, default=str)
|
||||||
|
if len(data_json) > LLM_TOTAL_LIMIT:
|
||||||
|
# 极端情况下继续收缩: 砍掉 exa_results 再砍 commits
|
||||||
|
trimmed.pop("exa_results", None)
|
||||||
|
data_json = json.dumps(trimmed, ensure_ascii=False, default=str)
|
||||||
|
if len(data_json) > LLM_TOTAL_LIMIT and isinstance(trimmed.get("github_commits"), list):
|
||||||
|
trimmed["github_commits"] = trimmed["github_commits"][:3]
|
||||||
|
data_json = json.dumps(trimmed, ensure_ascii=False, default=str)
|
||||||
|
|
||||||
|
prompt = (
|
||||||
|
"你是 AI 编程助手项目的升级监控分析师。下面是项目 "
|
||||||
|
f"{project_key} 的最新抓取数据 (JSON):\n\n{data_json}\n\n"
|
||||||
|
"请分析这些变更, 严格只返回一个 JSON 对象, 不要输出其他内容:\n"
|
||||||
|
"{\n"
|
||||||
|
' "summary_zh": "不少于50字的中文摘要, 概括本次更新的核心内容, 2-3句话",\n'
|
||||||
|
' "breaking": true 或 false, 表示是否存在不向后兼容的破坏性变更,\n'
|
||||||
|
' "breaking_reason": "若有 breaking 说明具体内容; 若无则写 \'未发现破坏性变更\'",\n'
|
||||||
|
' "relevance_note": "对本地已安装该工具用户的实际意义, 1-2句话"\n'
|
||||||
|
"}"
|
||||||
|
)
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": "你是严谨的 AI 工具升级监控分析师, 只输出 JSON。"},
|
||||||
|
{"role": "user", "content": prompt},
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
LLM_URL,
|
||||||
|
json={"model": LLM_MODEL, "messages": messages},
|
||||||
|
timeout=LLM_TIMEOUT,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
content = (
|
||||||
|
resp.json()
|
||||||
|
.get("choices", [{}])[0]
|
||||||
|
.get("message", {})
|
||||||
|
.get("content", "")
|
||||||
|
)
|
||||||
|
if not content:
|
||||||
|
log.error("LLM empty content for %s", project_key)
|
||||||
|
return _llm_failure("空响应")
|
||||||
|
|
||||||
|
obj = _extract_json_object(content)
|
||||||
|
if obj is None:
|
||||||
|
log.error("LLM JSON parse failed for %s: %.200s", project_key, content)
|
||||||
|
return _llm_failure("JSON 解析失败")
|
||||||
|
|
||||||
|
summary = str(obj.get("summary_zh", "")).strip()
|
||||||
|
if not summary:
|
||||||
|
log.error("LLM empty summary_zh for %s", project_key)
|
||||||
|
return _llm_failure("summary_zh 为空")
|
||||||
|
|
||||||
|
breaking = obj.get("breaking")
|
||||||
|
if not isinstance(breaking, bool):
|
||||||
|
breaking = None
|
||||||
|
return {
|
||||||
|
"summary_zh": summary,
|
||||||
|
"breaking": breaking,
|
||||||
|
"breaking_reason": str(obj.get("breaking_reason", "")).strip() or "未发现破坏性变更",
|
||||||
|
"relevance_note": str(obj.get("relevance_note", "")).strip(),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
log.error("LLM call failed for %s: %s", project_key, e)
|
||||||
|
return _llm_failure(str(e))
|
||||||
|
|
||||||
|
|
||||||
|
def gh_headers():
|
||||||
|
return {
|
||||||
|
"User-Agent": UA,
|
||||||
|
"Accept": "application/vnd.github+json",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_github_release(github_repo: str) -> tuple[dict | None, str | None]:
|
||||||
|
url = f"https://api.github.com/repos/{github_repo}/releases/latest"
|
||||||
|
try:
|
||||||
|
r = requests.get(url, headers=gh_headers(), timeout=GITHUB_TIMEOUT)
|
||||||
|
r.raise_for_status()
|
||||||
|
d = r.json()
|
||||||
|
return {
|
||||||
|
"tag_name": d.get("tag_name", ""),
|
||||||
|
"published_at": d.get("published_at", ""),
|
||||||
|
"name": d.get("name", ""),
|
||||||
|
"body": (d.get("body") or "")[:LLM_BODY_LIMIT],
|
||||||
|
}, None
|
||||||
|
except Exception as e:
|
||||||
|
log.error("GitHub release failed for %s: %s", github_repo, e)
|
||||||
|
return None, _short_reason(e)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_github_commits(github_repo: str) -> tuple[list[dict] | None, str | None]:
|
||||||
|
url = f"https://api.github.com/repos/{github_repo}/commits?per_page=5"
|
||||||
|
try:
|
||||||
|
r = requests.get(url, headers=gh_headers(), timeout=GITHUB_TIMEOUT)
|
||||||
|
r.raise_for_status()
|
||||||
|
items = []
|
||||||
|
for c in r.json():
|
||||||
|
msg = c.get("commit", {}).get("message", "")
|
||||||
|
first_line = msg.split("\n")[0][:LLM_MESSAGE_LIMIT]
|
||||||
|
date = c.get("commit", {}).get("author", {}).get("date", "")
|
||||||
|
items.append({"message": first_line, "date": date})
|
||||||
|
return items, None
|
||||||
|
except Exception as e:
|
||||||
|
log.error("GitHub commits failed for %s: %s", github_repo, e)
|
||||||
|
return None, _short_reason(e)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_npm(pkg: str) -> tuple[dict | None, str | None]:
|
||||||
|
if pkg.startswith("@"):
|
||||||
|
scope, name = pkg.split("/", 1)
|
||||||
|
encoded = f"{scope}%2F{name}"
|
||||||
|
else:
|
||||||
|
encoded = pkg
|
||||||
|
url = f"https://registry.npmjs.org/{encoded}/latest"
|
||||||
|
try:
|
||||||
|
r = requests.get(url, headers={"User-Agent": UA}, timeout=HTTP_TIMEOUT)
|
||||||
|
r.raise_for_status()
|
||||||
|
d = r.json()
|
||||||
|
return {
|
||||||
|
"version": d.get("version", ""),
|
||||||
|
"description": d.get("description", ""),
|
||||||
|
}, None
|
||||||
|
except Exception as e:
|
||||||
|
log.error("npm fetch failed for %s: %s", pkg, e)
|
||||||
|
return None, _short_reason(e)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_releases_atom(github_repo: str) -> tuple[dict | None, str | None]:
|
||||||
|
url = f"https://github.com/{github_repo}/releases.atom"
|
||||||
|
last_err: str | None = None
|
||||||
|
for attempt in range(2): # 首次 + 1 次重试 (仅 Timeout)
|
||||||
|
try:
|
||||||
|
r = requests.get(
|
||||||
|
url, headers={"User-Agent": UA}, timeout=ATOM_TIMEOUT
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
root = ET.fromstring(r.text)
|
||||||
|
entries = root.findall(f"{ATOM_NS}entry")
|
||||||
|
if not entries:
|
||||||
|
return None, "feed 无 entry"
|
||||||
|
entry = entries[0]
|
||||||
|
title = entry.findtext(f"{ATOM_NS}title", default="")
|
||||||
|
updated = entry.findtext(f"{ATOM_NS}updated", default="")
|
||||||
|
return {"title": title, "updated": updated}, None
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
last_err = "超时 (github.com 限流/网络)"
|
||||||
|
log.warning(
|
||||||
|
"Atom feed timeout for %s (attempt %d/2)", github_repo, attempt + 1
|
||||||
|
)
|
||||||
|
if attempt == 0:
|
||||||
|
time.sleep(5)
|
||||||
|
except Exception as e:
|
||||||
|
last_err = _short_reason(e)
|
||||||
|
log.error("Atom feed failed for %s: %s", github_repo, e)
|
||||||
|
break
|
||||||
|
return None, last_err
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_exa_text_block(text: str) -> list[dict]:
|
||||||
|
results = []
|
||||||
|
blocks = re.split(r"\n---\n", text)
|
||||||
|
for block in blocks:
|
||||||
|
block = block.strip()
|
||||||
|
if not block:
|
||||||
|
continue
|
||||||
|
title = ""
|
||||||
|
url = ""
|
||||||
|
highlights = []
|
||||||
|
in_highlights = False
|
||||||
|
for line in block.split("\n"):
|
||||||
|
if line.startswith("Title:"):
|
||||||
|
title = line[6:].strip()
|
||||||
|
in_highlights = False
|
||||||
|
elif line.startswith("URL:"):
|
||||||
|
url = line[4:].strip()
|
||||||
|
in_highlights = False
|
||||||
|
elif line.startswith("Published:") or line.startswith("Author:"):
|
||||||
|
in_highlights = False
|
||||||
|
elif line.startswith("Highlights:"):
|
||||||
|
in_highlights = True
|
||||||
|
elif in_highlights and line.strip() and not line.startswith("..."):
|
||||||
|
highlights.append(line.strip())
|
||||||
|
if title and url:
|
||||||
|
summary = " ".join(highlights)[:LLM_FIELD_LIMIT]
|
||||||
|
results.append({"title": title, "url": url, "text": summary})
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
class ExaClient:
|
||||||
|
def __init__(self):
|
||||||
|
self.url = "https://mcp.exa.ai/mcp?tools=web_search_exa"
|
||||||
|
self.headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json, text/event-stream",
|
||||||
|
}
|
||||||
|
self.session_id: str | None = None
|
||||||
|
self._initialized = False
|
||||||
|
|
||||||
|
def initialize(self) -> bool:
|
||||||
|
try:
|
||||||
|
body = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"method": "initialize",
|
||||||
|
"params": {
|
||||||
|
"protocolVersion": "2025-03-26",
|
||||||
|
"capabilities": {},
|
||||||
|
"clientInfo": {"name": "daily-watchdog", "version": "1.0"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
r = requests.post(
|
||||||
|
self.url, json=body, headers=self.headers, timeout=EXA_TIMEOUT
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
self.session_id = r.headers.get("Mcp-Session-Id", "")
|
||||||
|
notify = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "notifications/initialized",
|
||||||
|
}
|
||||||
|
h = {**self.headers}
|
||||||
|
if self.session_id:
|
||||||
|
h["Mcp-Session-Id"] = self.session_id
|
||||||
|
requests.post(
|
||||||
|
self.url, json=notify, headers=h, timeout=HTTP_TIMEOUT
|
||||||
|
)
|
||||||
|
self._initialized = True
|
||||||
|
log.info("Exa MCP initialized, session=%s", self.session_id)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Exa init failed: %s", e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def search(self, query: str, num_results: int = 3) -> list[dict] | None:
|
||||||
|
if not self._initialized:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
body = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 2,
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "web_search_exa",
|
||||||
|
"arguments": {"query": query, "numResults": num_results},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h = {**self.headers}
|
||||||
|
if self.session_id:
|
||||||
|
h["Mcp-Session-Id"] = self.session_id
|
||||||
|
r = requests.post(
|
||||||
|
self.url, json=body, headers=h, timeout=EXA_TIMEOUT
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
text = r.text
|
||||||
|
results = []
|
||||||
|
for line in text.split("\n"):
|
||||||
|
line = line.strip()
|
||||||
|
if not line.startswith("data:"):
|
||||||
|
continue
|
||||||
|
payload = line[5:].strip()
|
||||||
|
if not payload:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
obj = json.loads(payload)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if "result" not in obj:
|
||||||
|
continue
|
||||||
|
content = obj["result"].get("content", [])
|
||||||
|
for c in content:
|
||||||
|
raw = c.get("text", "")
|
||||||
|
parsed = _parse_exa_text_block(raw)
|
||||||
|
results.extend(parsed)
|
||||||
|
return results if results else None
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Exa search failed for '%s': %s", query, e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def check_npm_relevance(pkg: str, npm_data: dict) -> bool:
|
||||||
|
desc = (npm_data.get("description") or "").lower()
|
||||||
|
keywords = [
|
||||||
|
"vectorize",
|
||||||
|
"hindsight",
|
||||||
|
"replay",
|
||||||
|
"debugging",
|
||||||
|
"browser",
|
||||||
|
"record",
|
||||||
|
"session",
|
||||||
|
]
|
||||||
|
return any(kw in desc for kw in keywords)
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_one_project(proj: dict, exa: ExaClient | None) -> dict:
|
||||||
|
"""Fetch all sources for one project and summarize with the LLM.
|
||||||
|
|
||||||
|
Runs inside a worker thread. Every fetcher already catches its own
|
||||||
|
exceptions, so one failing source (or a failing LLM call) never raises
|
||||||
|
out of here and never affects other projects. `exa` is None when the
|
||||||
|
shared MCP session failed to initialize.
|
||||||
|
"""
|
||||||
|
log.info("Collecting data for %s", proj["label"])
|
||||||
|
data: dict = {
|
||||||
|
"key": proj["key"],
|
||||||
|
"label": proj["label"],
|
||||||
|
"star": proj["star"],
|
||||||
|
"installed": proj["installed"],
|
||||||
|
"github_release": None,
|
||||||
|
"github_commits": None,
|
||||||
|
"npm": None,
|
||||||
|
"atom": None,
|
||||||
|
"exa_results": None,
|
||||||
|
"source_errors": [],
|
||||||
|
"sources_used": [],
|
||||||
|
"llm_summary": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
rel, rel_err = fetch_github_release(proj["github"])
|
||||||
|
if rel:
|
||||||
|
data["github_release"] = rel
|
||||||
|
data["sources_used"].append("GitHub Releases API")
|
||||||
|
else:
|
||||||
|
data["source_errors"].append(
|
||||||
|
f"GitHub Releases API: {rel_err or '请求失败'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
commits, commits_err = fetch_github_commits(proj["github"])
|
||||||
|
if commits:
|
||||||
|
data["github_commits"] = commits
|
||||||
|
data["sources_used"].append("GitHub commits API")
|
||||||
|
else:
|
||||||
|
data["source_errors"].append(
|
||||||
|
f"GitHub commits API: {commits_err or '请求失败'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
npm_data, npm_err = fetch_npm(proj["npm"])
|
||||||
|
if npm_data:
|
||||||
|
if proj.get("npm_relevance_check"):
|
||||||
|
if check_npm_relevance(proj["npm"], npm_data):
|
||||||
|
data["npm"] = npm_data
|
||||||
|
data["sources_used"].append("npm registry")
|
||||||
|
else:
|
||||||
|
log.info(
|
||||||
|
"npm package '%s' not relevant, skipping", proj["npm"]
|
||||||
|
)
|
||||||
|
data["source_errors"].append(
|
||||||
|
"npm registry: 包存在但与本 repo 不相关, 已跳过"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
data["npm"] = npm_data
|
||||||
|
data["sources_used"].append("npm registry")
|
||||||
|
else:
|
||||||
|
data["source_errors"].append(f"npm registry: {npm_err or '请求失败'}")
|
||||||
|
|
||||||
|
atom, atom_err = fetch_releases_atom(proj["github"])
|
||||||
|
if atom:
|
||||||
|
data["atom"] = atom
|
||||||
|
data["sources_used"].append("GitHub releases.atom")
|
||||||
|
else:
|
||||||
|
data["source_errors"].append(
|
||||||
|
f"GitHub releases.atom: {atom_err or '请求失败'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if exa is not None:
|
||||||
|
query = f"{proj['label']} latest release changelog"
|
||||||
|
exa_res = exa.search(query)
|
||||||
|
if exa_res:
|
||||||
|
data["exa_results"] = exa_res
|
||||||
|
data["sources_used"].append("Exa web search")
|
||||||
|
else:
|
||||||
|
data["source_errors"].append("Exa web search: 无结果或查询失败")
|
||||||
|
else:
|
||||||
|
data["source_errors"].append("Exa web search: MCP 初始化失败")
|
||||||
|
|
||||||
|
collected_for_llm = {
|
||||||
|
"github_release": data["github_release"],
|
||||||
|
"github_commits": data["github_commits"],
|
||||||
|
"npm": data["npm"],
|
||||||
|
"atom": data["atom"],
|
||||||
|
"exa_results": data["exa_results"],
|
||||||
|
}
|
||||||
|
data["llm_summary"] = summarize_with_llm(proj["key"], collected_for_llm)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def collect_all() -> tuple[list[dict], dict[str, int], list[str]]:
|
||||||
|
exa = ExaClient()
|
||||||
|
exa_ok = exa.initialize()
|
||||||
|
if not exa_ok:
|
||||||
|
log.warning("Exa MCP init failed — all projects will note it")
|
||||||
|
|
||||||
|
# I/O bound (HTTP + LLM), so threads sidestep the GIL. One hanging
|
||||||
|
# project cannot stall the run: its future is abandoned after
|
||||||
|
# COLLECT_PROJECT_TIMEOUT and the thread is left to die on its own.
|
||||||
|
with futures.ThreadPoolExecutor(max_workers=COLLECT_MAX_WORKERS) as pool:
|
||||||
|
future_by_index = {
|
||||||
|
i: pool.submit(_collect_one_project, proj, exa if exa_ok else None)
|
||||||
|
for i, proj in enumerate(PROJECTS)
|
||||||
|
}
|
||||||
|
results_by_index: dict[int, dict] = {}
|
||||||
|
for i, fut in future_by_index.items():
|
||||||
|
proj = PROJECTS[i]
|
||||||
|
try:
|
||||||
|
results_by_index[i] = fut.result(timeout=COLLECT_PROJECT_TIMEOUT)
|
||||||
|
except Exception as e:
|
||||||
|
log.error(
|
||||||
|
"collect timed out or crashed for %s: %s", proj["label"], e
|
||||||
|
)
|
||||||
|
results_by_index[i] = {
|
||||||
|
"key": proj["key"],
|
||||||
|
"label": proj["label"],
|
||||||
|
"star": proj["star"],
|
||||||
|
"installed": proj["installed"],
|
||||||
|
"github_release": None,
|
||||||
|
"github_commits": None,
|
||||||
|
"npm": None,
|
||||||
|
"atom": None,
|
||||||
|
"exa_results": None,
|
||||||
|
"source_errors": [f"并行抓取超时或异常: {_short_reason(e)}"],
|
||||||
|
"sources_used": [],
|
||||||
|
"llm_summary": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Preserve PROJECTS order; aggregate per-source stats from the results
|
||||||
|
# produced by the worker threads (counts are merged here, single-threaded).
|
||||||
|
project_results = [results_by_index[i] for i in range(len(PROJECTS))]
|
||||||
|
source_counts: dict[str, int] = {s: 0 for s in CANONICAL_SOURCES}
|
||||||
|
sources_failed: set[str] = set()
|
||||||
|
if not exa_ok:
|
||||||
|
sources_failed.add("Exa web search")
|
||||||
|
for data in project_results:
|
||||||
|
for src in data["sources_used"]:
|
||||||
|
if src in source_counts:
|
||||||
|
source_counts[src] += 1
|
||||||
|
for err in data["source_errors"]:
|
||||||
|
src, _sep, _reason = err.partition(": ")
|
||||||
|
if src in source_counts:
|
||||||
|
sources_failed.add(src)
|
||||||
|
|
||||||
|
return project_results, source_counts, sorted(sources_failed)
|
||||||
|
|
||||||
|
|
||||||
|
def determine_project_status(data: dict) -> str:
|
||||||
|
has_any = any(
|
||||||
|
[
|
||||||
|
data["github_release"],
|
||||||
|
data["github_commits"],
|
||||||
|
data["npm"],
|
||||||
|
data["atom"],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if has_any and not data["source_errors"]:
|
||||||
|
return "ok"
|
||||||
|
if has_any:
|
||||||
|
return "partial"
|
||||||
|
return "failed"
|
||||||
|
|
||||||
|
|
||||||
|
def _split_source_error(err: str) -> tuple[str, str]:
|
||||||
|
src, sep, reason = err.partition(": ")
|
||||||
|
if not sep:
|
||||||
|
return err, "未知原因"
|
||||||
|
return src, reason
|
||||||
|
|
||||||
|
|
||||||
|
def render_report(
|
||||||
|
date_str: str,
|
||||||
|
project_results: list[dict],
|
||||||
|
source_counts: dict[str, int],
|
||||||
|
) -> str:
|
||||||
|
lines = []
|
||||||
|
lines.append(f"# AI Agent 升级迭代日报 - {date_str}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("## 数据源")
|
||||||
|
lines.append("")
|
||||||
|
total = len(PROJECTS)
|
||||||
|
for src in CANONICAL_SOURCES:
|
||||||
|
n = source_counts.get(src, 0)
|
||||||
|
lines.append(f"- {src} ({n}/{total})")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
all_failed = all(determine_project_status(d) == "failed" for d in project_results)
|
||||||
|
if all_failed:
|
||||||
|
lines.append("> ⚠️ 所有项目全部数据源抓取失败")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
for data in project_results:
|
||||||
|
status = determine_project_status(data)
|
||||||
|
star_mark = " ★" if data["star"] else ""
|
||||||
|
install_mark = f" (已装 {data['installed']})" if data["installed"] else " (未装)"
|
||||||
|
lines.append(f"## {data['label']}{star_mark}{install_mark}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if status == "failed":
|
||||||
|
lines.append("⚠️ 全部数据源抓取失败:")
|
||||||
|
lines.append("")
|
||||||
|
for err in data["source_errors"]:
|
||||||
|
src, reason = _split_source_error(err)
|
||||||
|
lines.append(f"⚠️ 抓取失败: {src} — {reason}")
|
||||||
|
lines.append("")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if data["github_release"]:
|
||||||
|
rel = data["github_release"]
|
||||||
|
lines.append(f"**Latest Release:** `{rel['tag_name']}`")
|
||||||
|
lines.append(f"**发布日期:** {rel['published_at']}")
|
||||||
|
if rel["name"]:
|
||||||
|
lines.append(f"**Release 名称:** {rel['name']}")
|
||||||
|
lines.append("")
|
||||||
|
if rel["body"]:
|
||||||
|
lines.append("**Changelog 摘要:**")
|
||||||
|
lines.append(f"> {rel['body'][:LLM_FIELD_LIMIT]}")
|
||||||
|
lines.append("")
|
||||||
|
elif data["github_commits"]:
|
||||||
|
lines.append("**最近 Commits (无 release):**")
|
||||||
|
for c in data["github_commits"]:
|
||||||
|
lines.append(f"- [{c['date'][:10]}] {c['message']}")
|
||||||
|
lines.append("")
|
||||||
|
else:
|
||||||
|
lines.append("⚠️ GitHub 数据不可用")
|
||||||
|
for err in data["source_errors"]:
|
||||||
|
if err.startswith("GitHub Releases API:") or err.startswith(
|
||||||
|
"GitHub commits API:"
|
||||||
|
):
|
||||||
|
src, reason = _split_source_error(err)
|
||||||
|
lines.append(f"⚠️ 抓取失败: {src} — {reason}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
llm = data["llm_summary"] or {}
|
||||||
|
if llm.get("summary_zh"):
|
||||||
|
lines.append(f"**LLM 总结:** {llm['summary_zh']}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(
|
||||||
|
f"**Breaking Changes:** {llm.get('breaking_reason', 'LLM 总结失败')}"
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if data["npm"]:
|
||||||
|
npm = data["npm"]
|
||||||
|
lines.append(f"**npm latest:** `{npm['version']}` — {npm['description']}")
|
||||||
|
lines.append("")
|
||||||
|
elif any(
|
||||||
|
e.startswith("npm registry: 包存在但与本 repo 不相关")
|
||||||
|
for e in data["source_errors"]
|
||||||
|
):
|
||||||
|
lines.append("**npm:** ⚠️ 包存在但与本 repo 不相关, 已跳过")
|
||||||
|
lines.append("")
|
||||||
|
elif any(e.startswith("npm registry:") for e in data["source_errors"]):
|
||||||
|
err = next(
|
||||||
|
e for e in data["source_errors"] if e.startswith("npm registry:")
|
||||||
|
)
|
||||||
|
src, reason = _split_source_error(err)
|
||||||
|
lines.append(f"**npm:** ⚠️ 抓取失败: {src} — {reason}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if data["atom"]:
|
||||||
|
atom = data["atom"]
|
||||||
|
lines.append(f"**releases.atom 最新:** {atom['title']} ({atom['updated']})")
|
||||||
|
lines.append("")
|
||||||
|
elif any(
|
||||||
|
e.startswith("GitHub releases.atom:") for e in data["source_errors"]
|
||||||
|
):
|
||||||
|
err = next(
|
||||||
|
e
|
||||||
|
for e in data["source_errors"]
|
||||||
|
if e.startswith("GitHub releases.atom:")
|
||||||
|
)
|
||||||
|
src, reason = _split_source_error(err)
|
||||||
|
lines.append(f"**releases.atom:** ⚠️ 抓取失败: {src} — {reason}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
llm = data["llm_summary"] or {}
|
||||||
|
relevance = llm.get("relevance_note", "")
|
||||||
|
if relevance:
|
||||||
|
lines.append(f"★ **相关性:** {relevance}")
|
||||||
|
lines.append("")
|
||||||
|
else:
|
||||||
|
lines.append("★ **相关性:** LLM 总结失败, 相关性待评估")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if data["exa_results"]:
|
||||||
|
lines.append("**Exa 搜索结果:**")
|
||||||
|
lines.append("")
|
||||||
|
for item in data["exa_results"][:3]:
|
||||||
|
title = item.get("title", "无标题")
|
||||||
|
url = item.get("url", "")
|
||||||
|
text = item.get("text", "")[:LLM_MESSAGE_LIMIT]
|
||||||
|
lines.append(f"> [{title}]({url}): {text}")
|
||||||
|
lines.append("")
|
||||||
|
elif any(e.startswith("Exa web search:") for e in data["source_errors"]):
|
||||||
|
err = next(
|
||||||
|
e for e in data["source_errors"] if e.startswith("Exa web search:")
|
||||||
|
)
|
||||||
|
src, reason = _split_source_error(err)
|
||||||
|
lines.append(f"**Exa 搜索:** ⚠️ 抓取失败: {src} — {reason}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("---")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def save_to_sqlite(date_str: str, markdown: str, meta: dict) -> None:
|
||||||
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(str(DB_PATH))
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""CREATE TABLE IF NOT EXISTS reports (
|
||||||
|
date TEXT PRIMARY KEY,
|
||||||
|
markdown TEXT NOT NULL,
|
||||||
|
meta_json TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)"""
|
||||||
|
)
|
||||||
|
now = datetime.now().isoformat()
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO reports (date, markdown, meta_json, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
(date_str, markdown, json.dumps(meta, ensure_ascii=False), now),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def save_markdown(date_str: str, markdown: str) -> Path:
|
||||||
|
LOGS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
path = LOGS_DIR / f"daily-agent-watchdog-{date_str}.md"
|
||||||
|
path.write_text(markdown, encoding="utf-8")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _watchdog_already_running() -> bool:
|
||||||
|
try:
|
||||||
|
out = subprocess.check_output(
|
||||||
|
["pgrep", "-f", "daily_watchdog.py"], text=True, timeout=10
|
||||||
|
)
|
||||||
|
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError):
|
||||||
|
return False
|
||||||
|
own = str(os.getpid())
|
||||||
|
for pid in out.split():
|
||||||
|
pid = pid.strip()
|
||||||
|
if not pid or pid == own:
|
||||||
|
continue
|
||||||
|
# 只认真正的 python 脚本进程, 排除命令行里恰好提到该文件名的进程
|
||||||
|
try:
|
||||||
|
with open(f"/proc/{pid}/cmdline", "rb") as f:
|
||||||
|
argv = f.read().split(b"\x00")
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if not argv or not argv[0]:
|
||||||
|
continue
|
||||||
|
exe = os.path.basename(argv[0].decode(errors="replace"))
|
||||||
|
if exe.startswith("python"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _today_report_ok(date_str: str) -> bool:
|
||||||
|
if not DB_PATH.exists():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(str(DB_PATH))
|
||||||
|
try:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT meta_json FROM reports WHERE date = ?", (date_str,)
|
||||||
|
).fetchone()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("fix-if-missing check failed: %s", e)
|
||||||
|
return False
|
||||||
|
if not row:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
meta = json.loads(row[0])
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return False
|
||||||
|
projects = meta.get("projects", {})
|
||||||
|
ok_count = sum(
|
||||||
|
1
|
||||||
|
for st in projects.values()
|
||||||
|
if isinstance(st, dict) and st.get("status") != "failed"
|
||||||
|
)
|
||||||
|
return ok_count >= 2
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
fix_if_missing = "--fix-if-missing" in sys.argv[1:]
|
||||||
|
date_str = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
if _watchdog_already_running():
|
||||||
|
log.warning("Another daily_watchdog instance is already running — skip")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if fix_if_missing and _today_report_ok(date_str):
|
||||||
|
log.info("today's report already OK — skip full run (--fix-if-missing)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
log.info("Starting daily watchdog for %s", date_str)
|
||||||
|
|
||||||
|
project_results, source_counts, sources_failed = collect_all()
|
||||||
|
|
||||||
|
all_failed = all(determine_project_status(d) == "failed" for d in project_results)
|
||||||
|
|
||||||
|
markdown = render_report(date_str, project_results, source_counts)
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
"sources_ok": sorted(k for k, v in source_counts.items() if v > 0),
|
||||||
|
"sources_failed": sources_failed,
|
||||||
|
"source_counts": source_counts,
|
||||||
|
"projects": {
|
||||||
|
d["key"]: {
|
||||||
|
"status": determine_project_status(d),
|
||||||
|
"sources_used": d["sources_used"],
|
||||||
|
}
|
||||||
|
for d in project_results
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
save_to_sqlite(date_str, markdown, meta)
|
||||||
|
md_path = save_markdown(date_str, markdown)
|
||||||
|
log.info("Report saved to %s", md_path)
|
||||||
|
log.info("SQLite record saved to %s", DB_PATH)
|
||||||
|
|
||||||
|
if all_failed:
|
||||||
|
log.error("All projects failed — exit code 1")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
log.info("Daily watchdog completed successfully")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user