bfddfdfdfewe
This commit is contained in:
@@ -404,18 +404,29 @@ async def enrichment_loop() -> None:
|
||||
while True:
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
# 已翻译完成 + 4 个状态中至少有一个是 pending
|
||||
# 关键:不能按 translated_at 升序 — 老文章已 enrich,新文章 translated_at=NULL(被排到最后)
|
||||
# 改为按 id 升序(新文章 id 大),循环里再过滤 status
|
||||
# 精准定位待 enrich 的文章:已翻译 + 任一 LLM 状态 ∈ {n/a, pending, failed}
|
||||
# (不能用 order_by id ASC + 内存过滤:已 enrich 的文章 id 可能更小,会占满 limit,
|
||||
# 让 enrichment_loop 永远看不到后面大 id 的 n/a 文章 — 真实踩过的坑)
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(Article)
|
||||
.where(
|
||||
Article.translation_status == "ok",
|
||||
Article.title_zh.is_not(None),
|
||||
# 任一 LLM 状态不是 ok(包括 NULL)
|
||||
(
|
||||
(Article.classify_status.is_(None))
|
||||
| (Article.classify_status != "ok")
|
||||
| (Article.format_status.is_(None))
|
||||
| (Article.format_status != "ok")
|
||||
| (Article.commentary_status.is_(None))
|
||||
| (Article.commentary_status != "ok")
|
||||
| (Article.image_ai_status.is_(None))
|
||||
| (Article.image_ai_status != "ok")
|
||||
),
|
||||
)
|
||||
.order_by(Article.id.asc())
|
||||
.limit(ENRICHMENT_BATCH_SIZE * 20) # 多取一些找需要 enrich 的
|
||||
.limit(ENRICHMENT_BATCH_SIZE * 5) # 比 batch 略多
|
||||
)
|
||||
).scalars()
|
||||
candidates = list(rows)
|
||||
|
||||
BIN
docs/android/app-debug.apk
Normal file
BIN
docs/android/app-debug.apk
Normal file
Binary file not shown.
39
scripts/append_mem2.py
Normal file
39
scripts/append_mem2.py
Normal file
@@ -0,0 +1,39 @@
|
||||
p = r'C:\Users\Administrator\.mavis\agents\mavis\memory\MEMORY.md'
|
||||
with open(p, encoding='utf-8') as f:
|
||||
s = f.read()
|
||||
|
||||
old = '''**已犯**:diary-news healthcheck.py 用了 `f"...%{{http_code}}..."` 写法,3 处全部 NameError,在 detail 解析时 m 还会变 None 引发二次 AttributeError。修法:3 处改普通字符串拼接 + rsplit 拿 status_part。'''
|
||||
|
||||
new = '''**已犯**:diary-news healthcheck.py 用了 `f"...%{{http_code}}..."` 写法,3 处全部 NameError,在 detail 解析时 m 还会变 None 引发二次 AttributeError。修法:3 处改普通字符串拼接 + rsplit 拿 status_part。
|
||||
|
||||
### global + lambda 闭包 + 条件赋值的隐藏 NameError (2026-06-11)
|
||||
Type: pitfall
|
||||
|
||||
**坑**:模块顶层 GROUPS 字典里的 `lambda r: check_xxx(r, GLOBAL_VAR)`,GLOBAL_VAR 在 main() 里用 `global X` 声明,只在某个 if 分支里赋值。
|
||||
|
||||
**症状**:lambda 调用时 `name 'X' is not defined`,但代码里**所有** global 块都正确声明了。
|
||||
|
||||
**根因**:`global X` 只是声明"当前作用域的 X 指向模块 dict",**不**会自动在模块 dict 里创建键。如果 if 分支没走到,模块 dict 里压根没 X 键,lambda 闭包查找时 NameError。
|
||||
|
||||
**关键诊断**:`AUTH_TOKEN in module_dict` → False(在 main 跑过后,在没传参的 else 分支里)
|
||||
|
||||
**修法**:`global X` 后**立即无条件** `X = ""`(或合理的默认值),保证键存在;再在 if 分支里覆盖。
|
||||
|
||||
```python
|
||||
def main():
|
||||
global AUTH_TOKEN
|
||||
AUTH_TOKEN = "" # ← 必须,即使后续不登录也要先写空串
|
||||
if login_success:
|
||||
AUTH_TOKEN = get_token()
|
||||
# else 分支走不到时,lambda 仍能读到 ""
|
||||
```
|
||||
|
||||
**和 f-string 那个坑的对比**:那次是 f-string 求值时机问题(import 期就报);这次是 lambda deferred 求值 + 模块 dict 缺键,跑 main() 之后才暴露。**两类都要靠"无条件初始化"防御**。'''
|
||||
|
||||
if old in s:
|
||||
s = s.replace(old, new, 1)
|
||||
with open(p, 'w', encoding='utf-8') as f:
|
||||
f.write(s)
|
||||
print('appended')
|
||||
else:
|
||||
print('old not found, skipping')
|
||||
27
scripts/check_enrich_logs.py
Normal file
27
scripts/check_enrich_logs.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""看 enrich 实际行为"""
|
||||
import os, paramiko
|
||||
|
||||
c = paramiko.SSHClient()
|
||||
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
c.connect("207.57.129.228", port=19717, username="root",
|
||||
password=os.environ["REMOTE_PASS"],
|
||||
timeout=30, allow_agent=False, look_for_keys=False)
|
||||
|
||||
|
||||
def run(label, cmd, timeout=20):
|
||||
print(f"\n=== {label} ===")
|
||||
si, so, se = c.exec_command(cmd, timeout=timeout)
|
||||
out = so.read().decode(errors="replace")
|
||||
print(out.rstrip())
|
||||
|
||||
|
||||
# 1) enrich 关键字完整日志
|
||||
run("1) enrich 关键字全部", "bash -lc 'cd /srv/news && docker compose logs worker 2>&1 | grep -iE \"enrich|llm_settings|llm.enabled\" | head -60'")
|
||||
|
||||
# 2) 最近 30 分钟 enrich 关键字
|
||||
run("2) enrich 最近 30min", "bash -lc 'cd /srv/news && docker compose logs --since 30m worker 2>&1 | grep -iE \"enrich\" | head -40'")
|
||||
|
||||
# 3) worker 当前 asyncio tasks
|
||||
run("3) 当前 asyncio tasks", "bash -lc 'cd /srv/news && docker compose exec -T worker python -c \"\nimport asyncio\nasync def m():\n for t in asyncio.all_tasks():\n if not t.done(): print(t.get_name(), t.done())\nasyncio.run(m())\n\"'")
|
||||
|
||||
c.close()
|
||||
11
scripts/check_errors.py
Normal file
11
scripts/check_errors.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""看 12:35 后所有 ERROR/WARNING/Traceback + enrich_article 日志"""
|
||||
import os, paramiko, datetime
|
||||
c = paramiko.SSHClient()
|
||||
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
c.connect("207.57.129.228", port=19717, username="root",
|
||||
password=os.environ["REMOTE_PASS"],
|
||||
timeout=30, allow_agent=False, look_for_keys=False)
|
||||
# 用 --since 24h (覆盖整个 12:35 后的时间)
|
||||
si, so, se = c.exec_command("bash -lc 'cd /srv/news && docker compose logs --since 24h worker 2>&1 | grep -iE \"ERROR|WARNING|Traceback|exception|enrich_article|llm|enabled|skip|disabled\" | grep -v httpx | head -60'", timeout=20)
|
||||
print(so.read().decode(errors="replace"))
|
||||
c.close()
|
||||
88
scripts/check_flow.py
Normal file
88
scripts/check_flow.py
Normal file
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""SSH 上服务器,快速检查:
|
||||
1. .env 里的 AGNES_API_KEY 是否已配(不要再打印 key 值)
|
||||
2. worker 进程是否在跑、enrichment_loop 任务是否在跑
|
||||
3. worker 日志最近 200 行是否出现 enrich_article / classify / commentary / format 等关键字
|
||||
4. 翻译/enrich 各自最后处理时间
|
||||
5. enrichment_loop 配置(LLM enable 状态)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
import os
|
||||
import paramiko
|
||||
|
||||
HOST = os.environ.get("REMOTE_HOST", "207.57.129.228")
|
||||
PORT = int(os.environ.get("REMOTE_PORT", "19717"))
|
||||
USER = os.environ.get("REMOTE_USER", "root")
|
||||
PASS = os.environ.get("REMOTE_PASS", "")
|
||||
|
||||
if not PASS:
|
||||
print("ERROR: REMOTE_PASS not set", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
def run(c, cmd, timeout=30, label=""):
|
||||
if label: print(f"\n=== {label} ===")
|
||||
print(f"$ {cmd}")
|
||||
si, so, se = c.exec_command(cmd, timeout=timeout, get_pty=True)
|
||||
out = so.read().decode(errors="replace")
|
||||
err = se.read().decode(errors="replace")
|
||||
rc = so.channel.recv_exit_status()
|
||||
if out.strip(): print(out.rstrip())
|
||||
if err.strip(): print(f"[stderr] {err.rstrip()}")
|
||||
print(f"-> rc={rc}")
|
||||
return out
|
||||
|
||||
print(f"连 {USER}@{HOST}:{PORT} ...")
|
||||
c = paramiko.SSHClient()
|
||||
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
c.connect(HOST, port=PORT, username=USER, password=PASS,
|
||||
timeout=30, banner_timeout=30, auth_timeout=30,
|
||||
allow_agent=False, look_for_keys=False)
|
||||
print("✓ 连上\n")
|
||||
|
||||
# 1) AGNES_API_KEY 状态(只看长度,不打值)
|
||||
run(c, "cd /srv/news && "
|
||||
"grep -E '^AGNES_(API_KEY|BASE_URL|CHAT_MODEL|IMAGE_MODEL)=' .env | "
|
||||
"awk -F= 'BEGIN{FS=\"=\"} { "
|
||||
" if ($1==\"AGNES_API_KEY\") { k=$2; gsub(/\"/,\"\",k); "
|
||||
" printf \" %s = (length=%d, prefix=%s***)\\n\", $1, length(k), substr(k,1,4) "
|
||||
" } else { print \" \" $0 }"
|
||||
"}'", label="1) .env 中 Agnes 相关配置")
|
||||
|
||||
# 2) worker 进程 + enrichment_loop 状态
|
||||
run(c, "cd /srv/news && "
|
||||
"echo '--- docker compose ps ---' && "
|
||||
"docker compose ps worker && "
|
||||
"echo '--- worker 容器内进程 ---' && "
|
||||
"docker compose exec -T worker sh -c 'ps -ef | grep -E \"python|app.workers\" | grep -v grep' && "
|
||||
"echo '--- enrichment 任务在 asyncio 队列 ---' && "
|
||||
"docker compose exec -T worker sh -c 'cat /proc/1/status 2>/dev/null | head -3' ",
|
||||
label="2) Worker 进程 + enrichment_loop 状态")
|
||||
|
||||
# 3) LLM enable 状态(admin_llm_settings 表)
|
||||
run(c, "cd /srv/news && set -a && . ./.env && set +a && "
|
||||
"docker compose exec -T postgres psql -U \"$POSTGRES_USER\" -d \"$POSTGRES_DB\" -t -A -F $'\\t' -c "
|
||||
"\"SELECT key, value, updated_at FROM admin_llm_settings ORDER BY key;\" 2>&1 | head -10",
|
||||
label="3) admin_llm_settings(LLM enable 状态)")
|
||||
|
||||
# 4) worker 日志最近 200 行
|
||||
run(c, "cd /srv/news && "
|
||||
"docker compose logs --no-color --tail=200 worker 2>&1 | tail -80",
|
||||
label="4) worker 日志(最近 200 行)")
|
||||
|
||||
# 5) 翻译/enrich 各自最后活跃时间
|
||||
run(c, "cd /srv/news && set -a && . ./.env && set +a && "
|
||||
"docker compose exec -T postgres psql -U \"$POSTGRES_USER\" -d \"$POSTGRES_DB\" -t -A -F $'\\t' -c \""
|
||||
"SELECT 'translated_last_5min=' || count(*) FROM articles WHERE translated_at > now() - interval '5 minute';"
|
||||
"SELECT 'classified_last_5min=' || count(*) FROM articles WHERE classify_status='ok' AND translated_at > now() - interval '5 minute';"
|
||||
"SELECT 'format_last_5min=' || count(*) FROM articles WHERE format_status='ok' AND translated_at > now() - interval '5 minute';"
|
||||
"SELECT 'commentary_last_5min=' || count(*) FROM articles WHERE commentary_status='ok' AND translated_at > now() - interval '5 minute';"
|
||||
"SELECT 'image_ai_last_5min=' || count(*) FROM articles WHERE image_ai_status='ok' AND translated_at > now() - interval '5 minute';"
|
||||
"SELECT 'pending_classify=' || count(*) FROM articles WHERE classify_status IN ('pending','n/a') AND translation_status='ok';"
|
||||
"SELECT 'pending_format=' || count(*) FROM articles WHERE format_status IN ('pending','n/a') AND translation_status='ok';"
|
||||
"\"",
|
||||
label="5) 最近 5 分钟 LLM 步骤活跃度")
|
||||
|
||||
c.close()
|
||||
print("\n✓ 检查完成")
|
||||
72
scripts/check_llm_state.py
Normal file
72
scripts/check_llm_state.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""SSH 上去看 llm_settings + enrichment_loop 状态(v2: 不用复杂 quote)"""
|
||||
import os
|
||||
import sys
|
||||
import paramiko
|
||||
|
||||
HOST = "207.57.129.228"
|
||||
PORT = 19717
|
||||
USER = "root"
|
||||
PASS = os.environ.get("REMOTE_PASS", "")
|
||||
|
||||
if not PASS:
|
||||
print("REMOTE_PASS not set", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
c = paramiko.SSHClient()
|
||||
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
c.connect(HOST, port=PORT, username=USER, password=PASS,
|
||||
timeout=30, allow_agent=False, look_for_keys=False)
|
||||
print("✓ 连上\n")
|
||||
|
||||
|
||||
def run(label, cmd, timeout=30):
|
||||
print(f"\n=== {label} ===")
|
||||
print(f"$ {cmd[:200]}{'...' if len(cmd) > 200 else ''}")
|
||||
try:
|
||||
si, so, se = c.exec_command(cmd, timeout=timeout)
|
||||
out = so.read().decode(errors="replace")
|
||||
err = se.read().decode(errors="replace")
|
||||
rc = so.channel.recv_exit_status()
|
||||
except Exception as e:
|
||||
print(f"[exception] {type(e).__name__}: {e}")
|
||||
return
|
||||
if out.strip(): print(out.rstrip())
|
||||
if err.strip(): print(f"[stderr] {err.rstrip()}")
|
||||
print(f"-> rc={rc}")
|
||||
|
||||
|
||||
# 1) 写一个 shell 文件到远程,然后用 bash file.sh 调(避开 quote)
|
||||
# 把 SQL 都拼到 /tmp/check_llm.sh
|
||||
shell_script = r"""#!/bin/bash
|
||||
set -a
|
||||
. /srv/news/.env
|
||||
set +a
|
||||
cd /srv/news
|
||||
echo "--- llm_settings ---"
|
||||
docker compose exec -T postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" \
|
||||
-c "SELECT id, enabled, chat_model, image_model, interval_sec, updated_at FROM llm_settings;"
|
||||
echo "--- worker asyncio tasks ---"
|
||||
docker compose exec -T worker python <<'PYEOF'
|
||||
import asyncio
|
||||
async def main():
|
||||
for t in asyncio.all_tasks():
|
||||
if not t.done():
|
||||
print(f'name={t.get_name()!r} done={t.done()}')
|
||||
asyncio.run(main())
|
||||
PYEOF
|
||||
echo "--- worker 日志(enrich 关键字) ---"
|
||||
docker compose logs --tail=500 worker 2>&1 | grep -iE "enrich|llm_settings|enrichment_loop|Traceback" | head -40
|
||||
"""
|
||||
|
||||
# 写入远程
|
||||
si, so, se = c.exec_command("cat > /tmp/check_llm.sh <<'EOFCAT'\n" + shell_script + "\nEOFCAT", timeout=10)
|
||||
so.read()
|
||||
se.read()
|
||||
so.channel.recv_exit_status()
|
||||
print("✓ shell 脚本已写入 /tmp/check_llm.sh\n")
|
||||
|
||||
# 执行
|
||||
run("llm_settings + asyncio + 日志", "bash /tmp/check_llm.sh", timeout=60)
|
||||
|
||||
c.close()
|
||||
print("\n✓ 完成")
|
||||
29
scripts/check_worker_signal.py
Normal file
29
scripts/check_worker_signal.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""查 worker shutdown / signal / cancel 关键字"""
|
||||
import os, paramiko
|
||||
c = paramiko.SSHClient()
|
||||
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
c.connect("207.57.129.228", port=19717, username="root",
|
||||
password=os.environ["REMOTE_PASS"],
|
||||
timeout=30, allow_agent=False, look_for_keys=False)
|
||||
|
||||
# 1) shutdown / signal / cancel 关键字
|
||||
si, so, se = c.exec_command("bash -lc 'cd /srv/news && docker compose logs worker 2>&1 | grep -iE \"shutdown|stop|signal|cancel|stopping\" | head -20'", timeout=20)
|
||||
print("=== 1) shutdown/stop/signal/cancel 关键字 ===")
|
||||
print(so.read().decode(errors="replace"))
|
||||
|
||||
# 2) news.llm.enrichment 全部日志
|
||||
si, so, se = c.exec_command("bash -lc 'cd /srv/news && docker compose logs worker 2>&1 | grep -E \"news.llm.enrichment|enrich\" | head -30'", timeout=20)
|
||||
print("\n=== 2) news.llm.enrichment 全部日志 ===")
|
||||
print(so.read().decode(errors="replace"))
|
||||
|
||||
# 3) news.worker 全部日志
|
||||
si, so, se = c.exec_command("bash -lc 'cd /srv/news && docker compose logs worker 2>&1 | grep -E \"news.worker\" | head -20'", timeout=20)
|
||||
print("\n=== 3) news.worker 全部日志 ===")
|
||||
print(so.read().decode(errors="replace"))
|
||||
|
||||
# 4) 容器进程状态
|
||||
si, so, se = c.exec_command("bash -lc 'cd /srv/news && docker compose exec -T worker sh -c \"ls /proc/1/task/ | head -20; echo ---; cat /proc/1/status | head -5\"'", timeout=20)
|
||||
print("\n=== 4) 容器进程状态 ===")
|
||||
print(so.read().decode(errors="replace"))
|
||||
|
||||
c.close()
|
||||
34
scripts/check_worker_startup.py
Normal file
34
scripts/check_worker_startup.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""看 worker 启动日志 + Traceback 完整内容"""
|
||||
import os, paramiko
|
||||
|
||||
PASS = os.environ.get("REMOTE_PASS", "")
|
||||
c = paramiko.SSHClient()
|
||||
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
c.connect("207.57.129.228", port=19717, username="root", password=PASS,
|
||||
timeout=30, allow_agent=False, look_for_keys=False)
|
||||
|
||||
|
||||
def run(label, cmd, timeout=30):
|
||||
print(f"\n=== {label} ===")
|
||||
si, so, se = c.exec_command(cmd, timeout=timeout)
|
||||
out = so.read().decode(errors="replace")
|
||||
err = se.read().decode(errors="replace")
|
||||
print(out.rstrip())
|
||||
if err.strip():
|
||||
print(f"[stderr] {err.rstrip()}")
|
||||
|
||||
|
||||
# 1) worker 启动时全部 INFO/ERROR 日志(关键!)
|
||||
run("1) worker 启动日志(前 60 行)", "bash -lc 'cd /srv/news && docker compose logs worker 2>&1 | head -60'")
|
||||
|
||||
# 2) Traceback 完整内容
|
||||
run("2) Traceback 完整内容", "bash -lc 'cd /srv/news && docker compose logs worker 2>&1 | grep -A 25 Traceback | head -100'")
|
||||
|
||||
# 3) container 启动时间 + restart count
|
||||
run("3) container 启动时间 + restart count",
|
||||
"docker inspect news-aggregator-worker-1 --format '{{.State.StartedAt}} restarts={{.RestartCount}} status={{.State.Status}}'")
|
||||
|
||||
# 4) 服务器当前时间
|
||||
run("4) 服务器当前时间", "date '+%Y-%m-%d %H:%M:%S'")
|
||||
|
||||
c.close()
|
||||
59
scripts/deploy_enrich_fix.py
Normal file
59
scripts/deploy_enrich_fix.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""把改完的 enrichment.py 复制到服务器,重建 worker,重启"""
|
||||
import os, paramiko
|
||||
c = paramiko.SSHClient()
|
||||
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
c.connect("207.57.129.228", port=19717, username="root",
|
||||
password=os.environ["REMOTE_PASS"],
|
||||
timeout=30, allow_agent=False, look_for_keys=False)
|
||||
|
||||
|
||||
def run(label, cmd, timeout=180):
|
||||
print(f"\n=== {label} ===")
|
||||
si, so, se = c.exec_command(cmd, timeout=timeout)
|
||||
out = so.read().decode(errors="replace")
|
||||
err = se.read().decode(errors="replace")
|
||||
rc = so.channel.recv_exit_status()
|
||||
if out.strip(): print(out.rstrip())
|
||||
if err.strip(): print(f"[stderr] {err.rstrip()}")
|
||||
print(f"-> rc={rc}")
|
||||
|
||||
|
||||
# 1) 复制改完的 enrichment.py 到服务器
|
||||
import base64
|
||||
# 本地读改完的 enrichment.py
|
||||
local = r"D:\selftools\diary-news\backend\app\services\llm\enrichment.py"
|
||||
with open(local, encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
b64 = base64.b64encode(content.encode("utf-8")).decode("ascii")
|
||||
si, so, se = c.exec_command(f"bash -lc 'mkdir -p /srv/news/backend/app/services/llm && echo {b64} | base64 -d > /srv/news/backend/app/services/llm/enrichment.py.new && wc -l /srv/news/backend/app/services/llm/enrichment.py.new'", timeout=30)
|
||||
print("=== 1) 复制 enrichment.py.new ===")
|
||||
print(so.read().decode(errors="replace").rstrip())
|
||||
|
||||
# 备份原文件再覆盖
|
||||
si, so, se = c.exec_command("bash -lc 'cd /srv/news/backend/app/services/llm && cp -f enrichment.py enrichment.py.bak.$(date +%Y%m%d_%H%M%S) && mv enrichment.py.new enrichment.py && ls -la enrichment.py*'", timeout=10)
|
||||
print("\n=== 2) 备份 + 覆盖 ===")
|
||||
print(so.read().decode(errors="replace").rstrip())
|
||||
|
||||
# 3) 重建 worker 镜像
|
||||
run("3) docker compose build worker(增量,会很快)", "cd /srv/news && docker compose build worker", timeout=180)
|
||||
|
||||
# 4) 重启 worker
|
||||
run("4) docker compose up -d worker(只重启 worker)", "cd /srv/news && docker compose up -d worker", timeout=60)
|
||||
|
||||
# 5) 等启动
|
||||
run("5) 等 5 秒", "sleep 5 && date '+%H:%M:%S'")
|
||||
|
||||
# 6) 看 enrichment_loop 启动
|
||||
run("6) enrichment_loop 启动日志", "cd /srv/news && docker compose logs --tail=20 worker 2>&1 | grep -iE 'enrich|started|enabled'")
|
||||
|
||||
# 7) 等 30 秒,看是否开始 enrich
|
||||
run("7) 等 30 秒", "sleep 30 && date '+%H:%M:%S'")
|
||||
|
||||
# 8) 看是否有 enrich_article 日志
|
||||
run("8) enrich_article 日志", "cd /srv/news && docker compose logs --tail=200 worker 2>&1 | grep -E 'enrich_article|classify|commentary' | head -20")
|
||||
|
||||
# 9) 看 n/a 数量变化
|
||||
run("9) 当前 n/a 数量", "cd /srv/news && docker compose exec -T postgres psql -U news -d news -c \"SELECT classify_status, count(*) FROM articles GROUP BY classify_status ORDER BY count(*) DESC;\"")
|
||||
|
||||
c.close()
|
||||
print("\n🎉 修复完成")
|
||||
64
scripts/diag_enrich_inplace.py
Normal file
64
scripts/diag_enrich_inplace.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""在 worker 容器内写文件 + 跑"""
|
||||
import os, paramiko, base64
|
||||
c = paramiko.SSHClient()
|
||||
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
c.connect("207.57.129.228", port=19717, username="root",
|
||||
password=os.environ["REMOTE_PASS"],
|
||||
timeout=30, allow_agent=False, look_for_keys=False)
|
||||
|
||||
py = r"""import asyncio, sys
|
||||
sys.path.insert(0, '/app')
|
||||
from sqlalchemy import select
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models.article import Article
|
||||
|
||||
async def main():
|
||||
async with AsyncSessionLocal() as session:
|
||||
rows = (await session.execute(
|
||||
select(Article)
|
||||
.where(Article.translation_status == "ok", Article.title_zh.is_not(None))
|
||||
.order_by(Article.id.asc())
|
||||
.limit(160)
|
||||
)).scalars()
|
||||
candidates = list(rows)
|
||||
print(f"candidates={len(candidates)}")
|
||||
todo = []
|
||||
for a in candidates:
|
||||
statuses = [a.format_status or "pending",
|
||||
a.classify_status or "pending",
|
||||
a.image_ai_status or "pending",
|
||||
a.commentary_status or "pending"]
|
||||
if any(s in ("pending","failed","n/a") for s in statuses):
|
||||
todo.append(a.id)
|
||||
if len(todo) >= 8: break
|
||||
print(f"todo={len(todo)} ids={todo[:5]}")
|
||||
if candidates:
|
||||
a = candidates[0]
|
||||
print(f"first: id={a.id} tr={a.translation_status} fmt={a.format_status} cls={a.classify_status} img={a.image_ai_status} cmt={a.commentary_status}")
|
||||
|
||||
asyncio.run(main())
|
||||
"""
|
||||
|
||||
b64 = base64.b64encode(py.encode("utf-8")).decode("ascii")
|
||||
|
||||
# 分两步:先在主机上写(避免 docker exec 不持久文件)
|
||||
si, so, se = c.exec_command(f"bash -lc 'echo {b64} | base64 -d > /srv/news/diag.py && ls -la /srv/news/diag.py && cat /srv/news/diag.py | head -3'", timeout=15)
|
||||
print("=== step 1: write file ===")
|
||||
print(so.read().decode(errors="replace"))
|
||||
|
||||
# 再 docker exec(此时文件在 /srv/news 挂载进 worker 容器,会出现在 /app 或 / 目录)
|
||||
si, so, se = c.exec_command("bash -lc 'cd /srv/news && docker compose exec -T worker sh -c \"ls /tmp/diag.py 2>/dev/null; ls /app/diag.py 2>/dev/null; ls /diag.py 2>/dev/null; find / -name diag.py 2>/dev/null | head -5\"'", timeout=30)
|
||||
print("=== step 2: find diag.py in container ===")
|
||||
print(so.read().decode(errors="replace"))
|
||||
|
||||
# 直接用 docker compose exec 把文件传进去
|
||||
si, so, se = c.exec_command("bash -lc 'cd /srv/news && docker compose exec -T worker sh -c \"cat > /tmp/diag.py\" < diag.py && docker compose exec -T worker ls -la /tmp/diag.py'", timeout=30)
|
||||
print("=== step 3: copy file into container ===")
|
||||
print(so.read().decode(errors="replace"))
|
||||
|
||||
# 跑
|
||||
si, so, se = c.exec_command("bash -lc 'cd /srv/news && docker compose exec -T worker python /tmp/diag.py 2>&1 | tail -20'", timeout=30)
|
||||
print("=== step 4: run ===")
|
||||
print(so.read().decode(errors="replace"))
|
||||
|
||||
c.close()
|
||||
62
scripts/diag_enrich_v2.py
Normal file
62
scripts/diag_enrich_v2.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""现场重跑 enrichment_loop 查询 + 看排序"""
|
||||
import os, paramiko, base64
|
||||
c = paramiko.SSHClient()
|
||||
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
c.connect("207.57.129.228", port=19717, username="root",
|
||||
password=os.environ["REMOTE_PASS"],
|
||||
timeout=30, allow_agent=False, look_for_keys=False)
|
||||
|
||||
py = r"""import asyncio, sys
|
||||
sys.path.insert(0, '/app')
|
||||
from sqlalchemy import select
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models.article import Article
|
||||
|
||||
async def main():
|
||||
async with AsyncSessionLocal() as session:
|
||||
# 跟 enrichment_loop.py:410 一样的查询
|
||||
rows = (await session.execute(
|
||||
select(Article)
|
||||
.where(Article.translation_status == "ok", Article.title_zh.is_not(None))
|
||||
.order_by(Article.id.asc())
|
||||
.limit(160)
|
||||
)).scalars()
|
||||
candidates = list(rows)
|
||||
# 状态分布
|
||||
cls_dist = {}
|
||||
for a in candidates:
|
||||
s = a.classify_status or "NULL"
|
||||
cls_dist[s] = cls_dist.get(s, 0) + 1
|
||||
print(f"candidates={len(candidates)}")
|
||||
print(f"classify 分布: {cls_dist}")
|
||||
# 头 5 篇的 id + status
|
||||
for a in candidates[:5]:
|
||||
print(f" id={a.id} cls={a.classify_status} fmt={a.format_status} cmt={a.commentary_status} img={a.image_ai_status}")
|
||||
# 尾 5 篇
|
||||
print("--- last 5 ---")
|
||||
for a in candidates[-5:]:
|
||||
print(f" id={a.id} cls={a.classify_status} fmt={a.format_status} cmt={a.commentary_status} img={a.image_ai_status}")
|
||||
# todo 计算
|
||||
todo = []
|
||||
for a in candidates:
|
||||
statuses = [a.format_status or "pending",
|
||||
a.classify_status or "pending",
|
||||
a.image_ai_status or "pending",
|
||||
a.commentary_status or "pending"]
|
||||
if any(s in ("pending","failed","n/a") for s in statuses):
|
||||
todo.append(a.id)
|
||||
if len(todo) >= 8: break
|
||||
print(f"todo={len(todo)} ids={todo}")
|
||||
|
||||
asyncio.run(main())
|
||||
"""
|
||||
|
||||
b64 = base64.b64encode(py.encode("utf-8")).decode("ascii")
|
||||
si, so, se = c.exec_command(f"bash -lc 'echo {b64} | base64 -d > /srv/news/diag.py'", timeout=10)
|
||||
so.read()
|
||||
|
||||
# 复制进 worker 容器
|
||||
si, so, se = c.exec_command("bash -lc 'cd /srv/news && CID=$(docker compose ps -q worker) && docker cp diag.py $CID:/tmp/diag.py && docker compose exec -T worker python /tmp/diag.py 2>&1 | head -30'", timeout=30)
|
||||
print(so.read().decode(errors="replace"))
|
||||
|
||||
c.close()
|
||||
24
scripts/diag_env.py
Normal file
24
scripts/diag_env.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""检查 .env 里的 POSTGRES_USER"""
|
||||
import os, paramiko
|
||||
c = paramiko.SSHClient()
|
||||
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
c.connect("207.57.129.228", port=19717, username="root",
|
||||
password=os.environ["REMOTE_PASS"],
|
||||
timeout=30, allow_agent=False, look_for_keys=False)
|
||||
|
||||
# 看 .env
|
||||
si, so, se = c.exec_command("bash -lc 'cd /srv/news && grep -E \"^POSTGRES_(USER|DB|PASSWORD)=\" .env'", timeout=10)
|
||||
print("=== .env ===")
|
||||
print(so.read().decode(errors="replace"))
|
||||
|
||||
# 直接 set 然后 echo
|
||||
si, so, se = c.exec_command("bash -lc 'cd /srv/news && set -a && . ./.env && set +a && echo \"USER=$POSTGRES_USER DB=$POSTGRES_DB\"'", timeout=10)
|
||||
print("=== set -a 之后 ===")
|
||||
print(so.read().decode(errors="replace"))
|
||||
|
||||
# 最简方式: 传 PGPASSWORD
|
||||
si, so, se = c.exec_command("bash -lc 'cd /srv/news && docker compose exec -T -e PGPASSWORD=news postgres psql -U news -d news -f /tmp/diag.sql'", timeout=15)
|
||||
print("=== 硬编码 USER/DB ===")
|
||||
print(so.read().decode(errors="replace"))
|
||||
|
||||
c.close()
|
||||
27
scripts/diag_status_dist.py
Normal file
27
scripts/diag_status_dist.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""弄清 pending_classify=648 的真实含义 - 用单引号"""
|
||||
import os, paramiko
|
||||
c = paramiko.SSHClient()
|
||||
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
c.connect("207.57.129.228", port=19717, username="root",
|
||||
password=os.environ["REMOTE_PASS"],
|
||||
timeout=30, allow_agent=False, look_for_keys=False)
|
||||
|
||||
cmds = [
|
||||
("1) llm_settings", "SELECT id, enabled, chat_model, image_model, interval_sec, updated_at FROM llm_settings;"),
|
||||
("2) translation_status", "SELECT translation_status, count(*) FROM articles GROUP BY translation_status ORDER BY count(*) DESC;"),
|
||||
("3) classify_status", "SELECT classify_status, count(*) FROM articles GROUP BY classify_status ORDER BY count(*) DESC;"),
|
||||
("4) format_status", "SELECT format_status, count(*) FROM articles GROUP BY format_status ORDER BY count(*) DESC;"),
|
||||
("5) commentary_status", "SELECT commentary_status, count(*) FROM articles GROUP BY commentary_status ORDER BY count(*) DESC;"),
|
||||
("6) image_ai_status", "SELECT image_ai_status, count(*) FROM articles GROUP BY image_ai_status ORDER BY count(*) DESC;"),
|
||||
("7) 已 enrich 比例(translation=ok AND 4 status 全 ok)", "SELECT count(*) AS fully_enriched FROM articles WHERE translation_status='ok' AND classify_status='ok' AND format_status='ok' AND image_ai_status='ok' AND commentary_status='ok';"),
|
||||
("8) 翻译 ok 但 classify 是 n/a", "SELECT count(*) FROM articles WHERE translation_status='ok' AND classify_status='n/a';"),
|
||||
]
|
||||
for label, sql in cmds:
|
||||
# 用 set -a; . .env; set +a 加载 env 变量
|
||||
cmd = f"bash -lc 'cd /srv/news && set -a && . ./.env && set +a && docker compose exec -T postgres psql -U \"$POSTGRES_USER\" -d \"$POSTGRES_DB\" -c \"{sql}\"'"
|
||||
si, so, se = c.exec_command(cmd, timeout=20)
|
||||
out = so.read().decode(errors="replace")
|
||||
print(f"=== {label} ===")
|
||||
print(out.rstrip())
|
||||
print()
|
||||
c.close()
|
||||
13
scripts/diag_status_more.py
Normal file
13
scripts/diag_status_more.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""用 docker cp 复制文件"""
|
||||
import os, paramiko
|
||||
c = paramiko.SSHClient()
|
||||
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
c.connect("207.57.129.228", port=19717, username="root",
|
||||
password=os.environ["REMOTE_PASS"],
|
||||
timeout=30, allow_agent=False, look_for_keys=False)
|
||||
|
||||
# 直接用 docker cp
|
||||
si, so, se = c.exec_command("bash -lc 'cd /srv/news && docker cp diag.sql $(docker compose ps -q postgres):/tmp/diag.sql && docker compose exec -T postgres psql -U $POSTGRES_USER -d $POSTGRES_DB -f /tmp/diag.sql'", timeout=30)
|
||||
print(so.read().decode(errors="replace"))
|
||||
|
||||
c.close()
|
||||
25
scripts/diag_step.py
Normal file
25
scripts/diag_step.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""分步调试 - 修 psql 调用的 env"""
|
||||
import os, paramiko
|
||||
c = paramiko.SSHClient()
|
||||
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
c.connect("207.57.129.228", port=19717, username="root",
|
||||
password=os.environ["REMOTE_PASS"],
|
||||
timeout=30, allow_agent=False, look_for_keys=False)
|
||||
|
||||
|
||||
def run(label, cmd, timeout=30):
|
||||
print(f"\n=== {label} ===")
|
||||
si, so, se = c.exec_command(cmd, timeout=timeout)
|
||||
out = so.read().decode(errors="replace")
|
||||
err = se.read().decode(errors="replace")
|
||||
rc = so.channel.recv_exit_status()
|
||||
if out.strip(): print(out.rstrip())
|
||||
if err.strip(): print(f"[stderr] {err.rstrip()}")
|
||||
print(f"-> rc={rc}")
|
||||
|
||||
|
||||
# psql 走 docker compose exec(自动加载 .env 的 env_file)
|
||||
run("step 4 fix: 用 docker compose exec",
|
||||
"bash -lc 'cd /srv/news && docker compose exec -T postgres psql -U $POSTGRES_USER -d $POSTGRES_DB -f /tmp/diag.sql'")
|
||||
|
||||
c.close()
|
||||
266
scripts/fix_enrich_loop.py
Normal file
266
scripts/fix_enrich_loop.py
Normal file
@@ -0,0 +1,266 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""一键修复 diary-news 的 enrichment_loop bug。
|
||||
|
||||
真因:`services/llm/enrichment.py:405-419` 的查询用
|
||||
`order by id ASC + limit 160 + 内存过滤 status`,但
|
||||
- 162 篇最早的文章已经被 enrich 完(4 status 全 ok)
|
||||
- 662 篇 n/a 的文章 id > 388354,在 160 limit 之外
|
||||
- 每次 while True 循环都看到这 162 篇已 ok,filter 命中 0 → todo=0 → continue 死循环
|
||||
|
||||
修法:把 where 条件改成"任一 LLM 状态 != 'ok'",精准定位待 enrich 的文章。
|
||||
|
||||
用法:
|
||||
$env:REMOTE_PASS = '<root 密码>'
|
||||
python scripts/fix_enrich_loop.py [--host ...] [--port ...] [--user ...] [--compose-dir ...] [--wait 120]
|
||||
|
||||
退出码:
|
||||
0 = 修复 + enrich 跑起来(有 n/a → ok 的变化)
|
||||
1 = 修复但 enrich 未观察到跑(可能是 0 候选 / LLM 调不通)
|
||||
2 = 部署失败
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import paramiko
|
||||
|
||||
# ============== 修复后的 enrichment.py 关键段(只动 where/limit) ==============
|
||||
# 完整文件本地读,然后原样上传
|
||||
ENRICHMENT_PY_LOCAL = r"D:\selftools\diary-news\backend\app\services\llm\enrichment.py"
|
||||
|
||||
|
||||
# ============== 配置 ==============
|
||||
DEFAULT_HOST = "207.57.129.228"
|
||||
DEFAULT_PORT = 19717
|
||||
DEFAULT_USER = "root"
|
||||
DEFAULT_COMPOSE = "/srv/news"
|
||||
DEFAULT_WAIT_SEC = 120 # 等几分钟看 enrich 是否在跑
|
||||
|
||||
|
||||
def ssh_exec(c: paramiko.SSHClient, cmd: str, timeout: int = 300) -> tuple[int, str, str]:
|
||||
"""执行远程命令,返回 (rc, stdout, stderr)。出错抛 SSHException。"""
|
||||
si, so, se = c.exec_command(cmd, timeout=timeout, get_pty=True)
|
||||
out = so.read().decode(errors="replace")
|
||||
err = se.read().decode(errors="replace")
|
||||
rc = so.channel.recv_exit_status()
|
||||
return rc, out, err
|
||||
|
||||
|
||||
def put_file(c: paramiko.SSHClient, remote_path: str, content_bytes: bytes) -> None:
|
||||
"""把本地文件原样传到 remote_path(用 base64 避开 shell quoting)。"""
|
||||
b64 = base64.b64encode(content_bytes).decode("ascii")
|
||||
cmd = (
|
||||
f"bash -lc 'mkdir -p \"$(dirname {remote_path})\" && "
|
||||
f"echo {b64} | base64 -d > {remote_path} && "
|
||||
f"wc -l {remote_path}'"
|
||||
)
|
||||
rc, out, err = ssh_exec(c, cmd, timeout=60)
|
||||
if rc != 0:
|
||||
print(f"[stderr] {err.rstrip()}")
|
||||
raise RuntimeError(f"put_file 失败 rc={rc}")
|
||||
print(f" ✓ 写入 {remote_path} {out.strip()}")
|
||||
|
||||
|
||||
def put_text_via_file(c: paramiko.SSHClient, remote_path: str, text: str) -> None:
|
||||
"""用 base64 + heredoc 写文本(避免 shell 转义噩梦)。"""
|
||||
put_file(c, remote_path, text.encode("utf-8"))
|
||||
|
||||
|
||||
def get_text(c: paramiko.SSHClient, remote_path: str) -> str | None:
|
||||
"""读远程文件,文件不存在返回 None。"""
|
||||
cmd = f"bash -lc 'if [ -f {remote_path} ]; then cat {remote_path}; fi'"
|
||||
rc, out, err = ssh_exec(c, cmd, timeout=30)
|
||||
if rc != 0 or not out.strip():
|
||||
return None
|
||||
return out
|
||||
|
||||
|
||||
# ============== 主流程 ==============
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="一键修复 enrichment_loop bug")
|
||||
ap.add_argument("--host", default=os.environ.get("REMOTE_HOST", DEFAULT_HOST))
|
||||
ap.add_argument("--port", type=int, default=int(os.environ.get("REMOTE_PORT", DEFAULT_PORT)))
|
||||
ap.add_argument("--user", default=os.environ.get("REMOTE_USER", DEFAULT_USER))
|
||||
ap.add_argument("--password", default=os.environ.get("REMOTE_PASS", ""))
|
||||
ap.add_argument("--compose-dir", default=os.environ.get("COMPOSE_DIR", DEFAULT_COMPOSE))
|
||||
ap.add_argument("--wait", type=int, default=DEFAULT_WAIT_SEC,
|
||||
help="部署后等多少秒再检查 enrich 跑了多少(默认 120)")
|
||||
ap.add_argument("--no-build", action="store_true",
|
||||
help="跳过 docker build(只重启容器,代码改动不会被采纳)")
|
||||
ap.add_argument("--no-restart", action="store_true",
|
||||
help="跳过 docker up -d(只复制代码 + build)")
|
||||
ap.add_argument("--dry-run", action="store_true",
|
||||
help="只比对文件,不改不重启")
|
||||
ap.add_argument("--force-recreate", action="store_true",
|
||||
help="服务器文件不存在时,直接创建新文件(不要求存在)")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not args.password:
|
||||
print("ERROR: 需要 REMOTE_PASS 环境变量或 --password", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
print(f"==== 目标: {args.user}@{args.host}:{args.port} ====")
|
||||
print(f"==== compose: {args.compose_dir} ====")
|
||||
print(f"==== 修复前等待: {args.wait}s ====\n")
|
||||
|
||||
# 0) 读本地改完的 enrichment.py
|
||||
if not os.path.exists(ENRICHMENT_PY_LOCAL):
|
||||
print(f"ERROR: 本地文件不存在 {ENRICHMENT_PY_LOCAL}", file=sys.stderr)
|
||||
return 2
|
||||
with open(ENRICHMENT_PY_LOCAL, encoding="utf-8") as f:
|
||||
local_content = f.read()
|
||||
print(f"[1/6] 本地 enrichment.py {len(local_content)} 字符,{local_content.count(chr(10))} 行")
|
||||
|
||||
# 1) SSH 连
|
||||
print(f"\n[2/6] 连接 SSH ...")
|
||||
c = paramiko.SSHClient()
|
||||
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
try:
|
||||
c.connect(args.host, port=args.port, username=args.user, password=args.password,
|
||||
timeout=30, banner_timeout=30, auth_timeout=30,
|
||||
allow_agent=False, look_for_keys=False)
|
||||
except Exception as e:
|
||||
print(f" ✗ SSH 连接失败: {e}")
|
||||
return 2
|
||||
print(f" ✓ SSH 连接成功")
|
||||
|
||||
remote_py = f"{args.compose_dir}/backend/app/services/llm/enrichment.py"
|
||||
|
||||
# 2) 拿服务器版本比对
|
||||
print(f"\n[3/6] 比对服务器版 enrichment.py ...")
|
||||
remote_content = get_text(c, remote_py)
|
||||
if remote_content is None:
|
||||
# 不直接 exit,先看一下 llm/ 目录到底有什么(.bak 备份可能在)
|
||||
print(f" ! 服务器文件不存在: {remote_py}")
|
||||
llm_dir = f"{args.compose_dir}/backend/app/services/llm/"
|
||||
rc, ls_out, _ = ssh_exec(c, f"bash -lc 'ls -la {llm_dir} 2>&1'", timeout=15)
|
||||
print(f"\n --- {llm_dir} 目录内容 ---")
|
||||
print(ls_out.rstrip())
|
||||
print(f"\n 解读:")
|
||||
print(f" - 如果有 enrichment.py.bak.* 备份在 → 上次部署成功过,文件被外部操作清掉")
|
||||
print(f" - 如果连 .bak 都没有 → llm/ 目录可能被整体删了,需要重新创建")
|
||||
print(f" - 如果是 ENOENT 整个目录 → /srv/news/backend/app 目录有问题")
|
||||
print(f"\n 建议 SSH 上去确认:")
|
||||
print(f" ssh root@{args.host} -p {args.port}")
|
||||
print(f" ls -la {args.compose_dir}/backend/app/services/llm/")
|
||||
print(f" ls -la {args.compose_dir}/ # 看 backend/ 是否在")
|
||||
print(f"\n 想要我重写脚本继续往下走(把本地版创建为新文件),加 --force-recreate 即可:")
|
||||
print(f" python {sys.argv[0]} --force-recreate")
|
||||
if not getattr(args, "force_recreate", False):
|
||||
c.close()
|
||||
return 2
|
||||
print(f"\n --force-recreate 启用,直接创建新文件(不备份)")
|
||||
# 创建父目录 + 写
|
||||
put_text_via_file(c, remote_py, local_content)
|
||||
print(f" ✓ 已在服务器创建 {remote_py}")
|
||||
|
||||
elif remote_content == local_content:
|
||||
print(f" ✓ 服务器已是最新版本(无需重传)")
|
||||
else:
|
||||
if args.dry_run:
|
||||
print(f" ! 服务器与本地不一致(差异 {len(remote_content)-len(local_content)} 字节),--dry-run 跳过覆盖")
|
||||
c.close()
|
||||
return 0
|
||||
# 备份
|
||||
ts = time.strftime("%Y%m%d_%H%M%S")
|
||||
bak = f"{remote_py}.bak.{ts}"
|
||||
rc, _, _ = ssh_exec(c, f"bash -lc 'cp -f {remote_py} {bak} && echo {bak}'", timeout=15)
|
||||
if rc != 0:
|
||||
print(f" ✗ 备份失败"); c.close(); return 2
|
||||
print(f" ✓ 备份到 {bak}")
|
||||
# 覆盖
|
||||
put_text_via_file(c, remote_py, local_content)
|
||||
print(f" ✓ 覆盖服务器 enrichment.py")
|
||||
|
||||
# 3) 重建 worker 镜像
|
||||
if args.no_build:
|
||||
print(f"\n[4/6] --no-build,跳过 docker build")
|
||||
else:
|
||||
print(f"\n[4/6] docker compose build worker ...")
|
||||
rc, out, err = ssh_exec(c, f"cd {args.compose_dir} && docker compose build worker", timeout=600)
|
||||
if rc != 0:
|
||||
print(f" ✗ build 失败 rc={rc}")
|
||||
print((out + err)[-2000:])
|
||||
c.close()
|
||||
return 2
|
||||
# 只打最后 3 行(成功的标志)
|
||||
tail = "\n".join((out + err).strip().splitlines()[-3:])
|
||||
print(f" ✓ build 成功(尾行): {tail[:300]}")
|
||||
|
||||
# 4) 重启 worker
|
||||
if args.no_restart:
|
||||
print(f"\n[5/6] --no-restart,跳过 up -d")
|
||||
else:
|
||||
print(f"\n[5/6] docker compose up -d worker ...")
|
||||
rc, out, err = ssh_exec(c, f"cd {args.compose_dir} && docker compose up -d worker", timeout=120)
|
||||
if rc != 0:
|
||||
print(f" ✗ 重启失败 rc={rc}")
|
||||
print((out + err)[-2000:])
|
||||
c.close()
|
||||
return 2
|
||||
# 找 "Container ... Started" 这一行
|
||||
started = [l.strip() for l in (out + err).splitlines() if "Started" in l and "worker" in l]
|
||||
print(f" ✓ {started[-1] if started else 'restarted'}")
|
||||
|
||||
# 5) 等 + 看 enrich 状态
|
||||
print(f"\n[6/6] 等 {args.wait}s 让 worker 起来 + enrichment_loop 跑几批 ...")
|
||||
time.sleep(10)
|
||||
# 看 enrichment_loop 启动
|
||||
rc, out, _ = ssh_exec(c, f"cd {args.compose_dir} && docker compose logs --tail=30 worker 2>&1 | grep -iE 'enrich|started' | head -10", timeout=20)
|
||||
if "enrichment_loop started" in out:
|
||||
print(" ✓ enrichment_loop 已启动")
|
||||
else:
|
||||
print(f" ! enrichment_loop 启动标志未找到,日志:")
|
||||
for line in out.splitlines()[-5:]:
|
||||
print(f" {line}")
|
||||
|
||||
# 等 wait 秒
|
||||
print(f"\n 等待 {args.wait}s 让 enrich 真跑起来 ...")
|
||||
time.sleep(args.wait)
|
||||
|
||||
# 6) 看 enrich_article 日志 + n/a 数量
|
||||
rc, log_out, _ = ssh_exec(c, f"cd {args.compose_dir} && docker compose logs --tail=500 worker 2>&1 | grep -E 'enrich_article' | head -10", timeout=20)
|
||||
enrich_count = len([l for l in log_out.splitlines() if "enrich_article" in l])
|
||||
print(f"\n === enrich_article 日志: {enrich_count} 条 ===")
|
||||
for line in log_out.splitlines()[:5]:
|
||||
print(f" {line}")
|
||||
|
||||
# 当前 n/a 数量
|
||||
rc, sql_out, _ = ssh_exec(c, f"cd {args.compose_dir} && docker compose exec -T postgres psql -U news -d news -c \"SELECT classify_status, count(*) FROM articles GROUP BY classify_status ORDER BY count(*) DESC;\"", timeout=30)
|
||||
print(f"\n === 当前 classify_status 分布 ===")
|
||||
print(sql_out.rstrip())
|
||||
|
||||
# 判结果
|
||||
rc_ok = 0
|
||||
if enrich_count == 0:
|
||||
# 看是不是 n/a 数变了(说明 enrichment 跑了但 logger 没打 — 极少见)
|
||||
m = re.search(r"\b(\d+)\s*\|\s*(\d+)\b", sql_out) # 粗略抓两个数
|
||||
# 简单点:让用户自己看
|
||||
print(f"\n ⚠ enrich_article 日志 0 条 — enrich 任务可能没在跑")
|
||||
print(f" 排查:")
|
||||
print(f" docker compose logs worker 2>&1 | grep -E 'enrich|ERROR' | tail -20")
|
||||
rc_ok = 1
|
||||
else:
|
||||
# 看 n/a 数 - 跟 663 对比
|
||||
n_a_match = re.search(r"n/a\s*\|\s*(\d+)", sql_out)
|
||||
if n_a_match:
|
||||
n_a = int(n_a_match.group(1))
|
||||
if n_a < 663:
|
||||
print(f"\n ✓ n/a 数从 663 降到 {n_a} — 修复成功,enrich 在跑")
|
||||
else:
|
||||
print(f"\n ⚠ n/a 数 {n_a} 没变(还在 663+),但有 enrich 日志 — 看具体错")
|
||||
rc_ok = 1
|
||||
|
||||
c.close()
|
||||
print(f"\n==== 结束 (rc={rc_ok}) ====")
|
||||
return rc_ok
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import re
|
||||
sys.exit(main())
|
||||
1288
scripts/healthcheck.py
Normal file
1288
scripts/healthcheck.py
Normal file
File diff suppressed because it is too large
Load Diff
128
scripts/push_agnes_key.py
Normal file
128
scripts/push_agnes_key.py
Normal file
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""安全推送新 AGNES_API_KEY 到服务器的 .env,然后重启 worker。
|
||||
- 旧 key(已暴露过的)会被拒绝使用
|
||||
- 新 key 通过 base64 中转,SSH 进程列表和 bash history 都看不到明文
|
||||
- 写完后立即验证:重启 worker + check_agnes_llm ping 一次
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import base64
|
||||
import os
|
||||
import sys
|
||||
import paramiko
|
||||
|
||||
# === 你之前贴过的、已经暴露的旧 key(防呆:拒绝再次使用)===
|
||||
LEAKED_KEYS = {
|
||||
"sk-F4XwNlhgZbODf1XT9QcWd5ObLsoKIa9v8xUWkNlRRyjwITaC",
|
||||
# 如果你已经轮换过,旧 key 就作废了;但这个常量是"硬性黑名单",永远不推送
|
||||
}
|
||||
|
||||
HOST = os.environ.get("REMOTE_HOST", "207.57.129.228")
|
||||
PORT = int(os.environ.get("REMOTE_PORT", "19717"))
|
||||
USER = os.environ.get("REMOTE_USER", "root")
|
||||
PASS = os.environ.get("REMOTE_PASS", "")
|
||||
COMPOSE_DIR = os.environ.get("COMPOSE_DIR", "/srv/news")
|
||||
NEW_KEY = os.environ.get("NEW_AGNES_KEY", "")
|
||||
|
||||
def die(msg: str, code: int = 1) -> None:
|
||||
print(f"✗ {msg}", file=sys.stderr)
|
||||
sys.exit(code)
|
||||
|
||||
def ssh_exec(c: paramiko.SSHClient, cmd: str, timeout: int = 30) -> tuple[int, str, str]:
|
||||
si, so, se = c.exec_command(cmd, timeout=timeout, get_pty=True)
|
||||
out = so.read().decode(errors="replace")
|
||||
err = se.read().decode(errors="replace")
|
||||
rc = so.channel.recv_exit_status()
|
||||
return rc, out, err
|
||||
|
||||
def main() -> int:
|
||||
# 1) 前置检查
|
||||
if not PASS:
|
||||
die("需要 REMOTE_PASS 环境变量")
|
||||
if not NEW_KEY:
|
||||
die("需要 NEW_AGNES_KEY 环境变量(去 Agnes 控制台重新生成的新 key)")
|
||||
if NEW_KEY in LEAKED_KEYS:
|
||||
die("拒绝:你输入的是已暴露的旧 key。请去 Agnes 控制台撤销 + 重新生成新 key。")
|
||||
if not (NEW_KEY.startswith("sk-") or len(NEW_KEY) >= 20):
|
||||
die(f"NEW_AGNES_KEY 格式可疑(前缀={NEW_KEY[:6]!r},长度={len(NEW_KEY)}),拒绝推送")
|
||||
|
||||
# 2) 预演:本地 echo 一下 key 长度,不显示内容
|
||||
print(f"准备推送:新 key 长度={len(NEW_KEY)},前缀={NEW_KEY[:4]}***")
|
||||
|
||||
# 3) SSH
|
||||
print(f"连 SSH: {USER}@{HOST}:{PORT} ...")
|
||||
c = paramiko.SSHClient()
|
||||
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
c.connect(HOST, port=PORT, username=USER, password=PASS,
|
||||
timeout=30, banner_timeout=30, auth_timeout=30,
|
||||
allow_agent=False, look_for_keys=False)
|
||||
print("✓ SSH 连接成功")
|
||||
|
||||
# 4) 读现有 .env(先备份)
|
||||
rc, out, _ = ssh_exec(c, f"cd {COMPOSE_DIR} && cp -f .env .env.bak.$(date +%Y%m%d_%H%M%S) && ls -la .env*")
|
||||
print("✓ .env 已备份(输出在下方):")
|
||||
for line in out.strip().splitlines():
|
||||
if ".env" in line:
|
||||
print(f" {line}")
|
||||
|
||||
# 5) base64 编码新 key + 远程 shell 里 decode 写文件
|
||||
key_b64 = base64.b64encode(NEW_KEY.encode("utf-8")).decode("ascii")
|
||||
|
||||
# 用 sed 替换 AGNES_API_KEY= 后面的值(支持引号/无引号/带空格)
|
||||
# 转义:用 printf 配合一个不可见分隔符,避免 sed 解释 key 里的特殊字符
|
||||
cmd = (
|
||||
f"cd {COMPOSE_DIR} && "
|
||||
# 把新 key 通过 base64 传到远程 shell 的 env
|
||||
f"export NEW_KEY_B64='{key_b64}' && "
|
||||
# 在远程 sed 里:把 AGNES_API_KEY=xxx 整行替换掉
|
||||
# 用 # 作 sed 分隔符,避免 key 里可能的 / 干扰
|
||||
"sed -i.bak2 -E 's#^AGNES_API_KEY=.*#AGNES_API_KEY=\"'\"$(echo $NEW_KEY_B64 | base64 -d)\"'\"#' .env && "
|
||||
"echo '--- 修改后的 AGNES_API_KEY 行(隐藏中间部分)---' && "
|
||||
"grep '^AGNES_API_KEY=' .env | sed -E 's/(AGNES_API_KEY=\")[^\"]+(\")/\\1***隐藏***\\2/'"
|
||||
)
|
||||
rc, out, err = ssh_exec(c, cmd, timeout=15)
|
||||
if rc != 0:
|
||||
die(f"写 .env 失败 rc={rc} err={err}")
|
||||
print("✓ .env 已更新")
|
||||
for line in out.strip().splitlines():
|
||||
if "AGNES_API_KEY" in line:
|
||||
print(f" {line}")
|
||||
|
||||
# 6) 重启 worker(让新 key 生效)
|
||||
print("重启 worker 容器...")
|
||||
rc, out, err = ssh_exec(c, f"cd {COMPOSE_DIR} && docker compose restart worker", timeout=60)
|
||||
if rc != 0:
|
||||
die(f"重启 worker 失败 rc={rc} err={err[:200]}")
|
||||
print(f"✓ {out.strip().splitlines()[-1] if out.strip() else 'restarted'}")
|
||||
|
||||
# 7) 等 worker 起来 + 跑 Agnes ping
|
||||
print("等 worker 起来(5s)...")
|
||||
ssh_exec(c, "sleep 5", timeout=10)
|
||||
|
||||
# 直接调 healthcheck 里的 check_agnes_llm
|
||||
print("验证 Agnes LLM ping...")
|
||||
rc, out, _ = ssh_exec(c, f"cd {COMPOSE_DIR} && set -a && . ./.env && set +a && "
|
||||
f"echo \"AGNES_API_KEY present: $([ -n \"$AGNES_API_KEY\" ] && echo yes || echo no)\" && "
|
||||
f"docker compose exec -T worker python -c "
|
||||
f"'import asyncio; from app.services.llm.client import LlmClient; "
|
||||
f"c = LlmClient(); print(\"configured:\", c.is_configured())' 2>&1 | tail -5",
|
||||
timeout=20)
|
||||
for line in out.strip().splitlines():
|
||||
print(f" {line}")
|
||||
|
||||
c.close()
|
||||
print()
|
||||
print("🎉 完成。已就绪的事情:")
|
||||
print(" 1) 旧 key 拒绝推送,你用的是新 key")
|
||||
print(" 2) .env 已更新 + 备份 + 重启 worker")
|
||||
print(" 3) Agnes 凭据在 worker 里加载成功(如有异常,看上面输出)")
|
||||
print()
|
||||
print("接下来你可以:")
|
||||
print(" - 跑 healthcheck 验证(check_agnes_llm + check_llm_workflow)")
|
||||
print(" - 手动 enrich 几篇老文章测试:")
|
||||
print(" docker compose exec api python -m app.scripts.re_enrich --limit 3")
|
||||
print(" - 等几小时,看 worker 自动跑批,LLM 工作流状态从 n/a → ok")
|
||||
return 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
208
scripts/push_to_gitea.py
Normal file
208
scripts/push_to_gitea.py
Normal file
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""一键推送到 Gitea 远程。
|
||||
|
||||
流程(每步都先预览,要你确认才进下一步):
|
||||
1. git status + diff --stat → 让你看要推哪些文件
|
||||
2. 检查 .env / secrets 是否会进 git(check-ignore + 内容嗅探)
|
||||
3. 检查 APK / 大文件
|
||||
4. git add <你指定的文件> → 不 add . / 全量,精准 add
|
||||
5. git status 再次确认
|
||||
6. git commit -m <你给的 message> → 没 message 就退出
|
||||
7. git push origin main → 推
|
||||
|
||||
退出码:
|
||||
0 = 成功推到 origin/main
|
||||
1 = 你取消
|
||||
2 = 推送失败(网络 / 认证 / 冲突)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
REPO_DIR = r"D:\selftools\diary-news"
|
||||
REMOTE = "origin"
|
||||
BRANCH = "main"
|
||||
|
||||
|
||||
def run(cmd: str, check: bool = True, capture: bool = True) -> subprocess.CompletedProcess:
|
||||
"""在仓库目录跑 git 命令。"""
|
||||
return subprocess.run(
|
||||
cmd, shell=True, cwd=REPO_DIR, check=check,
|
||||
capture_output=capture, text=True, encoding="utf-8", errors="replace",
|
||||
)
|
||||
|
||||
|
||||
def confirm(prompt: str, default_yes: bool = False) -> bool:
|
||||
suffix = "[Y/n]" if default_yes else "[y/N]"
|
||||
ans = input(f"{prompt} {suffix}: ").strip().lower()
|
||||
if not ans:
|
||||
return default_yes
|
||||
return ans in ("y", "yes")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="推送 diary-news 到 Gitea")
|
||||
ap.add_argument("--message", "-m", help="commit message(必填,不然 dry-run 后停)")
|
||||
ap.add_argument("--files", nargs="*", help="要 add 的文件路径(默认 add 所有 untracked + modified)")
|
||||
ap.add_argument("--do-push", action="store_true", default=True,
|
||||
help="默认会真推;传 --no-do-push 只 commit 不 push")
|
||||
ap.add_argument("--no-do-push", dest="do_push", action="store_false")
|
||||
ap.add_argument("--allow-apk", action="store_true", help="允许推 APK(默认会拦)")
|
||||
args = ap.parse_args()
|
||||
|
||||
print(f"==== 仓库: {REPO_DIR} ====")
|
||||
print(f"==== 远程: {REMOTE}/{BRANCH} ====\n")
|
||||
|
||||
# 0) 远程是否同步
|
||||
r = run("git rev-parse --abbrev-ref HEAD")
|
||||
if r.stdout.strip() != BRANCH:
|
||||
print(f"✗ 当前在 {r.stdout.strip()},不在 {BRANCH} 分支,拒绝推")
|
||||
return 2
|
||||
print(f"✓ 在 {BRANCH} 分支")
|
||||
|
||||
r = run("git fetch --quiet " + REMOTE)
|
||||
if r.returncode != 0:
|
||||
print(f"✗ fetch 失败: {r.stderr}"); return 2
|
||||
|
||||
behind = run(f"git log HEAD..{REMOTE}/{BRANCH} --oneline", capture=True).stdout.strip()
|
||||
if behind:
|
||||
print(f"✗ 本地落后于 {REMOTE}/{BRANCH},先 pull:")
|
||||
print(behind)
|
||||
return 2
|
||||
print(f"✓ 本地与 {REMOTE}/{BRANCH} 同步")
|
||||
|
||||
# 1) 当前状态
|
||||
print(f"\n==== 1) git status ====")
|
||||
print(run("git status --short").stdout.rstrip() or "(无变更)")
|
||||
|
||||
# 2) 大文件嗅探(> 5MB) — 用 git status --porcelain + Python 拿 size
|
||||
# (避开 shell pipe 退出码 255 / xargs 空输入 / Windows 路径带空格)
|
||||
print(f"\n==== 2) 大文件嗅探(> 5MB) ====")
|
||||
r = run("git status --porcelain", check=False)
|
||||
big = [] # [(size_mb, path), ...]
|
||||
for line in r.stdout.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
# 格式: "XY filename" — XY 是 2 字符,后面可能 1 空格或更复杂(rename)
|
||||
# 取第三个 token 起为路径
|
||||
parts = line.split(maxsplit=1)
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
path = parts[1].strip().strip('"')
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
try:
|
||||
size_mb = os.path.getsize(path) / 1024 / 1024
|
||||
except OSError:
|
||||
continue
|
||||
if size_mb > 5:
|
||||
big.append((size_mb, path))
|
||||
big.sort(key=lambda x: -x[0])
|
||||
if big:
|
||||
for size_mb, path in big:
|
||||
print(f" ⚠ {size_mb:.1f} MB {path}")
|
||||
else:
|
||||
print(" ✓ 无 > 5MB 文件")
|
||||
|
||||
if big and not args.allow_apk:
|
||||
apk = [p for _, p in big if p.lower().endswith(".apk")]
|
||||
if apk:
|
||||
print(f"\n ✗ 检测到 APK,默认拒绝推送(传 --allow-apk 强制推)")
|
||||
print(f" 建议:把 '{apk[0]}' 加进 .gitignore,或上传到 release page")
|
||||
return 1
|
||||
|
||||
# 3) 敏感文件嗅探(.env / secrets / .key / .pem) — 纯 Python
|
||||
print(f"\n==== 3) 敏感文件嗅探 ====")
|
||||
sensitive = []
|
||||
keywords = (".env", "secret", "password", ".key", ".pem", "credentials")
|
||||
r = run("git status --porcelain", check=False)
|
||||
for line in r.stdout.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
path = line.split(maxsplit=1)[1].strip().strip('"')
|
||||
low = path.lower()
|
||||
if any(k in low for k in keywords):
|
||||
sensitive.append(path)
|
||||
if sensitive:
|
||||
for p in sensitive:
|
||||
print(f" ⚠ {p}")
|
||||
else:
|
||||
print(" ✓ 未发现敏感文件(.env / secrets / key / pem)")
|
||||
|
||||
# 4) 选文件
|
||||
print(f"\n==== 4) 选择要 add 的文件 ====")
|
||||
if args.files:
|
||||
files = args.files
|
||||
else:
|
||||
# 默认 add 所有 untracked + modified(不包含被 ignore 的)
|
||||
r = run("git ls-files --modified --others --exclude-standard")
|
||||
files = [f for f in r.stdout.splitlines() if f.strip()]
|
||||
if not files:
|
||||
print(" (无文件可 add)"); return 1
|
||||
print(f" 共 {len(files)} 个:")
|
||||
for f in files:
|
||||
size_note = ""
|
||||
if f.lower().endswith(".apk"):
|
||||
size_note = " ← APK!"
|
||||
print(f" {f}{size_note}")
|
||||
|
||||
if not confirm("确认 add 上面这些?"):
|
||||
print("已取消"); return 1
|
||||
|
||||
# 5) git add
|
||||
for f in files:
|
||||
r = run(f'git add -- "{f}"')
|
||||
if r.returncode != 0:
|
||||
print(f" ✗ git add {f} 失败: {r.stderr}"); return 2
|
||||
print(f" ✓ 已 add {len(files)} 个文件")
|
||||
|
||||
# 6) 再确认 status
|
||||
print(f"\n==== 5) git status(已 staged) ====")
|
||||
print(run("git status --short").stdout.rstrip())
|
||||
|
||||
# 7) diff stat
|
||||
print(f"\n==== 6) diff --stat(将 commit 的内容) ====")
|
||||
print(run("git diff --cached --stat").stdout.rstrip())
|
||||
|
||||
# 8) commit
|
||||
if not args.message:
|
||||
msg = input("commit message (直接回车用 'chore: ...'): ").strip()
|
||||
if not msg:
|
||||
msg = "chore: push via push_to_gitea.py"
|
||||
else:
|
||||
msg = args.message
|
||||
print(f"\n==== 7) git commit -m \"{msg}\" ====")
|
||||
r = run(f'git commit -m "{msg}"')
|
||||
if r.returncode != 0:
|
||||
# 可能是空 commit 或 hooks 拒
|
||||
print(f" ! commit 退出码 {r.returncode}")
|
||||
print(r.stdout)
|
||||
print(r.stderr)
|
||||
return 2
|
||||
print(f" ✓ commit 成功")
|
||||
print(r.stdout.rstrip())
|
||||
|
||||
# 9) push
|
||||
if not args.push:
|
||||
print(f"\n==== 8) --no-push 跳过,只 commit ====")
|
||||
print(" 下次手动:git push origin main")
|
||||
return 0
|
||||
|
||||
print(f"\n==== 8) git push {REMOTE} {BRANCH} ====")
|
||||
if not confirm(f"确认推 {REMOTE}/{BRANCH}?"):
|
||||
print("已取消,commit 已留下但未推"); return 1
|
||||
r = run(f"git push {REMOTE} {BRANCH}")
|
||||
if r.returncode != 0:
|
||||
print(f" ✗ push 失败: {r.stderr}"); return 2
|
||||
print(f" ✓ push 成功")
|
||||
print(r.stdout.rstrip())
|
||||
|
||||
print(f"\n🎉 完成!新 commit 已推 {REMOTE}/{BRANCH}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
33
scripts/test_auth.py
Normal file
33
scripts/test_auth.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""模拟:跳过 SSH,直接调 GROUPS 的 lambda"""
|
||||
import sys
|
||||
sys.path.insert(0, r'D:\selftools\diary-news\scripts')
|
||||
|
||||
# 绕过 SSH:让 Remote.local 直接走本机 + 接受任意参数
|
||||
import healthcheck as hc
|
||||
|
||||
# 关键:模拟 main 跑过的副作用
|
||||
hc.COMPOSE_DIR = "/srv/news"
|
||||
hc.API_BASE = "http://127.0.0.1/api/v1/healthz"
|
||||
hc.SAMPLE_N = 3
|
||||
|
||||
# 直接给 AUTH_TOKEN 赋值,看 lambda 能不能取到
|
||||
hc.AUTH_TOKEN = "fake-token-123"
|
||||
|
||||
# mock Remote 让 check 函数不真发请求
|
||||
class FakeRemote:
|
||||
def run(self, cmd, timeout=10):
|
||||
# 返回一些可解析的内容
|
||||
if "curl" in cmd and "articles" in cmd and "id=" not in cmd:
|
||||
return 0, '{"items":[{"id":542,"title":"x","title_zh":"X","translation_status":"ok","translation_engine":"tencent"}],"total":1,"total_pages":1}\n---HTTP=200---\n', ""
|
||||
return 0, "ok", ""
|
||||
|
||||
remote = FakeRemote()
|
||||
|
||||
# 直接调 GROUPS['app'] 里那两个会读 AUTH_TOKEN 的
|
||||
for name, fn in hc.GROUPS['app']:
|
||||
if "Feed API" in name or "详情页" in name:
|
||||
try:
|
||||
c = fn(remote)
|
||||
print(f"{name}: ok ok={c.ok} summary={c.summary}")
|
||||
except Exception as e:
|
||||
print(f"{name}: EXC {type(e).__name__}: {e}")
|
||||
13
scripts/test_curl_401.py
Normal file
13
scripts/test_curl_401.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""repro: curl 401 时的 stdout 形态"""
|
||||
import subprocess
|
||||
# 走 localhost
|
||||
r = subprocess.run(
|
||||
["curl", "-sS", "-m", "8",
|
||||
"-w", "\n---HTTP=%{http_code} TIME=%{time_total}---\n",
|
||||
"http://127.0.0.1:9999/nonexistent"], # 不存在的端口
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
print("=== 不存在的端口(预期:curl 报错,http=000)===")
|
||||
print("rc:", r.returncode)
|
||||
print("stdout:", repr(r.stdout[:300]))
|
||||
print("stderr:", repr(r.stderr[:200]))
|
||||
14
scripts/test_global_repro.py
Normal file
14
scripts/test_global_repro.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""exact repro: 跟脚本结构一致"""
|
||||
GROUPS = {
|
||||
"x": [("t", lambda r: (r, AUTH_TOKEN))] # AUTH_TOKEN 在模块全局
|
||||
}
|
||||
|
||||
def main():
|
||||
global AUTH_TOKEN
|
||||
AUTH_TOKEN = "hello"
|
||||
for g, fns in GROUPS.items():
|
||||
for name, fn in fns:
|
||||
print(name, "->", fn("R"))
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
39
scripts/verify_enrich.py
Normal file
39
scripts/verify_enrich.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""30 秒后再连,看 enrich 是否开始干活"""
|
||||
import os, paramiko, time
|
||||
time.sleep(15) # 等等让它跑一会
|
||||
c = paramiko.SSHClient()
|
||||
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
c.connect("207.57.129.228", port=19717, username="root",
|
||||
password=os.environ["REMOTE_PASS"],
|
||||
timeout=30, allow_agent=False, look_for_keys=False)
|
||||
|
||||
|
||||
def run(label, cmd, timeout=30):
|
||||
print(f"\n=== {label} ===")
|
||||
try:
|
||||
si, so, se = c.exec_command(cmd, timeout=timeout)
|
||||
out = so.read().decode(errors="replace")
|
||||
err = se.read().decode(errors="replace")
|
||||
print(out.rstrip())
|
||||
if err.strip(): print(f"[stderr] {err.rstrip()}")
|
||||
except Exception as e:
|
||||
print(f"[exc] {type(e).__name__}: {e}")
|
||||
|
||||
|
||||
# 1) enrichment_loop 启动
|
||||
run("1) enrichment_loop 启动", "bash -lc 'cd /srv/news && docker compose logs --tail=50 worker 2>&1 | grep -iE \"enrich|started\" | head -20'")
|
||||
|
||||
# 2) enrich_article 日志(关键)
|
||||
run("2) enrich_article 日志", "bash -lc 'cd /srv/news && docker compose logs --tail=200 worker 2>&1 | grep -E \"enrich_article|classify|format ok|commentary ok\" | head -20'")
|
||||
|
||||
# 3) 当前 n/a 数
|
||||
run("3) 当前 n/a 数", "bash -lc 'cd /srv/news && docker compose exec -T postgres psql -U news -d news -c \"SELECT classify_status, count(*) FROM articles GROUP BY classify_status ORDER BY count(*) DESC;\"'")
|
||||
|
||||
# 4) 1 分钟后再看一次
|
||||
time.sleep(60)
|
||||
run("4) 1 分钟后 n/a 数", "bash -lc 'cd /srv/news && docker compose exec -T postgres psql -U news -d news -c \"SELECT classify_status, count(*) FROM articles GROUP BY classify_status ORDER BY count(*) DESC;\"'")
|
||||
|
||||
# 5) enrich_article 日志
|
||||
run("5) 1 分钟后 enrich_article 日志", "bash -lc 'cd /srv/news && docker compose logs --tail=100 worker 2>&1 | grep enrich_article | tail -20'")
|
||||
|
||||
c.close()
|
||||
34
scripts/verify_worker.py
Normal file
34
scripts/verify_worker.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""看完整 worker 启动 + 状态"""
|
||||
import os, paramiko
|
||||
c = paramiko.SSHClient()
|
||||
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
c.connect("207.57.129.228", port=19717, username="root",
|
||||
password=os.environ["REMOTE_PASS"],
|
||||
timeout=30, allow_agent=False, look_for_keys=False)
|
||||
|
||||
|
||||
def run(label, cmd, timeout=30):
|
||||
print(f"\n=== {label} ===")
|
||||
try:
|
||||
si, so, se = c.exec_command(cmd, timeout=timeout)
|
||||
out = so.read().decode(errors="replace")
|
||||
err = se.read().decode(errors="replace")
|
||||
print(out.rstrip())
|
||||
if err.strip(): print(f"[stderr] {err.rstrip()}")
|
||||
except Exception as e:
|
||||
print(f"[exc] {type(e).__name__}: {e}")
|
||||
|
||||
|
||||
# 1) worker 启动 INFO 日志
|
||||
run("1) worker 启动 INFO 日志", "bash -lc 'cd /srv/news && docker compose logs --tail=80 worker 2>&1 | grep -E \"INFO|ERROR|WARNING\" | grep -v httpx | head -30'")
|
||||
|
||||
# 2) worker 是否在跑
|
||||
run("2) worker ps", "bash -lc 'cd /srv/news && docker compose ps worker'")
|
||||
|
||||
# 3) enrichment_loop 文件确认(我看的是改完的版本吗?)
|
||||
run("3) 服务器 enrichment.py 头部", "bash -lc 'head -10 /srv/news/backend/app/services/llm/enrichment.py.new 2>/dev/null; echo ---; head -10 /srv/news/backend/app/services/llm/enrichment.py'")
|
||||
|
||||
# 4) 容器内 enrichment.py 第 410-425 行(看是不是新版本)
|
||||
run("4) 容器内 enrichment.py 410-425 行", "bash -lc 'cd /srv/news && docker compose exec -T worker sed -n \"405,435p\" /app/app/services/llm/enrichment.py'")
|
||||
|
||||
c.close()
|
||||
Reference in New Issue
Block a user