Skip to content
Merged
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
25 changes: 25 additions & 0 deletions app/dashboard/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,7 @@ class ReusableThreadingHTTPServer(ThreadingHTTPServer):
{"name": "CROSSDESK_BASE_URL", "label": "Crossdesk Base URL", "group": "上游模型覆盖", "kind": "text", "default": "", "effect": "next_run"},
{"name": "CROSSDESK_API_KEY", "label": "Crossdesk API Key", "group": "上游模型覆盖", "kind": "secret", "default": "", "effect": "next_run"},
{"name": "DASHBOARD_GROK_MODEL", "label": "Grok 模型", "group": "牛牛美股", "kind": "text", "default": "grok-4.20-multi-agent-xhigh", "effect": "next_run"},
{"name": "DASHBOARD_GROK_API_MODE", "label": "Grok 搜索工具接口模式", "group": "牛牛美股", "kind": "api_mode", "default": "auto", "effect": "next_run"},
{"name": "DASHBOARD_GROK_CONTEXT_LENGTH", "label": "Grok 模型上下文长度", "group": "牛牛美股", "kind": "context_length", "default": DEFAULT_MODEL_CONTEXT_LENGTH, "effect": "next_run"},
{"name": "DASHBOARD_GROK_MAX_TOKENS", "label": "Grok 最大输出长度", "group": "牛牛美股", "kind": "max_tokens", "default": DEFAULT_MODEL_MAX_TOKENS, "effect": "next_run"},
{"name": "DASHBOARD_GROK_BASE_URL", "label": "Grok API 地址", "group": "牛牛美股", "kind": "text", "default": "", "effect": "next_run"},
Expand Down Expand Up @@ -386,6 +387,7 @@ class ReusableThreadingHTTPServer(ThreadingHTTPServer):

