Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions config/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,12 @@ ai:

max_tokens: 5000 # 最大生成 token 数
# 注意:如果 API 不支持此参数(报 HTTP 400),请设为 0 以禁用发送

# 推理强度(仅推理型模型有效,如 gpt-5 / o 系列 / grok;普通模型请留空)
# 留空 = 不发送该参数
# 可选值: minimal / low / medium / high
reasoning_effort: ""

# 高级选项
num_retries: 1 # 失败重试次数
fallback_models: [] # 备用模型列表(可选)
Expand Down
3 changes: 3 additions & 0 deletions docker/docker-compose-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ services:
volumes:
- ../config:/app/config:ro
- ../output:/app/output
- ../trendradar:/app/trendradar
- ../docker/manage.py:/app/manage.py
- ../docker/entrypoint.sh:/entrypoint.sh:ro

environment:
- TZ=Asia/Shanghai
Expand Down
3 changes: 3 additions & 0 deletions docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ services:
volumes:
- ../config:/app/config:ro
- ../output:/app/output
- ../trendradar:/app/trendradar
- ../docker/manage.py:/app/manage.py
- ../docker/entrypoint.sh:/entrypoint.sh:ro

environment:
- TZ=Asia/Shanghai
Expand Down
8 changes: 4 additions & 4 deletions docker/entrypoint.sh
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,16 @@ case "${RUN_MODE:-cron}" in
exit 1
fi

# 先启动 Web 服务器,避免 IMMEDIATE_RUN 抓取阻塞面板访问
echo "🌐 启动 Web 服务器..."
python manage.py start_webserver

# 立即执行一次(如果配置了)
if [ "${IMMEDIATE_RUN:-false}" = "true" ]; then
echo "▶️ 立即执行一次"
python -m trendradar
fi

# 启动 Web 服务器
echo "🌐 启动 Web 服务器..."
python manage.py start_webserver

echo "⏰ 启动supercronic: $CRON_EXPR"
echo "🎯 supercronic 将作为 PID 1 运行"

Expand Down
25 changes: 19 additions & 6 deletions docker/manage.py
Original file line number Diff line number Diff line change
Expand Up @@ -454,7 +454,10 @@ def _is_expected_webserver_process(pid: int) -> bool:
cmdline = _read_proc_cmdline(pid)
if not cmdline:
return False
return "http.server" in cmdline and str(WEBSERVER_PORT) in cmdline
return (
str(WEBSERVER_PORT) in cmdline
and ("http.server" in cmdline or "trendradar.web_control" in cmdline)
)


def _terminate_webserver_process(pid: int, require_expected: bool = True) -> bool:
Expand Down Expand Up @@ -531,7 +534,7 @@ def _cleanup_stale_pid():
def start_webserver():
"""启动 Web 服务器托管 output 目录"""
print(f"🌐 启动 Web 服务器 (端口: {WEBSERVER_PORT})...")
print(f" 🔒 安全提示:仅提供静态文件访问,限制在 {WEBSERVER_DIR} 目录")
print(f" 🔒 报告目录: {WEBSERVER_DIR};控制面板可手动抓取 / 分析")

# 检查是否已经运行
if Path(WEBSERVER_PID_FILE).exists():
Expand Down Expand Up @@ -564,12 +567,21 @@ def start_webserver():
# 启动 HTTP 服务器
# 使用 --bind 绑定到 0.0.0.0 使容器内部可访问
# 工作目录限制在 WEBSERVER_DIR,防止访问其他目录
here = Path(__file__).resolve().parent
project_root = str(here if (here / "trendradar").is_dir() else here.parent)
process = subprocess.Popen(
[sys.executable, '-m', 'http.server', str(WEBSERVER_PORT), '--bind', '0.0.0.0'],
cwd=WEBSERVER_DIR,
[
sys.executable,
"-m",
"trendradar.web_control",
str(WEBSERVER_PORT),
WEBSERVER_DIR,
project_root,
],
cwd=project_root,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True
start_new_session=True,
)

# 等待一下确保服务器启动
Expand All @@ -581,7 +593,8 @@ def start_webserver():
with open(WEBSERVER_PID_FILE, 'w') as f:
f.write(str(process.pid))
print(f" ✅ Web 服务器已启动 (PID: {process.pid})")
print(f" 📁 服务目录: {WEBSERVER_DIR} (只读,仅静态文件)")
print(f" 📁 服务目录: {WEBSERVER_DIR}")
print(f" 🎛️ 控制面板: 报告页顶部可手动抓取 / AI 分析 / 切换预设")
print(f" 🌐 访问地址: http://localhost:{WEBSERVER_PORT}")
print(f" 📄 首页: http://localhost:{WEBSERVER_PORT}/index.html")
print(" 💡 停止服务: python manage.py stop_webserver")
Expand Down
79 changes: 79 additions & 0 deletions tests/test_ai_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# coding=utf-8
import unittest
from unittest.mock import patch


def _fake_response():
class Message:
content = "ok"

class Choice:
message = Message()

class Resp:
choices = [Choice()]

return Resp()


class AIClientParamTests(unittest.TestCase):
def _capture(self, config, kwargs=None):
from trendradar.ai import client as client_mod

captured = {}

def fake_completion(**params):
captured.update(params)
return _fake_response()

with patch.object(client_mod, "completion", fake_completion):
client = client_mod.AIClient(config)
client.chat([{"role": "user", "content": "hi"}], **(kwargs or {}))
return captured

def test_reasoning_effort_sent_when_set(self):
# openai/ 前缀(含自定义兼容端点)走 extra_body 透传,避免 litellm 白名单校验
params = self._capture(
{"MODEL": "openai/custom-reasoner", "API_KEY": "sk-x", "REASONING_EFFORT": " HIGH "}
)
self.assertEqual(params["extra_body"]["reasoning_effort"], "high")
self.assertNotIn("reasoning_effort", params)

def test_reasoning_effort_top_level_for_other_providers(self):
# 非 openai 提供商走顶层参数,由 litellm 完成跨商映射
params = self._capture(
{"MODEL": "anthropic/claude-sonnet-4", "API_KEY": "sk-x", "REASONING_EFFORT": "high"}
)
self.assertEqual(params["reasoning_effort"], "high")
self.assertNotIn("extra_body", params)

def test_reasoning_effort_omitted_when_empty(self):
params = self._capture(
{"MODEL": "openai/gpt-4o", "API_KEY": "sk-x", "REASONING_EFFORT": ""}
)
self.assertNotIn("reasoning_effort", params)
self.assertNotIn("extra_body", params)

def test_reasoning_effort_overridable_per_call(self):
params = self._capture(
{"MODEL": "openai/gpt-5", "API_KEY": "sk-x", "REASONING_EFFORT": "low"},
{"reasoning_effort": "high"},
)
self.assertEqual(params["extra_body"]["reasoning_effort"], "high")

def test_extra_params_merged_without_override(self):
params = self._capture(
{
"MODEL": "openai/gpt-4o",
"API_KEY": "sk-x",
"TEMPERATURE": 0.5,
"EXTRA_PARAMS": {"top_p": 0.9, "temperature": 2.0},
}
)
self.assertEqual(params["top_p"], 0.9)
# 显式配置的 temperature 优先于 extra_params
self.assertEqual(params["temperature"], 0.5)


if __name__ == "__main__":
unittest.main()
Loading