A Flask web app at ~/Desktop/wechat-analyzer/ that pulls WeChat data via wx-cli, analyzes private/group chats, and renders a dark-theme dashboard with identity detection, relationship metrics, and summary views.
使用本工具前需要先准备好以下环境:
wx-cli 是一个 Rust 二进制工具,用于读取微信本地数据库。推荐两种安装方式:
npm 安装(推荐,全平台)
npm install -g @jackwener/wx-cli
或 curl 一键安装
curl -fsSL https://raw.githubusercontent.com/jackwener/wx-cli/main/install.sh | bash
验证安装:
wx --version
微信使用 SQLCipher 加密本地数据库,wx-cli 需要从微信进程内存中提取密钥。macOS 上需要先对 WeChat.app 做 ad-hoc 签名:
# 1. 签名微信(WeChat 更新后需重做)
codesign --force --deep --sign - /Applications/WeChat.app
# 2. 清理旧 TCC 授权(重签后必做,否则权限可能失效)
for s in ScreenCapture Camera Microphone AppleEvents \
SystemPolicyDocumentsFolder SystemPolicyDownloadsFolder SystemPolicyDesktopFolder; do
tccutil reset "$s" com.tencent.xinWeChat
done
# 3. 重启微信
killall WeChat && open /Applications/WeChat.app
# 等待微信完全登录
# 4. 初始化密钥
sudo wx init
> 已知副作用:重签后 macOS 可能频繁弹「微信」想访问其他 App 的数据,这是 ad-hoc 签名后 code identity 改变导致的。点「允许」即可放行。
初始化后,验证能否读取会话列表:
wx sessions
能看到最近会话即表示一切正常。daemon 在首次调用时自动启动,无需手动启动。
项目目录下的 config.json 需要填写以下内容:
{
"db_dir": "/Users/你的用户名/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/你的用户名_哈希/db_storage",
"llm": {
"api_key": "***",
"base_url": "https://api.deepseek.com/v1",
"enabled": true,
"model": "deepseek-chat",
"provider": "deepseek"
},
"user_nickname": "你的微信昵称"
}
db_dir: 微信本地数据库路径(可在微信 → 设置 → 文件管理查看实际路径)llm.api_key: LLM API Key(推荐 DeepSeek,也支持 OpenAI 兼容接口)user_nickname: 你在微信中的昵称(用于 AI 识别消息中的「我」)需要 Python 3.8+。推荐使用 uv 或 venv 管理依赖:
# 使用 uv(推荐)
uv venv
uv pip install flask requests
# 或使用 pip
python3 -m venv venv
source venv/bin/activate
pip install flask requests
完成以上步骤后,确认环境就绪:
# 验证 wx 可工作
wx sessions -n 3 --json
# 验证 Python 依赖
python3 -c "import flask; print('Flask OK')"
wx-cli 是本工具的数据基础,以下是与 wechat-analyzer 配合使用的常用命令和数据格式。
wx sessions # 最近 20 个会话(含 chat_type)
wx unread --filter private,group # 有未读消息的真人会话
wx new-messages # 上次检查后的新消息(增量)
wx history "姓名" -n 200 --json # 拉聊天记录
wx history "姓名" --since YYYY-MM-DD --until YYYY-MM-DD -n N --json
wx search "关键词" # 全库搜索
wx contacts # 联系人列表
wx contacts --query "关键字" # 按名字搜索
wx stats "群名" --json # 群聊统计
wx daemon status / stop / logs --follow # daemon 管理
-n(500+)频繁超时 → 用 --since/--until 按半月分批,每批 -n 5000> file,再 python3 读文件-n 0 不会返回"全部" → 返回 0 条,用 -n 99999 取全量wx history --json 返回平面数组(不是 {messages: [...]})sender: "",用户 sender: "{{用户昵称}}"sender_username:稳定 wxidsender_contact_display:通讯录显示名sender_group_nickname:群名片YYYY-MM-DD HH:MMtype:文本 / 图片 / 语音 / 链接 / 文件chat_type:private / group / official_account / folded当用户需要逐轮聊天分析和回复建议时,遵循以下规则:
回复建议格式:给出 3 条具体话术加理由,不是泛泛建议。需要用到详细的心理学分析模式时,见 references/ 下的相关文档。
| 症状 | 原因 | 修复 |
|---|---|---|
| ------ | ------ | ------ |
wx-daemon 启动超时(>15s) | WeChat 持有数据库文件锁 | 延长超时 WX_DAEMON_TIMEOUT=60 wx sessions,或重启 WeChat |
| 「预热完成,联系人 0 个」 | 密钥过期 | sudo wx init --force |
| 「无法解密 session.db」 | 密钥过期 | sudo wx init --force |
| 「读取密钥文件失败: No such file」 | CWD 与 init 目录不一致 | ln -s ~/.wx-cli/all_keys.json |
根因修复序列:
codesign --force --deep --sign - /Applications/WeChat.app
killall WeChat && open /Applications/WeChat.app
# 等待登录后
sudo wx init --force
cd ~/Desktop/wechat-analyzer
python3 server.py
# → http://localhost:8899 (默认无密码,可在设置中开启密码保护)
# → LAN: http://192.168.31.121:8899
~/Desktop/wechat-analyzer/
├── server.py # Flask app, all API routes (LLM + settings + auth)
├── analyzer.py # Core engine: wx-cli wrappers, analysis, identity, indices, signals
├── llm.py # LLM integration — 12 AI analysis functions + _sample_convo helper
├── config.json # LLM config + password settings (gitignored — keep local only)
├── config.example.json# 公开的配置模板(git 追踪,实际配置填到 config.json 后复制改名)
├── README.md # GitHub 仓库主页简介
├── .gitignore # 排除 config.json / all_keys.json / 构建产物
├── usage_stats.json # LLM usage stats (total_calls, tokens, by_function, recent calls)
├── custom_tags.json # Manual identity tag overrides
├── docs/
│ └── index.html # GitHub Pages 项目展示页(暗色冰蓝主题,WebGL 背景,匹配 app 风格)
└── templates/
└── index.html # Single-page dark-theme frontend (~2800 lines after 2026-05 refactor)
2026-05-23 Refactor: The summary dashboard has been removed. The page now opens with
a header + search panel + 3 capability preview cards (no auto-loading, no overview stats,
no topic leaderboard). Flow: search → select contact → click "开始分析" → see results.
2026-05-28 Startup Page Redesign: Header de-gradiented, search panel refined, capability
preview cards added. See references/startup-redesign.md.
API routes in server.py:
GET /api/contacts?q= — search contactsPOST /api/analyze — full analysis (private/group)GET /api/summary?range=today|3d|7d — summary (backend still exists but frontend no longer calls it on page load)GET/POST/DELETE /api/tags — custom identity tagsGET/POST /api/config — LLM settingsGET/POST /api/usage — LLM usage stats (GET returns stats, POST with {action:"reset"} clears)GET/POST /api/settings — password settingsPOST /api/llm/signal|reply|insight — original 3 AI dimensionsPOST /api/llm/topics|todos|emotion-track — private chat extended AI dimensionsPOST /api/llm/group-topics|group-members|group-vibe|group-signals|group-trace|group-roles — group chat AI dimensionsThe following were deleted from templates/index.html (~500 lines removed):
#summaryDashboard with time selector, stats, tabs, cards, leaderboard)#loadingOverlay with spinner + timer + skip button)loadSummary, showSummary, switchTab, applyFilter, setFilter, resetSummaryFilter, renderSummary, renderPrivateCards, renderGroupCards, renderGlobalTopics, renderGlobalTopicList, switchGlobalTab, showTopicDetail, switchTopicTab, jumpToAnalysis, startLoadingTimer, stopLoadingTimer, updateLoadingTimerDOMContentLoaded + document.readyState check both removed.summary-dashboard, .time-selector, .summary-overview, .summary-stat, .summary-filter-bar, .summary-tabs, .summary-tab, .summary-section-title, .summary-grid, .summary-card, .summary-unread, .summary-row, .todo-list, .todo-item, .todo-priority, .todo-snippet, .topic-tags, .topic-tag-sm, .signal-badge, .reply-hint-box, .global-topics, .global-topic-*, .recent-preview, .summary-loading, .loading-overlay, .loading-spinner, .loading-text, .loading-dots, .loading-timer, .back-to-summaryKept (still used by analysis results):
toggleTopics(), editIdentityTag(), deleteIdentityTag().topic-rank-*, .topic-hidden, .topic-expand-btn).screenshot-btn)#spinner)After analysis completes, autoRunLLM() runs all 6 private-chat dimensions sequentially
and renders each as a styled card with per-dimension color scheme. Cards appear below
the stats/charts section. No manual buttons — the button bar was removed.
Each dimension renders as an .ai-card with a title bar and styled body:
| Dimension | Title Color | Card Class | Visual Style |
|---|---|---|---|
| ----------- | ------------- | ------------ | -------------- |
| 🔍 信号解读 | pink | .ai-card.signal | 2×2 grid: emotion/needs/risk(tinted)/tips + reply bar (green/red) |
| 💬 回复建议 | green | .ai-card.reply | Context note + 3 numbered replies with gradient circles, style tags, italic reasons |
| 📊 关系洞察 | purple | .ai-card.insight | Left-accented quote block, lavender text |
| 🗣️ 话题挖掘 | blue | .ai-card.topics | Bullet lines as gradient cards with hover → pink border |
| 📌 待办提取 | orange | .ai-card.todos | Priority badges (red/orange/grey, weight 800) + who + item + context |
| 📈 情绪追踪 | fuchsia | .ai-card.emotion | Same quote style as insight, different border color |
Typography: titles have letter-spacing, values weight 500, secondary info weight 400,
priority badges weight 800 uppercase.
During analysis, a pulsing blue dot + animated trailing dots show:
<div class="ai-loading-header">
<div class="ai-loading-pulse"></div> AI 深度分析中<span class="ai-loading-dots"></span>
</div>
The pulse dot scales 0.8→1.3 with opacity 0.2→1 over 1.2s. After all dimensions
complete, the loading header is removed from the DOM.
Group dimensions return structured JSON (not free text). The frontend renders
graphical components (.gv-* CSS classes, 70+ lines of visualization styles).
POST /api/llm/group-topics```json
[{"rank": 1, "topic": "话题名", "pct": 30, "trend": "↑", "color": "#58a6ff",
"keywords": ["kw1","kw2"], "sample": "一句原文"}]
```
Rendered: rank number + colored progress bar + ↑↓→ trend + keyword tags + italic sample quote.
POST /api/llm/group-members```json
[{"name": "成员名", "tier": "核心", "msg_share": 35, "style": "话痨",
"icon": "🔥", "color": "#f85149", "desc": "一句话风格描述"}]
```
Rendered: circular avatar card (colored bg + emoji) + tier badge + share progress bar + %.
POST /api/llm/group-vibe```json
{"mood": "轻松", "mood_emoji": "😄", "score": 8, "conflict": false,
"conflict_detail": "", "description": "一句话", "color": "#3fb950"}
```
Rendered: tinted card + large emoji + big score number + conflict warning if true.
POST /api/llm/group-signals```json
[{"type": "通知", "icon": "📢", "priority": "高", "content": "活动/聚会摘要", "color": "#f85149"},
{"type": "分享", "icon": "🔗", "priority": "中", "content": "推荐链接/文章摘要", "color": "#d2991d"},
{"type": "求助", "icon": "❓", "priority": "高", "content": "问题或求助", "color": "#3fb950"},
{"type": "亮点", "icon": "💡", "priority": "低", "content": "有趣讨论/段子", "color": "#a371f7"},
{"type": "待办", "icon": "📌", "priority": "中", "content": "未落实事项", "color": "#58a6ff"}]
```
Categories designed for casual WeChat groups (not formal "公告/决策"). Max 6 items, 300 messages sampled. LLM instructed: "宁缺毋滥,没发现就返回[]".
POST /api/llm/group-trace```json
[{"type": "@我", "icon": "📌", "content": "谁说了什么", "time": "12:30", "color": "#f85149"}]
```
Rendered: colored circle icon + timestamp + content. Types: @我/参与/错过, each with distinct color.
POST /api/llm/group-roles```json
[{"role": "意见领袖", "icon": "👑", "members": ["name"], "color": "#d2991d", "desc": "why"}]
```
Rendered: tinted role card + icon + role name (colored) + member names + description.
Fallback: If LLM returns non-JSON, try/except passes the raw string through
and frontend renders it as plain text (.replace(/\n/g, ").
")
The group chat AI panel has NO horizontal tab buttons. Unlike the private chat
panel (which keeps 6 row buttons for manual re-query), the group panel is pure
vertical stacking — all 6 dimensions auto-load and display top-to-bottom.
Design decisions:
renderLLMPanel() for groups skips the llm-btns div entirely — only + .llm-result has NO max-height — it grows naturally with content. white-space: pre-wrap removed.- Dimension section headers compact for groups:
margin-top:10px;padding-top:6px vs private's 14px/10px. - CSS compactification: all
.gv-* styles reduced ~25-30% (see table below). - After auto-run,
panel._fullResult is saved. Manual re-query via runLLMDim() works if needed. Component Before After ----------- -------- ------- Topic row pad 10px 6px Topic bar h 8px 6px Member avatar 40px 32px Vibe emoji 2.2rem 1.6rem Signal padding 8px 5px Trace icon 28px 24px Role pad 10px 14px 6px 10px
Frontend rendering: in autoRunLLM(), each group dim has a dedicated else if
branch that parses the JSON array/object and builds HTML with the .gv-* CSS classes.
The rendering handles empty arrays gracefully (shows "未发现"/"暂无足迹"/"无数据").
All dim- divs are populated independently, so failures don't cascade.
runLLMDim() — single-dimension manual re-query
When a user clicks an individual dimension button, runLLMDim(dim, chatName) fires.
It delegates rendering to renderDimSlot(dim, data, result) — the same shared
function used by autoRunLLM. All 6 group dimensions get full graphical rendering
(topic bars, member avatars, vibe cards, signal badges, trace icons, role cards).
After showing the single dimension, a ← 显示全部 button is prepended. Clicking it
calls restoreFullResult() which restores panel._fullResult (saved at the end of
autoRunLLM()).
renderDimSlot() — shared rendering function
Centralizes all dimension-specific HTML rendering. Called by both autoRunLLM (via
renderDimSlot(dim, d.data, slot)) and runLLMDim. One place to add new visual
components — no duplication between auto-run and manual-click paths.
Groups dimensions: group-topics, group-members, group-vibe, group-signals,
group-trace, group-roles — each with dedicated .gv-* CSS classes.
Generic fallback: for unknown dims or non-JSON responses, renders as plain text or
compact JSON string.
Adding a new AI dimension
- Add LLM function in
llm.py — use _sample_convo(messages, max_n=200) helper, return None if not is_available() - Add endpoint in
server.py — standard POST pattern - Add entry to the
DIMS array in autoRunLLM() (index.html) - Add rendering case in autoRunLLM's per-dimension router (the
if (dim === ...) chain)
_sample_convo() helper (llm.py)
Uniform message sampling for group or generic use. Returns (convo_str, total_count).
Sender="" → "?", no identity mapping. For private chats, prefer _format_private_convo().
def _sample_convo(messages, max_n=200):
# Evenly sampled, formatted as "[HH:MM] sender: content[:120]"
# Non-text types mapped to [语音]/[图片]/[表情]/[通话]
_format_private_convo() — USE FOR ALL PRIVATE CHAT AI PROMPTS (llm.py)
The canonical way to format private chat conversations for LLM input. Returns (convo_str, total_count, preamble_str).
Critical — identity labeling: sender="" or sender=contact_name → labeled as the contact's actual name (e.g. "{{联系人姓名}}"). sender=user_name → labeled as "我". The preamble explains who is who:
对话身份:「{{联系人姓名}}」是联系人,「我」是用户(后台使用人 {{用户昵称}})。
[22:15] {{联系人姓名}}: 今天好累啊
[22:16] 我: 怎么了
This replaces the old ambiguous format where empty sender → "对方" and AI had no idea who "对方" was.
Every private-chat AI function in llm.py must use this helper — the preamble goes directly into the system prompt. All 8 private-chat functions now use it: analyze_signal, suggest_reply, analyze_chat_insight, analyze_topics_ai, extract_todos_ai, track_emotion_ai, generate_advice_ai, generate_phase_insight.
Each function accepts contact_name and user_name parameters. Server endpoints pass them via chat_name and user_name fields in the JSON body. Frontend stores _lastUserName from data.user_name and passes it in all API calls.
Settings Page
Four-tab layout in the ⚙️ settings modal (👤 个人信息 opens first by default):
Tab Content ----- --------- 👤 个人信息 User's WeChat nickname (e.g., \"{{用户昵称}}\"). AI uses this to identify \"who am I\" in chats. Stored as user_nickname in config.json. 🤖 大模型 API Provider/Base URL/Model/API Key + enable toggle + test button 🔐 安全设置 Password enable toggle + change password (min 3 chars) + confirm 📊 用量统计 LLM call stats: total calls, tokens (input/output), cost estimate, per-function breakdown table, refresh + reset buttons
Settings stored in config.json:
{
"llm": { ... },
"password": "",
"password_enabled": false,
"user_nickname": ""
}
When password_enabled: false, the login_required decorator skips all auth
checks. Login page redirects to index. User can still access settings to re-enable.
Endpoints: GET/POST /api/settings (password is always masked as * in responses).
Nickname Flow Through the System
- User sets nickname in ⚙️ → saved via
POST /api/settings with {user_nickname: "your_nickname"} server.py _save_server_config(user_nickname=...) persists to config.json- Frontend
saveProfile() stores in window._configuredNickname renderResults(): window._lastUserName = window._configuredNickname || data.user_name || ""renderLLMPanel() stores panel._userName = window._lastUserNameautoRunLLM() passes uname in all private-chat API calls: {..., user_name: uname}- Server endpoints extract
user_name and pass to LLM functions _format_private_convo() uses it to label messages: sender=user_name → \"我\"
Priority: configured nickname > auto-detected from messages (data.user_name). If
user hasn't configured a nickname, the system falls back to the first non-empty sender
found in the chat history. Without this setting, group chat dimensions like \"我的足迹\"
and \"信息雷达\" cannot accurately identify which messages are from the user.
API Contract: username field
The /api/analyze endpoint accepts an optional username field alongside contact. When provided, analyze_group() uses username (the wxid or xxx@chatroom ID) for wx history queries instead of the display name.
Why this matters
wx history matches by username (the WeChat internal ID like 57931515500@chatroom), not by display name. Display names can contain special characters or have been renamed, causing wx history to fail with "找不到...的消息记录".
Data flow
Frontend: selectedContact.username → POST /api/analyze {contact, username, ...}
Server: analyze_group(contact, since, until, username=username)
query = username or contact_name # username wins if provided
cmd = f'wx history {_q(query)} --since {since}...'
Adding username to a new feature
When making a new API call that uses wx history, always pass the username
... [OUTPUT TRUNCATED - 9289 chars omitted out of 59289 total] ...
vy → near-black, #09090c base)
- Mouse radial ripple:
sin(md12 - t3.5) exp(-md3.5) — distance-based decay - Domain warp: 3-iteration
sin/cos feedback loop for slow fluid drift - Highlight sparkle:
pow(sin(...), 7.0) for occasional bright specks
CSS setup:
#bg-fluid {
position: fixed; inset: 0; z-index: 0;
pointer-events: none; opacity: 0.55;
}
.app { position: relative; z-index: 1; }
Graceful fallback: if WebGL context is unavailable, the canvas is hidden via canvas.style.display='none'. No visual breakage.
Tuning: adjust opacity 0.35–0.7 in CSS. Shader is ~70 lines of JS (inline before
). See references/webgl-background.md.
After analysis renders, a compact floating widget appears at bottom-right with two buttons:
<nav class="quick-nav" id="scrollNav">
<button class="quick-nav-btn top" onclick="scrollToTop()">▲ 顶部</button>
<button class="quick-nav-btn screenshot" onclick="captureScreenshot()">📸 截图</button>
</nav>
CSS: position: fixed; right: 16px; bottom: 80px; width: 52px. Glassmorphism backdrop.
Activation: document.getElementById("scrollNav").classList.add("visible") after analysis.
Hidden on mobile. Labels ≤ 6 chars. Screenshot button lives OUTSIDE #results div (prevents DOM destruction).
Five-tab layout in 560px-wide modal (was 480px). Tabs use padding: 6px 12px; font-size: 0.8rem.
| Tab | Content |
|---|---|
| ----- | --------- |
| 👤 个人信息 | WeChat nickname |
| 🤖 大模型 API | LLM provider config |
| 🔐 安全设置 | Password toggle |
| 📊 用量统计 | 4 stat cards (1.5rem value, 0.7rem label, 14px padding) + function table |
| 🌐 网络设置 | LAN toggle + real IP (click to copy) + visit link (new tab) |
lan_enabled in config.json_detect_lan_ip() in server.pyHallmark audit results and CSS fixes: references/hallmark-audit.md.
All chart functions use chartOk() guard to prevent "Chart is not defined" errors when CDN hasn't loaded:
function chartOk(canvasId) {
if (typeof Chart === 'undefined') return false;
return !!document.getElementById(canvasId);
}
// Applied to all 9 chart rendering functions
Group analysis now uses batch endpoint /api/llm/group-all — all 6 dimensions in a single LLM call. Saves tokens and reduces latency. group_batch_analysis() in llm.py returns a single JSON object with all 6 dimension keys.
python3 -c "import py_compile; py_compile.compile('f', doraise=True)"pkill -f "python3 server.py" can leave the port occupied (Flask debug mode spawns a child process that survives the initial SIGTERM). ✅ Fix: lsof -ti:8899 | xargs kill -9 2>/dev/null; sleep 1 — force-kills whatever is holding the port, then start fresh.secret_key each start)shlex.quote(). Use the _q() helper function (defined near imports in analyzer.py) as shorthand: _q = shlex.quote. Pattern: f'wx history {_q(name)} --since ...' — note NO surrounding double quotes, _q() adds them.references/daemon-troubleshooting.mdAdded fallback: _get_sessions_from_cache() in analyzer.py reads from cached decrypted SQLite when wx sessions fails (line 109-). The fallback auto-discovers cache DBs by scanning table names (SessionTable for sessions, contact for display names).
sudo wx init saves keys relative to CWD: Run from ~/Desktop/wechat-analyzer/ (same dir the server runs from) so the daemon finds ./all_keys.json at startup. Symlink ln -s ~/.wx-cli/all_keys.json /all_keys.json if you ran init elsewhere.sudo wx init --force to rescan keys, then restart daemon.wx history 用显示名查不到群聊: wx history 接受的是 username(微信内部 ID,如 57931515500@chatroom),不是显示名("Hermes 中文社区 互联网 IT 软件")。如果显示名包含特殊字符或群被改名,查询会失败。修复:前端 POST /api/analyze 时传 username 字段(来自 selectedContact.username),后端 analyze_group() 用 query = username or group_name 优先使用 username 查。见"API Contract: username field"一节。[time] 对方: content (AI guesses). ✅ New format: [time] {{联系人姓名}}: content with a preamble 对话身份:「{{联系人姓名}}」是联系人,「我」是用户(后台使用人 {{用户昵称}})。. ALWAYS use _format_private_convo() for private chat prompts"你是用户的同事。回复要专业高效" (AI thinks it IS the colleague and generates replies as if it were the contact). ✅ "对话双方是同事关系。回复要专业高效" (AI knows the context and generates replies FROM the user TO the contact). This applies to any LLM prompt involving role-play — never tell the AI to BE the contact."""...""" string to an f"""...""" f-string that contains JSON template braces ({"key": "value"}), ALL braces must be escaped to {{"key": "value"}}. Otherwise Python interprets them as f-string expressions and raises ValueError: Invalid format specifier at runtime. This silently passes ast.parse() because the syntax is valid — the error only occurs when the f-string evaluates. Always grep for bare { in new f-strings that embed JSON examples._format_private_convo(), check that no downstream code references the old local variables (sample_n, sampled, etc.). The helper returns (convo, total, preamble); old code that references sample_n in user prompts (e.g., f"共{total}条,展示{sample_n}条") must be updated to use only the returned values.analyze_signal() must know that "对方" = the contact (for emotional state analysis) and the named sender = the user (for "should I reply?" advice). Fixed by passing contact_name + user_name through the pipeline.ThreadPoolExecutor(max_workers=4) parallelizes all wx history callswx history -n 1) primes SQLite cache before parallel batchwx search does NOT return chat_type. Must detect groups via username.endswith("@chatroom") or by checking senders (groups have diverse non-empty senders).analyze_summary() has duplicate processing logic between the sessions loop and the extra-contacts fallback. When adding fields, update BOTH code paths.config.json — API key is stored in plaintext. Password settings also in same config.json.0.0.0.0:8899 with debug=True — suitable for LAN access, not production.