{"name": "X_WATCHLIST_STRICT_CONTEXT_HOLD", "label": "X 上下文缺失时暂缓发送", "group": "X 监控", "kind": "bool", "default": "0", "effect": "next_run"},
{"name": "X_WATCHLIST_DEADLINE_SECONDS", "label": "X 总截止秒数", "group": "X 监控", "kind": "int", "default": "135", "effect": "next_run"},
{"name": "X_WATCHLIST_REQUEST_TIMEOUT_SECONDS", "label": "X 单账号请求超时秒数", "group": "牛牛美股", "kind": "int", "default": "45", "effect": "next_run"},
{"name": "X_WATCHLIST_SCRIPT_ALARM_SECONDS", "label": "X 脚本 alarm 秒数", "group": "X 监控", "kind": "int", "default": "90", "effect": "next_run"},
{"name": "X_WATCHLIST_MAX_WORKERS", "label": "X 抓取并发", "group": "X 监控", "kind": "int", "default": "5", "effect": "next_run"},
{"name": "X_WATCHLIST_MAX_ATTEMPTS", "label": "X 抓取重试次数", "group": "X 监控", "kind": "int", "default": "1", "effect": "next_run"},
Expand All @@ -407,12 +409,14 @@ class ReusableThreadingHTTPServer(ThreadingHTTPServer):
"DASHBOARD_ADMIN_PASSWORD",
"DASHBOARD_US_FEATURES_ENABLED",
"DASHBOARD_GROK_MODEL",
"DASHBOARD_GROK_API_MODE",
"DASHBOARD_GROK_CONTEXT_LENGTH",
"DASHBOARD_GROK_MAX_TOKENS",
"DASHBOARD_GROK_BASE_URL",
"DASHBOARD_GROK_API_KEY",
"X_WATCHLIST_ACCOUNTS",
"X_WATCHLIST_DAEMON_INTERVAL_SECONDS",
"X_WATCHLIST_REQUEST_TIMEOUT_SECONDS",
"DASHBOARD_US_RATING_CRON",
"US_RATING_CONTEXT_LENGTH",
"US_RATING_MAX_TOKENS",
Expand Down Expand Up @@ -2369,6 +2373,19 @@ def normalize_env_update(name: str, value: str, kind: str) -> str:
int(value)
if kind in {"max_tokens", "context_length"}:
return normalize_context_length_update(value)
if kind == "api_mode":
normalized = value.lower().replace("-", "_") or "auto"
aliases = {
"auto": "auto",
"responses": "responses",
"response": "responses",
"chat": "chat",
"chat_completions": "chat",
"chat_completion": "chat",
}
if normalized not in aliases:
raise ValueError("API 接口模式必须是 auto、responses 或 chat")
return aliases[normalized]
if kind == "time":
normalized = normalize_hhmm(value)
if value and not normalized:
Expand Down Expand Up @@ -2751,13 +2768,15 @@ def removed_notification_config_names(channel_ids: set[str] | list[str] | tuple[
"US_RATING_CONTEXT_LENGTH",
"US_RATING_MAX_TOKENS",
"DASHBOARD_GROK_MODEL",
"DASHBOARD_GROK_API_MODE",
"DASHBOARD_GROK_CONTEXT_LENGTH",
"DASHBOARD_GROK_MAX_TOKENS",
"DASHBOARD_GROK_BASE_URL",
"DASHBOARD_GROK_API_KEY",
"X_WATCHLIST_ACCOUNTS",
"X_WATCHLIST_MAX_TOKENS",
"X_WATCHLIST_DAEMON_INTERVAL_SECONDS",
"X_WATCHLIST_REQUEST_TIMEOUT_SECONDS",
"DASHBOARD_US_RATING_CRON",
"US_RATING_DEADLINE_SECONDS",
"US_RATING_REQUEST_TIMEOUT_SECONDS",
Expand Down Expand Up @@ -2952,6 +2971,8 @@ def normalize_business_updates(updates: dict[str, str]) -> dict[str, str]:
normalized[name] = normalize_preset_strategy_text_update(normalized[name])
elif ENV_CONFIG_BY_NAME.get(name, {}).get("kind") == "trade_discipline_text":
normalized[name] = normalize_trade_discipline_text_update(normalized[name])
elif ENV_CONFIG_BY_NAME.get(name, {}).get("kind") == "api_mode":
normalized[name] = normalize_env_update(name, normalized[name], "api_mode")
elif ENV_CONFIG_BY_NAME.get(name, {}).get("kind") in {"max_tokens", "context_length"}:
normalized[name] = normalize_context_length_update(normalized[name])
return normalized
Expand Down Expand Up @@ -3018,6 +3039,10 @@ def validate_business_updates(updates: dict[str, str]) -> None:
timeout = int(value)
if timeout < 1 or timeout > 30:
raise ValueError(f"{name} 必须在 1 到 30 之间")
elif name == "X_WATCHLIST_REQUEST_TIMEOUT_SECONDS" and str(value or "").strip():
timeout = int(value)
if timeout < 8 or timeout > 120:
raise ValueError(f"{name} 必须在 8 到 120 之间")
elif name in {
"DASHBOARD_MAX_SINGLE_POSITION_PCT",
"DASHBOARD_MAX_TOTAL_POSITION_PCT",
Expand Down
93 changes: 83 additions & 10 deletions app/monitoring/x/monitor_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@
def load_dashboard_env() -> None:
allowed = {
"DASHBOARD_GROK_MODEL",
"DASHBOARD_GROK_API_MODE",
"DASHBOARD_GROK_CONTEXT_LENGTH",
"DASHBOARD_GROK_BASE_URL",
"DASHBOARD_GROK_API_KEY",
Expand All @@ -106,6 +107,7 @@ def load_dashboard_env() -> None:
"X_WATCHLIST_BASE_URL",
"X_WATCHLIST_API_KEY",
"X_WATCHLIST_ACCOUNTS",
"X_WATCHLIST_REQUEST_TIMEOUT_SECONDS",
"CROSSDESK_BASE_URL",
"CROSSDESK_API_KEY",
}
Expand Down Expand Up @@ -150,6 +152,7 @@ def configured_max_tokens(default: int) -> int:


MODEL = os.environ.get("X_WATCHLIST_MODEL") or os.environ.get("DASHBOARD_GROK_MODEL") or "grok-4.20-multi-agent-xhigh"
GROK_API_MODE = os.environ.get("DASHBOARD_GROK_API_MODE") or "auto"
X_WATCHLIST_CONTEXT_LENGTH = env_token_count("X_WATCHLIST_CONTEXT_LENGTH", "DASHBOARD_GROK_CONTEXT_LENGTH", default=128000)
X_WATCHLIST_MAX_TOKENS = env_token_count("X_WATCHLIST_MAX_TOKENS", default=4096)
CROSSDESK_PROVIDER_NAME = "Crossdesk.ccwu.cc"
Expand All @@ -159,7 +162,13 @@ def configured_max_tokens(default: int) -> int:
# Grok-backed X fetching can intermittently return empty/non-JSON content or run slow;
# those should be treated as transient poll misses, not user-visible job failures.
TOTAL_DEADLINE_SECONDS = 135
REQUEST_TIMEOUT_SECONDS = 25
try:
REQUEST_TIMEOUT_SECONDS = max(
8,
min(120, int(os.environ.get("X_WATCHLIST_REQUEST_TIMEOUT_SECONDS") or "45")),
)
except (TypeError, ValueError):
REQUEST_TIMEOUT_SECONDS = 45
DETAIL_REQUEST_TIMEOUT_SECONDS = 8
REPAIR_REQUEST_TIMEOUT_SECONDS = 10
HELD_CONTEXT_REPAIR_TIMEOUT_SECONDS = 8
Expand Down Expand Up @@ -257,14 +266,63 @@ def is_temporary_error(exc):
return False


def openai_chat_json(base_url, api_key, prompt, max_tokens, timeout=REQUEST_TIMEOUT_SECONDS):
payload = {
"model": MODEL,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": max_tokens,
}
def responses_output_text(data):
direct = str(data.get("output_text") or "").strip()
if direct:
return direct
parts = []
for item in data.get("output") or []:
if not isinstance(item, dict):
continue
for content in item.get("content") or []:
if not isinstance(content, dict):
continue
if content.get("type") in {"output_text", "text"} and content.get("text"):
parts.append(str(content["text"]))
return "\n".join(parts).strip()


def uses_responses_api(mode, model):
normalized = str(mode or "auto").strip().lower().replace("-", "_")
if normalized in {"responses", "response"}:
return True
if normalized in {"chat", "chat_completions", "chat_completion"}:
return False
# Preserve zero-config compatibility for the first Grok generation that
# requires tool-backed Responses requests, while allowing gateways and
# future model aliases to opt in explicitly.
return str(model or "").strip().lower().startswith("grok-4.5")


def openai_chat_json(base_url, api_key, prompt, max_tokens, timeout=REQUEST_TIMEOUT_SECONDS, x_handles=None):
use_responses_tools = uses_responses_api(GROK_API_MODE, MODEL)
if use_responses_tools:
handles = []
for raw_handle in x_handles or []:
handle = str(raw_handle or "").strip().lstrip("@").lower()
if handle and handle not in handles:
handles.append(handle)
tool = {"type": "x_search"}
if handles:
tool["allowed_x_handles"] = handles[:20]
payload = {
"model": MODEL,
"input": [{"role": "user", "content": prompt}],
"tools": [tool],
"reasoning": {"effort": "low"},
"max_output_tokens": max_tokens,
"stream": False,
}
endpoint = base_url + "/responses"
else:
payload = {
"model": MODEL,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": max_tokens,
}
endpoint = base_url + "/chat/completions"
req = urllib.request.Request(
base_url + "/chat/completions",
endpoint,
data=json.dumps(payload).encode("utf-8"),
headers={
"Authorization": "Bearer " + api_key,
Expand Down Expand Up @@ -297,7 +355,11 @@ def openai_chat_json(base_url, api_key, prompt, max_tokens, timeout=REQUEST_TIME
content = "".join(content_parts)
else:
data = json.loads(raw)
content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
content = (
responses_output_text(data)
if use_responses_tools
else data.get("choices", [{}])[0].get("message", {}).get("content", "")
)
return extract_json(content)


Expand Down Expand Up @@ -325,6 +387,7 @@ def call_grok_once(base_url, api_key, account_handles, latest_by_handle, timeout
prompt,
configured_max_tokens(min(3000, 1000 + 500 * len(account_handles))),
timeout=timeout,
x_handles=account_handles,
)
return parsed.get("accounts", [])

Expand Down Expand Up @@ -370,6 +433,8 @@ def hydrate_posts(base_url, api_key, new_items, timeout=DETAIL_REQUEST_TIMEOUT_S
- 如果推文或引用/回复里有图片/视频/GIF,尽量返回可打开的媒体 URL;不需要识图、OCR 或图片内容描述。
"""
try:
# Context may belong to an unmonitored reply or quote author, so this
# lookup must not inherit the initial account-fetch allowlist.
parsed = openai_chat_json(
base_url,
api_key,
Expand Down Expand Up @@ -609,7 +674,15 @@ def repair_one_context(base_url, api_key, display_name, post, post_id, handle, t
- reply_to_chinese_text / quoted_chinese_text 只填中文:外文原帖只给中文翻译,中文原帖只给中文原文;不要中英双语,不要加“翻译:”。
- 如果原推包含图片/视频/GIF,尽量返回 reply_to_media/quoted_media 的可打开 URL;不需要识图、OCR 或图片内容描述。
"""
parsed = openai_chat_json(base_url, api_key, prompt, configured_max_tokens(3000), timeout=timeout)
# The parent or quoted post can belong to a different account. Restricting
# X Search to the monitored handle would make that context unreachable.
parsed = openai_chat_json(
base_url,
api_key,
prompt,
configured_max_tokens(3000),
timeout=timeout,
)
if not isinstance(parsed, dict):
return post
merged = dict(post)
Expand Down
61 changes: 53 additions & 8 deletions app/reports/us/rating_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
def load_dashboard_env() -> None:
allowed = {
"DASHBOARD_GROK_MODEL",
"DASHBOARD_GROK_API_MODE",
"DASHBOARD_GROK_CONTEXT_LENGTH",
"DASHBOARD_GROK_BASE_URL",
"DASHBOARD_GROK_API_KEY",
Expand Down Expand Up @@ -77,6 +78,7 @@ def load_dashboard_env() -> None:
JOB_NAME = "每日美股机构买入评级汇报"
CONFIG_PATH = Path(os.environ.get("DASHBOARD_CONFIG") or str(DASHBOARD_HOME / "config.yaml")).expanduser()
US_RATING_MODEL = os.environ.get("US_RATING_MODEL") or os.environ.get("DASHBOARD_GROK_MODEL") or "grok-4.20-multi-agent-xhigh"
GROK_API_MODE = os.environ.get("DASHBOARD_GROK_API_MODE") or "auto"


def _int_env(name: str, default: int, *, min_value: int) -> int:
Expand Down Expand Up @@ -143,16 +145,55 @@ def _is_transient_error(err):
return any(s in text for s in ("timed out", "timeout", "temporarily", "connection reset", "empty stream", "ssl"))


def _responses_output_text(data: dict) -> str:
direct = str(data.get("output_text") or "").strip()
if direct:
return direct
parts: list[str] = []
for item in data.get("output") or []:
if not isinstance(item, dict):
continue
for content in item.get("content") or []:
if not isinstance(content, dict):
continue
if content.get("type") in {"output_text", "text"} and content.get("text"):
parts.append(str(content["text"]))
return "\n".join(parts).strip()


def _uses_responses_api(mode: str, model: str) -> bool:
normalized = str(mode or "auto").strip().lower().replace("-", "_")
if normalized in {"responses", "response"}:
return True
if normalized in {"chat", "chat_completions", "chat_completion"}:
return False
return str(model or "").strip().lower().startswith("grok-4.5")


def _call_api(base_url, api_key, messages, max_tokens=US_RATING_MAX_TOKENS):
body = json.dumps({
"model": US_RATING_MODEL,
"messages": messages,
"max_tokens": max_tokens,
"stream": False,
}).encode("utf-8")
use_responses_tools = _uses_responses_api(GROK_API_MODE, US_RATING_MODEL)
if use_responses_tools:
endpoint = f"{base_url}/responses"
payload = {
"model": US_RATING_MODEL,
"input": messages,
"tools": [{"type": "web_search"}],
"reasoning": {"effort": "low"},
"max_output_tokens": max_tokens,
"stream": False,
}
else:
endpoint = f"{base_url}/chat/completions"
payload = {
"model": US_RATING_MODEL,
"messages": messages,
"max_tokens": max_tokens,
"stream": False,
}
body = json.dumps(payload).encode("utf-8")

req = Request(
f"{base_url}/chat/completions",
endpoint,
data=body,
headers={
"Content-Type": "application/json",
Expand All @@ -173,7 +214,11 @@ def _call_api(base_url, api_key, messages, max_tokens=US_RATING_MAX_TOKENS):
timeout_seconds = min(max(10, US_RATING_REQUEST_TIMEOUT_SECONDS), max(10, remaining - 2))
with urlopen(req, timeout=timeout_seconds, context=_SSL_CONTEXT) as resp:
data = json.loads(resp.read().decode("utf-8", "ignore"))
content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
content = (
_responses_output_text(data)
if use_responses_tools
else data.get("choices", [{}])[0].get("message", {}).get("content", "")
)
if str(content or "").strip():
return content
last_err = RuntimeError("API returned empty content")
Expand Down
3 changes: 3 additions & 0 deletions dashboard.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ DASHBOARD_TELEGRAM_CHAT_ID=

DASHBOARD_US_FEATURES_ENABLED=0
DASHBOARD_GROK_MODEL=grok-4.20-multi-agent-xhigh
# auto: Grok 4.5 uses Responses search tools; responses/chat force either API.
DASHBOARD_GROK_API_MODE=auto
DASHBOARD_GROK_CONTEXT_LENGTH=128000
DASHBOARD_GROK_MAX_TOKENS=4096
DASHBOARD_GROK_BASE_URL=
Expand Down Expand Up @@ -109,5 +111,6 @@ A_SHARE_MODEL_SUMMARY_REQUEST_TIMEOUT_SECONDS=45
X_WATCHLIST_CONTEXT_LENGTH=128000
X_WATCHLIST_MAX_TOKENS=4096
X_WATCHLIST_DAEMON_INTERVAL_SECONDS=1200
X_WATCHLIST_REQUEST_TIMEOUT_SECONDS=45
DASHBOARD_US_RATING_CRON="0 11 * * *"
DASHBOARD_INDICES_TTL_SECONDS=60
3 changes: 2 additions & 1 deletion docs/OPERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ NiuOne 需要大模型驱动完整工作流。X 关注列表监控和美股机
| 场景 | 配置项 |
|---|---|
| 牛牛美股总开关 | `DASHBOARD_US_FEATURES_ENABLED` |
| Grok API | `DASHBOARD_GROK_BASE_URL`、`DASHBOARD_GROK_API_KEY`、`DASHBOARD_GROK_MODEL`、`DASHBOARD_GROK_CONTEXT_LENGTH` |
| Grok API | `DASHBOARD_GROK_BASE_URL`、`DASHBOARD_GROK_API_KEY`、`DASHBOARD_GROK_MODEL`、`DASHBOARD_GROK_API_MODE`、`DASHBOARD_GROK_CONTEXT_LENGTH` |
| A 股盘面模型总结单独覆盖 | `A_SHARE_MODEL_SUMMARY_BASE_URL`、`A_SHARE_MODEL_SUMMARY_API_KEY`、`A_SHARE_MODEL_SUMMARY_MODEL`、`A_SHARE_MODEL_SUMMARY_MAX_TOKENS` |
| 消息面预检 API | `DASHBOARD_NEWS_BASE_URL`、`DASHBOARD_NEWS_API_KEY`、`DASHBOARD_NEWS_MODEL`、`DASHBOARD_NEWS_MAX_TOKENS`、`DASHBOARD_NEWS_CONCURRENCY` |
| 买卖决策 API | `DASHBOARD_DECISION_BASE_URL`、`DASHBOARD_DECISION_API_KEY`、`DASHBOARD_DECISION_MODEL` |
Expand All @@ -89,6 +89,7 @@ NiuOne 需要大模型驱动完整工作流。X 关注列表监控和美股机
| X 关注列表单独覆盖 | `X_WATCHLIST_BASE_URL`、`X_WATCHLIST_API_KEY`、`X_WATCHLIST_MODEL`、`X_WATCHLIST_MAX_TOKENS` |

完成管理员认证后,优先通过页面上的设置按钮进入设置页维护。推文监控和美股评级相关设置由“开启牛牛美股”开关控制;关闭时设置页会隐藏这些项,后台 X 监控和美股评级定时任务也会跳过。也可以直接编辑 `.local-data/dashboard.env`,保存后按配置影响范围重启或等待下一轮任务读取。
`DASHBOARD_GROK_API_MODE` 可设为 `auto`、`responses` 或 `chat`。默认 `auto` 会为 Grok 4.5 使用带 `web_search`/`x_search` 工具的 Responses API,其他模型保持 Chat Completions;兼容网关可显式选择对应模式。`X_WATCHLIST_REQUEST_TIMEOUT_SECONDS` 控制 X 单账号请求超时,默认 `45` 秒。
`*_CONTEXT_LENGTH` 仅表示模型上下文窗口,默认 `128000`;`*_MAX_TOKENS` 仅用于请求体里的 `max_tokens`,默认 `4096`,可按场景覆盖。
消息面预检默认最多并发检查 5 只候选股;如果上游出现限流或 403/429,可将 `DASHBOARD_NEWS_CONCURRENCY` 降为 `2` 或 `1`。

Expand Down
Loading