+
{{ key }}
@@ -552,13 +560,11 @@ const TasksApp = {
'scheduled_optimize': { icon: 'IconSearch', name: '合规审查', defaultTime: '04:30' },
'scheduled_optimize_sources': { icon: 'IconSetting', name: '信息源优化', defaultTime: '05:00' },
'scheduled_metrics_sync': { icon: 'IconDashboard', name: '指标同步', defaultTime: '06:00' },
- 'scheduled_refresh_search_cache': { icon: 'IconRefresh', name: '搜索缓存', defaultTime: '01:00' },
'scheduled_fetch_trends': { icon: 'IconRefresh', name: '热点趋势', defaultTime: '01:10' },
'scheduled_task_monitor': { icon: 'IconRefresh', name: '任务监控', defaultTime: '*' },
};
const MODULE_TRIGGER_ENDPOINTS = {
scheduled_collect: '/api/system/collect/run',
- scheduled_refresh_search_cache: '/api/system/refresh-search-cache/run',
scheduled_fetch_trends: '/api/system/trends/run',
scheduled_generate: '/api/system/generate/run',
scheduled_optimize: '/api/system/review/run',
@@ -592,6 +598,7 @@ const TasksApp = {
sourceForm: { name: '', source_type: 'web_search', query: '', credibility: 'medium', focus: '', sort_order: 0, is_active: true },
sourcePage: 1, sourcePageSize: 10,
SCHEDULER_JOBS, MODULE_TRIGGER_ENDPOINTS,
+ llmConfigs: [], defaultLlmLabel: '',
}
},
computed: {
@@ -645,17 +652,28 @@ const TasksApp = {
async loadModules() {
this.moduleLoading = true;
try {
- const data = await this.api('/api/system/modules/status');
+ const [data, llmConfigs] = await Promise.all([
+ this.api('/api/system/modules/status'),
+ this.loadLLMConfigs(),
+ ]);
if (!data) return;
this.modules = data.modules || [];
this.schedulerRunning = data.scheduler && data.scheduler.running === true;
} catch (e) { console.error(e); }
finally { this.moduleLoading = false; }
},
+ async loadLLMConfigs() {
+ try {
+ const configs = await this.api('/api/admin/llmconfigs');
+ this.llmConfigs = configs || [];
+ const def = (configs || []).find(c => c.is_default);
+ this.defaultLlmLabel = def ? def.provider + ' (' + def.model + ')' : '';
+ } catch (e) { console.error('loadLLMConfigs error:', e); this.llmConfigs = []; }
+ },
async openModuleDetail(mod) {
this.showDrawer = true;
this.drawerTitle = mod.title + ' 详情';
- this.drawerData = { ...mod };
+ this.drawerData = { ...mod, params: { ...(mod.params || {}) } };
this.drawerError = '';
this.drawerLoading = true;
this.drawerTab = 'inputs';
@@ -667,7 +685,7 @@ const TasksApp = {
this.api('/api/admin/task-configs/history/' + mod.module_id + '?limit=20'),
this.api('/api/admin/prompt-configs?module_id=' + mod.module_id),
]);
- this.drawerData = { ...mod, ...detail };
+ this.drawerData = { ...mod, ...detail, params: { ...((detail.params || mod.params || {})) } };
this.drawerHistory = history || [];
this.drawerPrompts = prompts || [];
} catch (e) { this.drawerError = e.message; }
diff --git a/scripts/compliance_optimizer.py b/scripts/compliance_optimizer.py
index d76e068..66c6847 100644
--- a/scripts/compliance_optimizer.py
+++ b/scripts/compliance_optimizer.py
@@ -3,7 +3,7 @@
合规审查:文章合规检查 → LLM迭代修复
从articles表读取待审文章,进行合规评分;不合格文章由LLM修复(最多3次),通过后更新选题状态为待发布
"""
-import json, datetime, logging, sys, re
+import json, os, datetime, logging, sys, re
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from dataclasses import dataclass, asdict
@@ -165,7 +165,7 @@ def polish_with_llm(html: str, platform: str, remaining_issues: Optional[List[Di
"""用 LLM 优化文章内容,返回 (html, log_message_or_None)
如果指定 remaining_issues,则针对性修复合规问题
LLM 失败时自动重试一次
- 固定使用 opencode-go (deepseek-v4-flash) — 审查用更好的模型
+ LLM 提供者由 LLM_TASK_PROVIDER 环境变量决定(scheduler 从 TaskConfig 读取设置)
"""
if not HAVE_LLM:
return html, None
@@ -182,16 +182,15 @@ def polish_with_llm(html: str, platform: str, remaining_issues: Optional[List[Di
prompt = get_prompt("compliance_fix", issues_desc=issues_desc, html=html)
else:
prompt = get_prompt("compliance_polish", html=html)
- polished = call_llm(prompt, provider="sensenova", temperature=temperature, max_tokens=max_tokens, system_prompt=system_prompt)
+ polished = call_llm(prompt, temperature=temperature, max_tokens=max_tokens, system_prompt=system_prompt)
polished = clean_html_content(polished)
polished = strip_ai_preface(polished)
polished = strip_thinking_html(polished)
if '
' in polished:
if len(polished) > len(html) * 0.3 and len(polished) > 100:
- if not any(kw in polished for kw in ['保留', '建议', '可以', '应该', '推荐', '改为', '替换为']):
- tag = "针对性修复" if remaining_issues else "常规润色"
- return polished, f"LLM {tag}"
- logger.warning(f"LLM 优化输出异常(过短或含建议性文字),保留原文 (len={len(polished)})")
+ tag = "针对性修复" if remaining_issues else "常规润色"
+ return polished, f"LLM {tag}"
+ logger.warning(f"LLM 优化输出过短,保留原文 (len={len(polished)})")
except Exception as e:
logger.warning(f"LLM 优化失败 (尝试 {attempt+1}/2): {e}")
if attempt == 0:
@@ -229,7 +228,8 @@ def _load_platform_configs() -> Dict[str, Dict]:
def main(topic_ids: List[str] = None, today_only: bool = False):
logger.info("=== 合规审查与优化开始 ===")
- logger.info("LLM 配置: opencode-go (model=deepseek-v4-flash) — 固定用于合规审查")
+ llm_provider = os.getenv("LLM_TASK_PROVIDER", "sensenova")
+ logger.info(f"LLM 配置: {llm_provider} — 合规审查")
platform_configs = _load_platform_configs()
logger.info(f"已加载 {len(platform_configs)} 个平台配置")
diff --git a/scripts/mcp_search_server.py b/scripts/mcp_search_server.py
deleted file mode 100644
index 024ed91..0000000
--- a/scripts/mcp_search_server.py
+++ /dev/null
@@ -1,300 +0,0 @@
-#!/usr/bin/env python3
-"""
-MCP Search Server — provides web search via opencode infrastructure.
-
-Two search methods (automatic fallback):
- 1. npx opencode run (rate-limited but returns real web results)
- 2. opencode-go API + model training data (no rate limit, less fresh)
-
-Usage:
- python3 scripts/mcp_search_server.py # MCP server (stdio)
- python3 scripts/mcp_search_server.py --query Q # one-shot search
- python3 scripts/mcp_search_server.py --url U # one-shot webfetch
-"""
-import json, os, subprocess, sys, time
-from pathlib import Path
-from typing import Any, Dict, List, Optional
-
-PROJECT_ROOT = Path(__file__).resolve().parent.parent
-CACHE_FILE = PROJECT_ROOT / "automation" / "data" / "mcp_search_cache.json"
-SESSION_FILE = PROJECT_ROOT / "automation" / "data" / "mcp_session.txt"
-CACHE_TTL = 3600
-SESSION_TITLE = "opencode搜索"
-
-API_BASE = "https://opencode.ai/zen/go/v1"
-API_KEY = os.environ.get("OPENCODE_API_KEY", "")
-if not API_KEY:
- try:
- from dotenv import load_dotenv
- env_path = PROJECT_ROOT / "platform" / "backend" / ".env"
- load_dotenv(env_path)
- API_KEY = os.environ.get("OPENCODE_API_KEY", "")
- except Exception:
- pass
-
-
-# ── session (reuse same session for all MCP searches) ─────────────
-def _load_session() -> Optional[str]:
- if SESSION_FILE.exists():
- try:
- return SESSION_FILE.read_text().strip() or None
- except Exception:
- pass
- return None
-
-def _save_session_from_output(stdout: str):
- for line in stdout.strip().split("\n"):
- try:
- ev = json.loads(line)
- sid = ev.get("sessionID") or ev.get("part", {}).get("sessionID")
- if sid:
- SESSION_FILE.parent.mkdir(parents=True, exist_ok=True)
- SESSION_FILE.write_text(sid)
- return
- except Exception:
- pass
-
-
-# ── cache ─────────────────────────────────────────────────────────
-def _check_cache(query: str) -> Optional[List[Dict]]:
- if not CACHE_FILE.exists():
- return None
- try:
- data = json.loads(CACHE_FILE.read_text())
- entry = data.get(query)
- if entry and time.time() - entry.get("ts", 0) < CACHE_TTL:
- return entry.get("results")
- except Exception:
- pass
- return None
-
-def _write_cache(query: str, results: List[Dict]):
- CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
- data = {}
- if CACHE_FILE.exists():
- try:
- data = json.loads(CACHE_FILE.read_text())
- except Exception:
- pass
- data[query] = {"ts": time.time(), "results": results}
- keys = sorted(data.keys(), key=lambda k: data[k].get("ts", 0), reverse=True)[:200]
- CACHE_FILE.write_text(json.dumps({k: data[k] for k in keys}, ensure_ascii=False))
-
-
-# ── method 1: npx opencode run ────────────────────────────────────
-def _search_via_opencode_cli(query: str, max_results: int) -> Optional[List[Dict]]:
- """Use npx opencode run to execute websearch tool (short timeout)."""
- sid = _load_session()
- args = ["npx", "opencode", "run", f"websearch {query}", "--format", "json", "--title", SESSION_TITLE]
- if sid:
- args.extend(["--session", sid, "--continue"])
- try:
- r = subprocess.run(
- args, capture_output=True, text=True, timeout=15,
- env={**os.environ, "OPENCODE_DISABLE_AUTOUPDATE": "1"}
- )
- except subprocess.TimeoutExpired:
- return None
- except Exception:
- return None
- if r.returncode != 0:
- return None
- # Save session ID for reuse
- _save_session_from_output(r.stdout)
- for line in r.stdout.strip().split("\n"):
- try:
- ev = json.loads(line)
- if ev.get("type") == "tool_use":
- part = ev.get("part", {})
- state = part.get("state", {})
- if part.get("tool") == "websearch" and state.get("status") == "completed":
- data = json.loads(state["output"])
- results = []
- for item in (data.get("results") or [])[:max_results]:
- url = (item.get("url") or "").strip()
- title = (item.get("title") or "").strip()
- excerpts = item.get("excerpts") or []
- content = (excerpts[0] if excerpts else "")[:500]
- if url and title:
- results.append({"title": title, "url": url, "content": content, "source": "opencode_cli"})
- return results
- except Exception:
- pass
- return None
-
-
-# ── method 2: opencode-go API + training data ─────────────────────
-def _search_via_api(query: str, max_results: int) -> Optional[List[Dict]]:
- """Use opencode-go API to answer query from training data (no rate limit)."""
- if not API_KEY:
- return None
- import requests
- try:
- resp = requests.post(
- f"{API_BASE}/chat/completions",
- headers={"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"},
- json={
- "model": "deepseek-v4-flash",
- "messages": [{"role": "user", "content": (
- f"你现在是一个网络搜索工具。用户查询: {query[:100]}\n\n"
- f"请根据你的训练数据,提供{max_results}条最相关的网页结果,包含标题、URL和摘要。"
- f"以JSON格式输出: [{{\"title\":\"...\",\"url\":\"...\",\"content\":\"...\"}}]"
- f"仅输出JSON数组,不要其他文字。如果URL不确定,用合理占位。"
- )}],
- "temperature": 0.3,
- "max_tokens": 2000,
- },
- timeout=30
- )
- data = resp.json()
- content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
- # Extract JSON array
- import re as _re
- m = _re.search(r'\[.*?\]', content, _re.DOTALL)
- if m:
- items = json.loads(m.group())
- if isinstance(items, list):
- for item in items:
- item["source"] = "opencode_api"
- return items[:max_results]
- except Exception:
- pass
- return None
-
-
-# ── search ─────────────────────────────────────────────────────────
-def web_search(query: str, max_results: int = 8) -> List[Dict]:
- max_results = min(max_results, 10)
- cached = _check_cache(query)
- if cached:
- return cached[:max_results]
-
- results = _search_via_opencode_cli(query, max_results)
- if results:
- _write_cache(query, results)
- return results
-
- results = _search_via_api(query, max_results)
- if results:
- _write_cache(query, results)
- return results
-
- return []
-
-
-def webfetch(url: str) -> Optional[str]:
- sid = _load_session()
- args = ["npx", "opencode", "run", f"webfetch {url}", "--format", "json", "--title", SESSION_TITLE]
- if sid:
- args.extend(["--session", sid, "--continue"])
- try:
- r = subprocess.run(
- args, capture_output=True, text=True, timeout=60,
- env={**os.environ, "OPENCODE_DISABLE_AUTOUPDATE": "1"}
- )
- if r.returncode == 0:
- _save_session_from_output(r.stdout)
- for line in r.stdout.strip().split("\n"):
- try:
- ev = json.loads(line)
- if ev.get("type") == "tool_use":
- p = ev.get("part", {})
- s = p.get("state", {})
- if p.get("tool") == "webfetch" and s.get("status") == "completed":
- return s.get("output", "")[:10000]
- except Exception:
- pass
- except Exception:
- pass
- return None
-
-
-# ── MCP protocol (JSON-RPC 2.0 over stdio) ────────────────────────
-def _read_msg() -> Optional[Dict]:
- line = sys.stdin.readline()
- if not line:
- return None
- try:
- return json.loads(line)
- except json.JSONDecodeError:
- return None
-
-def _send_msg(msg: Dict):
- sys.stdout.write(json.dumps(msg, ensure_ascii=False) + "\n")
- sys.stdout.flush()
-
-def _send_error(req_id: Any, code: int, message: str):
- _send_msg({"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}})
-
-def _send_result(req_id: Any, result: Any):
- _send_msg({"jsonrpc": "2.0", "id": req_id, "result": result})
-
-
-def serve():
- sys.stdin.reconfigure(encoding="utf-8")
- sys.stdout.reconfigure(encoding="utf-8")
- while True:
- msg = _read_msg()
- if msg is None:
- break
- req_id = msg.get("id")
- method = msg.get("method", "")
- params = msg.get("params", {})
- if method == "initialize":
- _send_result(req_id, {
- "protocolVersion": "2024-11-05",
- "capabilities": {"tools": {"listChanged": False}},
- "serverInfo": {"name": "opencode-search-mcp", "version": "1.0.0"}
- })
- elif method == "notifications/initialized":
- pass
- elif method == "tools/list":
- _send_result(req_id, {"tools": [
- {"name": "web_search", "description": "Search the web. Returns up to 10 results with title, url, content.", "inputSchema": {
- "type": "object", "properties": {
- "query": {"type": "string", "description": "Search query"},
- "max_results": {"type": "number", "description": "Max results (1-10)", "default": 8}
- }, "required": ["query"]
- }},
- {"name": "webfetch", "description": "Fetch and extract content from a URL.", "inputSchema": {
- "type": "object", "properties": {"url": {"type": "string", "description": "URL to fetch"}},
- "required": ["url"]
- }}
- ]})
- elif method == "tools/call":
- name = params.get("name", "")
- args = params.get("arguments", {})
- try:
- if name == "web_search":
- results = web_search(args.get("query", ""), int(args.get("max_results", 8)))
- _send_result(req_id, {"content": [{"type": "text", "text": json.dumps(results, ensure_ascii=False)}]})
- elif name == "webfetch":
- content = webfetch(args.get("url", ""))
- _send_result(req_id, {"content": [{"type": "text", "text": content or "Failed to fetch URL"}]})
- else:
- _send_error(req_id, -32601, f"Unknown tool: {name}")
- except Exception as e:
- _send_error(req_id, -32603, str(e))
- elif method == "shutdown":
- _send_result(req_id, {})
- break
- else:
- _send_error(req_id, -32601, f"Unknown method: {method}")
-
-
-def main():
- if "--query" in sys.argv:
- idx = sys.argv.index("--query")
- q = sys.argv[idx + 1] if idx + 1 < len(sys.argv) else ""
- print(json.dumps(web_search(q), ensure_ascii=False, indent=2))
- return
- if "--url" in sys.argv:
- idx = sys.argv.index("--url")
- u = sys.argv[idx + 1] if idx + 1 < len(sys.argv) else ""
- print(webfetch(u) or "Failed")
- return
- serve()
-
-
-if __name__ == "__main__":
- main()
diff --git a/scripts/opencode_search.py b/scripts/opencode_search.py
deleted file mode 100644
index aa43c86..0000000
--- a/scripts/opencode_search.py
+++ /dev/null
@@ -1,196 +0,0 @@
-#!/usr/bin/env python3
-"""
-通过 opencode CLI 执行联网搜索
-利用 opencode 的 webfetch 能力(当前 AI 环境可无障碍访问互联网)
-
-用法:
- python3 scripts/opencode_search.py --query "可持续生活 趋势 2026"
- python3 scripts/opencode_search.py --refresh-cache # 刷新所有分类的缓存
-"""
-import argparse, datetime, json, logging, os, re, subprocess, sys, time
-from pathlib import Path
-from typing import Dict, List, Optional
-
-PROJECT_ROOT = Path(__file__).parent.parent
-sys.path.insert(0, str(PROJECT_ROOT))
-
-LOGS_DIR = PROJECT_ROOT / "automation" / "logs"
-TODAY = datetime.datetime.now().strftime("%Y-%m-%d")
-LOG_FILE = LOGS_DIR / f"opencode_search_{TODAY}.log"
-
-logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
- handlers=[logging.FileHandler(LOG_FILE, encoding='utf-8'), logging.StreamHandler()])
-logger = logging.getLogger(__name__)
-
-SEARCH_CACHE_FILE = PROJECT_ROOT / "automation" / "data" / "search_cache.json"
-SESSION_FILE = PROJECT_ROOT / "automation" / "data" / "opencode_session.txt"
-
-
-def _get_or_create_session() -> Optional[str]:
- """获取或创建持久 session ID"""
- if SESSION_FILE.exists():
- try:
- sid = SESSION_FILE.read_text().strip()
- if sid:
- result = subprocess.run(
- ["npx", "opencode", "run", "ping", "--session", sid, "--format", "json"],
- capture_output=True, text=True, timeout=10,
- cwd=str(PROJECT_ROOT),
- env={**os.environ, "OPENCODE_DISABLE_AUTOUPDATE": "1"}
- )
- if result.returncode == 0:
- return sid
- except Exception:
- pass
- result = subprocess.run(
- ["npx", "opencode", "run", "init", "--format", "json"],
- capture_output=True, text=True, timeout=30,
- cwd=str(PROJECT_ROOT),
- env={**os.environ, "OPENCODE_DISABLE_AUTOUPDATE": "1"}
- )
- for line in result.stdout.strip().split("\n"):
- try:
- event = json.loads(line)
- sid = event.get("sessionID") or event.get("part", {}).get("sessionID")
- if sid:
- SESSION_FILE.write_text(sid)
- return sid
- except Exception:
- pass
- return None
-
-
-_session_id = None
-
-
-def _run_opencode(prompt: str, timeout: int = 60) -> Optional[str]:
- """调用 opencode run 执行任务,返回文本输出"""
- global _session_id
- if _session_id is None:
- _session_id = _get_or_create_session()
- args = ["npx", "opencode", "run", prompt, "--format", "json"]
- if _session_id:
- args.extend(["--session", _session_id, "--continue"])
- try:
- result = subprocess.run(
- args,
- capture_output=True, text=True, timeout=timeout,
- cwd=str(PROJECT_ROOT),
- env={**os.environ, "OPENCODE_DISABLE_AUTOUPDATE": "1"}
- )
- if result.returncode != 0:
- logger.warning(f"opencode run 返回非零: {result.stderr[:200]}")
- return None
- for line in result.stdout.strip().split("\n"):
- try:
- event = json.loads(line)
- if event.get("type") == "error":
- logger.warning(f"opencode 错误: {event}")
- return None
- except json.JSONDecodeError:
- pass
- lines = []
- for line in result.stdout.strip().split("\n"):
- try:
- event = json.loads(line)
- if event.get("type") == "text":
- text = event.get("part", {}).get("text", "")
- if text:
- lines.append(text)
- except json.JSONDecodeError:
- pass
- output = "\n".join(lines).strip()
- return output if output else None
- except subprocess.TimeoutExpired:
- logger.warning(f"opencode run 超时 ({timeout}s)")
- return None
- except Exception as e:
- logger.warning(f"opencode run 失败: {e}")
- return None
-
-
-def search_via_opencode(query: str, max_results: int = 5) -> List[Dict]:
- """通过 MCP 搜索工具联网搜索(替代脆弱的 npx prompt 方式)"""
- try:
- from search_utils import search
- return search(query, max_results)
- except Exception as e:
- logger.warning("search_utils 不可用,回退子进程: %s", e)
- result = subprocess.run(
- [sys.executable, str(PROJECT_ROOT / "scripts" / "mcp_search_server.py"),
- "--query", query],
- capture_output=True, text=True, timeout=90,
- )
- if result.returncode == 0:
- try:
- return json.loads(result.stdout)[:max_results]
- except Exception:
- pass
- return []
-
-
-def refresh_cache():
- """刷新所有搜索分类的缓存"""
- try:
- with open(PROJECT_ROOT / "config" / "sources.yaml") as f:
- import yaml
- cfg = yaml.safe_load(f)
- queries = [s["query"] for s in cfg["sustainability_sources"]["web_search"]]
- except Exception:
- logger.warning("无法读取 sources.yaml,使用默认查询")
- queries = [
- "以旧换新 二手交易 循环 2026",
- "新能源车 绿色通勤 低碳 2026",
- "干净饮食 有机食品 2026",
- "零浪费 极简生活 可持续时尚 2026",
- "绿色家电 一级能效 节能 2026",
- "碳账户 碳普惠 个人碳减排 2026",
- "环保科技 绿色产品 可持续材料 2026",
- "AI工具 人工智能 效率提升 2026",
- ]
-
- cache = {"_metadata": {"updated_at": datetime.datetime.now().isoformat()}}
- if SEARCH_CACHE_FILE.exists():
- try:
- old = json.loads(SEARCH_CACHE_FILE.read_text(encoding="utf-8"))
- for k, v in old.items():
- if not k.startswith("_"):
- cache.setdefault(k, v)
- except Exception:
- pass
-
- for i, q in enumerate(queries):
- logger.info(f"[{i+1}/{len(queries)}] 搜索: {q}")
- results = search_via_opencode(q, max_results=4)
- if results:
- cache[q] = results
- else:
- logger.warning(f" {q} 搜索无结果,保留旧缓存")
- time.sleep(2)
-
- SEARCH_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
- SEARCH_CACHE_FILE.write_text(json.dumps(cache, ensure_ascii=False, indent=2), encoding="utf-8")
- logger.info(f"缓存已刷新: {sum(len(v) for v in cache.values())} 条")
-
-
-def main():
- parser = argparse.ArgumentParser(description="通过 opencode 联网搜索")
- parser.add_argument("--query", help="搜索词")
- parser.add_argument("--refresh-cache", action="store_true", help="刷新所有分类缓存")
- parser.add_argument("--max-results", type=int, default=5)
- args = parser.parse_args()
-
- if args.refresh_cache:
- refresh_cache()
- return
-
- if args.query:
- results = search_via_opencode(args.query, args.max_results)
- print(json.dumps(results, ensure_ascii=False, indent=2))
- return
-
- parser.print_help()
-
-
-if __name__ == "__main__":
- main()
diff --git a/scripts/search_utils.py b/scripts/search_utils.py
index 5b2b389..130a0c0 100644
--- a/scripts/search_utils.py
+++ b/scripts/search_utils.py
@@ -179,32 +179,6 @@ def _call_bing(api_key: str, api_url: str, query: str, max_results: int) -> List
} for r in items[:max_results]]
-def _call_mcp(api_key: str, api_url: str, query: str, max_results: int) -> List[Dict]:
- """Call the MCP search server directly (no API key needed)."""
- import subprocess
- try:
- r = subprocess.run(
- [sys.executable, str(PROJECT_ROOT / "scripts" / "mcp_search_server.py"),
- "--query", query],
- capture_output=True, text=True, timeout=90,
- )
- if r.returncode != 0:
- logger.warning("MCP搜索子进程返回非零: %s", r.stderr[:100])
- return []
- results = json.loads(r.stdout)
- if isinstance(results, list):
- for res in results:
- res["source"] = "opencode"
- return results[:max_results]
- except json.JSONDecodeError as e:
- logger.warning("MCP搜索JSON解析失败: %s", e)
- except subprocess.TimeoutExpired:
- logger.warning("MCP搜索超时 (90s)")
- except Exception as e:
- logger.warning("MCP搜索失败: %s", e)
- return []
-
-
def _call_360(api_key: str, api_url: str, query: str, max_results: int) -> List[Dict]:
"""360搜索(HTML爬取,无需 API Key)"""
from bs4 import BeautifulSoup
@@ -311,7 +285,6 @@ _PROVIDER_CALLS = {
"qiniu": _call_qiniu,
"tinyfish": _call_tinyfish,
"bing": _call_bing,
- "mcp": _call_mcp,
"360": _call_360,
"sogou": _call_sogou,
"wechat": _call_wechat,
@@ -325,7 +298,7 @@ def search(query: str, max_results: int = 5) -> List[Dict]:
if (p.get("usage_today") or 0) >= (p.get("daily_limit") or 99999):
logger.info("提供商 %s 已达日限 %s,跳过", p.get("name"), p.get("daily_limit"))
continue
- no_key_types = {"mcp", "360", "sogou", "wechat"}
+ no_key_types = {"360", "sogou", "wechat"}
if not p.get("api_key") and p.get("provider_type") not in no_key_types:
logger.info("提供商 %s 未配置 API Key,跳过", p.get("name"))
continue
diff --git a/scripts/web_search.py b/scripts/web_search.py
index 1ed8df1..7378cc4 100644
--- a/scripts/web_search.py
+++ b/scripts/web_search.py
@@ -3,7 +3,7 @@
网络搜索模块
三种模式(优先级从高到低):
-1. 本地缓存(opencode webfetch 预填充)
+1. 本地缓存(search_cache.json)
2. Bing Web Search API(设 BING_API_KEY)
3. Bing 网页抓取(服务器环境常反爬拦截)
"""
@@ -92,7 +92,7 @@ def search_scrape(query: str, max_results: int = 5) -> List[Dict]:
def search_from_cache(query: str, max_results: int = 5) -> List[Dict]:
- """从 opencode webfetch 预填充的缓存中读取(跳过超过36小时的缓存)"""
+ """从本地搜索缓存中读取(跳过超过36小时的缓存)"""
if not SEARCH_CACHE_FILE.exists():
return []
try:
@@ -114,7 +114,7 @@ def search_from_cache(query: str, max_results: int = 5) -> List[Dict]:
def save_to_cache(query: str, results: List[Dict]):
- """保存搜索结果到缓存(供 opencode webfetch 填充时使用)"""
+ """保存搜索结果到缓存(供填充时使用)"""
cache = {}
if SEARCH_CACHE_FILE.exists():
try:
diff --git a/tests/test_new_features.py b/tests/test_new_features.py
index 4fab3b8..5e6f142 100644
--- a/tests/test_new_features.py
+++ b/tests/test_new_features.py
@@ -116,12 +116,12 @@ test("DELETE 删除源", r.status_code == 200)
print("\n=== 7. LLM多供应商 ===")
sys.path.insert(0, str(root / "platform" / "backend"))
from app.core.nvidia_client import _get_active_provider, _get_provider_config
-test("默认供应商", _get_active_provider() == "opencode-go")
+test("默认供应商存在", _get_active_provider() in ["opencode-go", "nvidia", "sensenova"])
cfg = _get_provider_config("opencode-go")
test("opencode-go已配置", cfg is not None)
cfg_nv = _get_provider_config("nvidia")
test("nvidia备用存在", cfg_nv is not None)
-test("opencode-go模型", cfg and cfg.get("model") == "deepseek-v4-flash")
+test("opencode-go模型非空", bool(cfg and cfg.get("model")))
test("opencode-go URL非空", bool(cfg and cfg.get("base_url")))
test("opencode-go Key非空", bool(cfg and cfg.get("api_key")))
diff --git a/可持续生活方式_公众号文章_2026.md b/可持续生活方式_公众号文章_2026.md
new file mode 100644
index 0000000..6012509
--- /dev/null
+++ b/可持续生活方式_公众号文章_2026.md
@@ -0,0 +1,175 @@
+# 2026年,中国人正在重新定义"好生活"
+
+---
+
+## 一场静悄悄的生活方式革命
+
+你发现了吗?身边越来越多的朋友开始自带杯买咖啡、在阳台种番茄、把旧衣服挂上闲鱼、换掉用了十年的老空调……
+
+这不是零散的个人选择,而是一场正在中国发生的、系统性的生活方式变革。
+
+我们梳理了2026年可持续生活方式的7个关键领域——每一个背后都有数据、有政策、有实实在在的"真金白银"。
+
+---
+
+## 一、你的每一次出行,都在"赚钱"
+
+2026年五一,新能源汽车占出行车辆比例达到 **24%**——每四辆车里就有一辆是绿牌。
+
+这不是偶然。全国日均已有 **2 亿人次**选择绿色出行,试点城市目标将绿色出行比例推至 **70% 以上**。新能源公交占比已达 **82.7%**。
+
+更重要的是真金白银的激励:
+
+- **报废换新**:新能源车补贴车价 **12%**(最高 2 万元),燃油车 10%(最高 1.5 万元)
+- **置换更新**:新能源车补贴 **8%**(最高 1.5 万),燃油车 6%(最高 1.3 万)
+
+> 骑行 1 公里,减碳约 0.24kg——积少成多,你的每一次踩踏都值得被记录。碳普惠平台正在把这种"小行为"变成"大价值"(见下文)。
+
+---
+
+## 二、阳台种菜:算不过来的账,却停不下来的热爱
+
+阳台种菜市场已达**百亿级**。
+
+但有意思的是——成本根本算不过来。种子、土、肥料、花盆、工具,一年投入 500-2000 元,种出来的菜市价可能不到 100 元。年省 3000 元?"行不通"。
+
+那为什么还有这么多人乐此不疲?
+
+答案是**情绪价值 + 食品安全焦虑**。看着一粒种子发芽、长大、结果的过程,本身就是城市人稀缺的"慢体验"。而亲手种出的菜,吃得放心。
+
+都市农业的象征意义远大于经济意义——它是一扇窗,让人在钢筋水泥中重新连接自然。
+
+---
+
+## 三、碳普惠:你的低碳行为,正在变成"钱"
+
+这是2026年最值得关注的制度创新之一。
+
+**碳普惠**(Carbon Inclusion)将个人的低碳行为——骑行、自带杯、地铁通勤——量化积分为碳积分,然后积分可以:
+
+1. **商城兑换**实物/优惠券/话费
+2. **进入碳交易市场**(广东 PHCER、山西等已打通)
+3. **银行信贷优惠**——碳积分越高,贷款利率越低
+4. **企业认购**——企业购买你的减排量用于碳中和
+5. **个人碳账本**——记录+社交+激励,积累到一定量可交易
+
+上海崇明已完成首笔碳普惠减排量交易,广东 PHCER 已进入区域碳市场。
+
+**瓶颈仍然存在**:仅少数省市打通了碳市场变现通道,多数地区仍以积分兑换为主。但这个方向已经明确——你的低碳行为,正在从"道德选择"变为"经济理性"。
+
+---
+
+## 四、以旧换新 + 闲置经济:625 亿的真金白银
+
+2026年,国家第一批 **625 亿元**超长期特别国债已下达用于以旧换新。
+
+关键规则变化:
+
+| 品类 | 补贴 | 上限 |
+|------|------|------|
+| 6 类家电(冰箱/洗衣机/电视/空调/热水器/电脑)**仅限 1 级能效** | 售价 15% | 1,500 元/件 |
+| 4 类数码(手机/平板/智能手表/智能眼镜) | 售价 15% | 500 元/件 |
+
+智能眼镜首次纳入国补——2026 年的"新物种"值得关注。
+
+与此同时,**二手市场规模**正在爆发式增长:
+- 2024 年:**1.69 万亿元**
+- 2026 年预计:**3.1 万亿元**
+- 用户规模:**6.6 亿人**,Z 世代是主力
+
+闲鱼、红布林、多抓鱼——"买二手"正在从"省钱"变成"一种生活方式标签"。
+
+---
+
+## 五、干净饮食:从"吃饱"到"吃对"
+
+### 植物基:中国市场增速全球第一
+
+全球植物基食品市场 2024 年 173.7 亿美元,预计 2035 年达 1702.8 亿美元(**CAGR 23.06%**)。
+
+中国市场的增速更为惊人——**2025 年增速 48.6%**,全球最快。
+
+品类结构:植物肉占 41%,但植物基乳制品增速最快(26.7%)。
+
+关键趋势:**弹性素食(Flexitarian)**崛起——不是完全不吃肉,而是有意识地减少。这正在成为主流。
+
+### 有机食品:从"小众"到"大众"
+
+2025 年全球有机食品市场 1693.37 亿美元,预计 2032 年达 2610.79 亿美元(CAGR 6.38%)。
+
+更值得关注的是消费群体的变化:
+- **Z 世代** 73% 过去 12 个月内购买过植物基产品,关注"清洁标签"和蛋白质含量
+- **中老年群体**关注饱和脂肪酸和膳食纤维
+- **渠道变革**:传统零售从 61%→44%,社区生鲜/便利店/会员店升至 35%
+
+"本真植选"成为 2026 年的关键词——少加工、更天然、更透明。
+
+---
+
+## 六、零浪费:一杯咖啡引发的连锁反应
+
+### 自带杯:从"小众"到"8.7%"
+
+2025 年全国现制饮品出杯量 **500 亿杯以上**——这是什么概念?每个中国人平均一年喝 35 杯。
+
+自带杯订单占比已提升至 **8.7%**。瑞幸、Manner、星巴克自带杯立减 2-5 元。"益杯行动"目标 2026 年前万家门店响应。
+
+上海/深圳试点自带杯积分纳入个人碳账户(每次减碳 50-80g CO₂)。
+
+更直观的趋势:天猫自带杯销量**年增 120%**——不锈钢/硅胶折叠杯是主流。
+
+### 极简生活:从"断舍离"到"清醒的极简"
+
+- "断舍离"小红书年搜索量同比增 **47%**
+- 趋势转向:"少买精买、长久使用"
+- **胶囊衣橱**(30 件过一季)、**一物一件**成为新关键词
+- 2026 年新增:**数字极简**——从物品断舍离延伸至数字生活整理
+
+### 可持续时尚:1280 亿的市场
+
+2026 年二手服装市场规模预计 **1280 亿元**(同比 +35%)。
+
+优衣库 RE.UNIQLO、H&M、Patagonia 的可持续计划渗透率在一二线城市达 22%。
+
+但挑战也在增加:"漂绿"指控越来越多,消费者信任度正在下降——品牌需要更透明的行动,而非口号。
+
+---
+
+## 七、绿色家电:AI 让节能不再是"牺牲"
+
+2026 年国补政策的核心变化:
+
+- 品类从 12 类**缩至 6 类**(灶具/烟机/净水器/洗碗机等退出)
+- **仅限 1 级能效**——2 级不再享受
+- 补贴比例 20%→15%,上限 2000 元→1500 元
+
+但更值得关注的是技术突破:
+
+- 新 1 级能效空调 vs 10 年老空调:降温 30→26℃ 耗电 **1 度 vs 5 度**
+- 换新空调一年全国可节省电费约 **67 亿元**
+- 格力 **AI 动态节能**空调全年能效提升 15.8%
+- TCL 新风空调至高省电 40%
+
+节能不再意味着"忍受"——AI 正在让高效和舒适同时实现。
+
+回收体系也在完善:21 亿台家电保有量,规范处置可减碳 1100-3000 万吨。"送新收旧"一站式服务和"互联网+回收"正在普及。
+
+---
+
+## 结语:好生活,正在被重新定义
+
+2026 年的中国,一个有意思的现象正在发生——
+
+**政策**(以旧换新、碳普惠)、**市场**(二手经济、植物基)、**技术**(AI 节能、绿色制造)和**个人选择**(自带杯、极简、阳台种菜)四个力量正在汇合。
+
+它们指向同一个方向:
+
+> 好生活,不是拥有更多,而是用得更好、活得更清醒、和自然相处得更聪明。
+
+这不是苦行僧式的"牺牲",而是有数据、有政策、有市场支撑的 **"理性愉悦"**。
+
+你已经在路上了吗?
+
+---
+
+*数据来源:公开政策文件、行业研究报告、Websearch 检索 | 整理时间:2026 年 6 月*
diff --git a/可持续生活方式研究摘要_2026.md b/可持续生活方式研究摘要_2026.md
new file mode 100644
index 0000000..699b1b9
--- /dev/null
+++ b/可持续生活方式研究摘要_2026.md
@@ -0,0 +1,139 @@
+# 可持续生活方式研究摘要(2026)
+
+> 基于 websearch 检索结果整理,涵盖 7 个主题。
+
+---
+
+## 1. 绿色通勤 / 新能源车 / 骑行(2026)
+
+### 新能源车
+- 五一期间新能源汽车占出行车辆比例达 24%
+- 试点城市绿色出行比例目标 70%+,全国日均 2 亿人次绿色出行
+- 新能源公交占比 82.7%
+- 骑行 1km 约减碳 0.24kg
+
+### 政策
+- 2026 年汽车报废更新:新能源车补贴车价 12%(最高 2 万元),燃油车 10%(最高 1.5 万元)
+- 汽车置换更新:新能源车补贴 8%(最高 1.5 万),燃油车 6%(最高 1.3 万)
+
+---
+
+## 2. 阳台种菜 / 都市农业
+
+- 阳台种菜市场已达百亿级
+- 账面算不过账:年省 3000 元"行不通"(种子/土/肥/工具成本倒挂)
+- 核心驱动力是**情绪价值 + 食品安全焦虑**,而非省钱
+- 都市农业作为可持续生活方式的象征意义远大于经济意义
+
+---
+
+## 3. 碳普惠(Carbon Inclusion)
+
+### 机制
+- 个人的低碳行为(骑行、自带杯、地铁通勤等)量化积分为碳积分
+- 积分可兑换商品/优惠券/碳信用
+
+### 5 条变现路径
+1. **碳积分商城兑换**(实物/优惠券/话费)
+2. **进入碳交易市场**——广东 PHCER、山西等已打通
+3. **碳账户银行信贷**——银行根据碳积分给予利率优惠
+4. **企业认购**——企业购买碳普惠减排量用于碳中和
+5. **个人碳账本**——记录+社交+激励,积累到一定量可交易
+
+### 最新进展
+- 上海崇明完成首笔碳普惠减排量交易
+- 广东 PHCER 已进入区域碳市场
+- 主要平台:支付宝"蚂蚁森林"、各地碳普惠平台
+- 瓶颈:仅少数省市打通碳市场变现,多数仍以积分兑换为主
+
+---
+
+## 4. 以旧换新 / 闲置经济(2026)
+
+### 国家补贴
+- 2026 年第一批 625 亿元超长期特别国债已下达
+- 2025 年全年以旧换新惠及 3.6 亿人次,带动消费 2.6 万亿元
+- 6 类家电(冰箱/洗衣机/电视/空调/热水器/电脑)仅限 **1 级能效**,售价 15% 补贴,上限 1500 元/件
+- 4 类数码产品(手机/平板/智能手表/智能眼镜)15% 补贴,上限 500 元/件
+- 智能眼镜首次纳入国补
+
+### 二手市场
+- 2024 年二手交易市场规模 1.69 万亿元,预计 2026 年达 3.1 万亿元
+- 用户规模 6.6 亿人,Z 世代为二手交易主力
+- 闲鱼/红布林/多抓鱼为主要平台
+
+---
+
+## 5. 干净饮食 / 有机 / 植物基 / 本地食材
+
+### 植物基食品(2026)
+- 全球市场 2024 年 173.7 亿美元,预计 2035 年达 1702.8 亿美元(CAGR 23.06%)
+- 中国市场 2025 年增速 48.6%(全球最快)
+- 品类结构:植物肉占 41%,植物基乳制品增速最快(26.7%)
+- 驱动因素:健康 + 环保 + 可持续
+- 弹性素食(Flexitarian)崛起——非完全素食,而是减少肉类摄入
+
+### 有机食品
+- 2025 年全球有机食品市场 1693.37 亿美元,预计 2032 年达 2610.79 亿美元(CAGR 6.38%)
+- Z 世代 73% 过去 12 个月内购买过植物基产品
+- 消费动机:年轻群体关注"清洁标签"和蛋白质含量;中老年关注饱和脂肪酸和膳食纤维
+- 渠道变革:传统零售从 61%→44%,社区生鲜/便利店/会员店升至 35%
+- 品牌趋势:头部品牌重品牌信任,中小品牌靠区域资源差异化
+
+### 本地食材
+- "食本地鲜"运动兴起,缩短食物里程
+- 城市农场/社区支持农业(CSA)模式持续增长
+
+---
+
+## 6. 零浪费 / 自带杯 / 极简生活 / 可持续时尚
+
+### 自带杯
+- 2025 年全国现制饮品出杯量 500 亿杯以上,一次性杯具消耗巨大
+- 瑞幸/Manner/星巴克自带杯立减 2-5 元
+- "益杯行动"目标 2026 年前万家门店响应
+- 美团推"自带杯立减"首年预计万家门店参与
+- 自带杯订单占比提升至 8.7%
+- 上海/深圳试点自带杯积分纳入个人碳账户(每次减碳 50-80g CO₂)
+- 天猫自带杯销量年增 120%(不锈钢/硅胶折叠杯为主流)
+
+### 极简生活
+- "断舍离"小红书年搜索量同比增 47%
+- 趋势转向:"少买精买、长久使用"——胶囊衣橱(30 件过一季)、一物一件
+- B 站相关视频播放量年增 45%,豆瓣小组 85 万人
+- 2026 年关键词:数字极简(从物品断舍离延伸至数字生活整理)
+- 零浪费 5R 原则(Refuse/Reduce/Reuse/Recycle/Rot)普及
+
+### 可持续时尚
+- 2026 年二手服装市场规模预计 1280 亿元(同比 +35%)
+- 品牌计划渗透率:优衣库 RE.UNIQLO、H&M、Patagonia(一二线 22%)
+- 再生面料(rPET/天丝/麻纤维)在小众和快时尚品牌中普及
+- Z 世代 44% 购买过二手或可持续面料服装;68% 愿为"环保认证"支付溢价
+- "漂绿"指控增加,消费者信任度下降
+
+---
+
+## 7. 绿色家电 / 一级能效 / 以旧换新(2026)
+
+### 国补政策核心变化
+- 品类从 12 类缩至 6 类(灶具/烟机/净水器/洗碗机等退出)
+- **仅限 1 级能效或水效**——2 级不再享受
+- 补贴比例 20%→15%,上限 2000 元→1500 元
+- 首批资金 625 亿元已下达
+- 数码新增智能眼镜品类
+
+### 节能数据
+- 新 1 级能效空调 vs 10 年老空调:降温 30→26℃ 耗电 1 度 vs 5 度
+- 换新空调一年全国可节省电费约 67 亿元(英国恩伯数据)
+- 海尔冰箱 90%+ 为 1 级能效产品
+- 格力 AI 动态节能空调全年能效提升 15.8%,降低耗电 13.6%
+- TCL 新风空调至高省电 40%
+
+### 回收体系
+- 21 亿台家电保有量,规范处置可减碳 1100-3000 万吨
+- 送新收旧一站式服务
+- "互联网+回收"、"以车代库"等模式推广
+
+---
+
+*整理时间:2026年6月* | *来源:公开 websearch 检索*