merge: 拉取 origin/main(KB snapshot + 在线 FAQ 同步 + 环境文档)

This commit is contained in:
wangliang
2026-10-23 11:32:01 +08:00
30 changed files with 2083 additions and 33 deletions
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# 从已有环境导出 KB(kb_docs)快照,供没有旧 bridge 的测试/生产环境导入。
# 用法: bash scripts/export-kb-snapshot.sh [输出文件]
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
OUT="${1:-$ROOT/deploy/seeds/kb_docs.sql.gz}"
SRC_CONTAINER="${SOURCE_PG_CONTAINER:-crm-postgres}"
SRC_DB="${SOURCE_PG_DB:-products}"
SRC_USER="${SOURCE_PG_USER:-postgres}"
mkdir -p "$(dirname "$OUT")"
docker exec "$SRC_CONTAINER" pg_dump -U "$SRC_USER" -d "$SRC_DB" \
-t kb_docs --no-owner --no-privileges | gzip -9 > "$OUT"
echo "[export-kb] $SRC_CONTAINER/$SRC_DB.kb_docs -> $OUT ($(du -h "$OUT" | cut -f1))"
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# 在没有旧 bridge 的环境导入 KB 快照(默认导入 crmnew-postgres/bridge)。
# 用法:
# bash scripts/import-kb-snapshot.sh [seed.sql.gz]
# FORCE=1 bash scripts/import-kb-snapshot.sh # 覆盖已有 kb_docs(会 DROP)
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SEED="${1:-$ROOT/deploy/seeds/kb_docs.sql.gz}"
TARGET_CONTAINER="${TARGET_PG_CONTAINER:-crmnew-postgres}"
TARGET_DB="${TARGET_PG_DB:-bridge}"
TARGET_USER="${TARGET_PG_USER:-crmnew}"
if [ ! -f "$SEED" ]; then echo "seed not found: $SEED" >&2; exit 1; fi
# 确保数据库存在
if ! docker exec "$TARGET_CONTAINER" psql -U "$TARGET_USER" -d postgres -tAc \
"SELECT 1 FROM pg_database WHERE datname='$TARGET_DB'" | grep -q 1; then
docker exec "$TARGET_CONTAINER" psql -U "$TARGET_USER" -d postgres -c "CREATE DATABASE \"$TARGET_DB\""
fi
docker exec "$TARGET_CONTAINER" psql -U "$TARGET_USER" -d "$TARGET_DB" -c \
"CREATE EXTENSION IF NOT EXISTS vector;" >/dev/null
# 旧 dump 带 tsv trigger,先确保函数存在
docker exec -i "$TARGET_CONTAINER" psql -U "$TARGET_USER" -d "$TARGET_DB" >/dev/null <<'SQL'
CREATE OR REPLACE FUNCTION public.kb_docs_tsv_update() RETURNS trigger AS $$
BEGIN
NEW.text_tsv := to_tsvector('english',
coalesce(NEW.question,'') || ' ' || coalesce(NEW.answer,'') || ' ' ||
coalesce(array_to_string(NEW.keywords,' '),'') || ' ' ||
coalesce(NEW.title,'') || ' ' || coalesce(NEW.section,''));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
SQL
EXISTS=$(docker exec "$TARGET_CONTAINER" psql -U "$TARGET_USER" -d "$TARGET_DB" -tAc \
"SELECT to_regclass('public.kb_docs') IS NOT NULL")
if [ "$EXISTS" = "t" ]; then
if [ "${FORCE:-0}" != "1" ]; then
echo "[import-kb] kb_docs already exists in $TARGET_DB; refusing to overwrite. Set FORCE=1 to drop it." >&2
exit 1
fi
echo "[import-kb] FORCE=1: dropping existing kb_docs in $TARGET_DB"
docker exec "$TARGET_CONTAINER" psql -U "$TARGET_USER" -d "$TARGET_DB" -c "DROP TABLE IF EXISTS kb_docs CASCADE" >/dev/null
fi
gunzip -c "$SEED" | docker exec -i "$TARGET_CONTAINER" psql -U "$TARGET_USER" -d "$TARGET_DB" -v ON_ERROR_STOP=1 >/dev/null
COUNT=$(docker exec "$TARGET_CONTAINER" psql -U "$TARGET_USER" -d "$TARGET_DB" -tAc "SELECT count(*) FROM kb_docs")
echo "[import-kb] imported kb_docs rows=$COUNT into $TARGET_CONTAINER/$TARGET_DB"
echo "[import-kb] 提示: bridge 的 RAG_PG_URL 应指向该库(容器内通常是 postgres://...@postgres:5432/$TARGET_DB)"
+2 -1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env bash
# 启动/重启公网 HTTPS(Cloudflare Quick Tunnel),并把 LibreDesk root_url 指过去。
# 仅本地联调:启动/重启公网 HTTPS(Cloudflare Quick Tunnel),并把 LibreDesk root_url 指过去。
# 测试/生产不要用 Quick Tunnel:使用正式域名 + Ingress/Nginx/ALB + TLS。
# 注意:quick tunnel 每次重启 URL 会变,需要重新跑本脚本(Meta 里的 webhook 也要同步改)。
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."
+4
View File
@@ -1,4 +1,8 @@
#!/usr/bin/env bash
# 仅当存在旧 bridge 容器/旧 products 库时使用。
# 没有旧 bridge 的测试/生产环境请用:
# bash scripts/import-kb-snapshot.sh # 初始快照
# node scripts/sync-kb-online.js # 在线更新
# 重建旧 KB 的 parent-child 索引(调用旧 bridge 容器里的 indexer),再回填 text_tsv。
# 旧表会由 indexer 归档为 kb_docs_vN;额外先做一份 kb_docs_backup_<date> 备份。
set -euo pipefail
+2 -1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env bash
# 测试服 JSON-RPC 端口外网被封:在本机建 SSH 隧道,供 crm-new 的 bridge 访问。
# 仅本地开发:测试服 JSON-RPC 端口外网被封时,在本机建 SSH 隧道。
# 测试/生产不需要此隧道:把 RPC URL 直接配成内网可达地址。
#
# 默认只启动隧道并打印 .env 需要的 4 行配置;
# 加 --apply 会自动更新 .env 里的 RPC URL 并重建 bridge。
+1 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# 本地静态测试页(widget JWT 演示)
# 仅本地预览:静态测试页(widget JWT 演示);测试/生产不需要
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/../web"
echo "http://localhost:${WEB_PORT:-8088}/demo.html"
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env node
'use strict';
// 在线 FAQ/KB 同步(不依赖旧 bridge):
// 抓官网 FAQ JSON-LD → 更新 bridge/kb/*.md → parent/child + embedding → 原子切换 kb_docs
// 用法:
// node scripts/sync-kb-online.js [--force]
// 需要环境变量:
// RAG_PG_URL / EMB_BASE_URL / EMB_API_KEY / EMB_MODEL / EMB_DIM
const fs = require('fs');
const path = require('path');
const { loadConfig } = require('../bridge/src/config');
const { createEmbeddingClient } = require('../bridge/src/embedding');
const { createKbIndexer } = require('../bridge/src/kb-indexer');
const { createFaqFetcher, loadFaqConfig } = require('../bridge/src/faq-fetcher');
const { updateMarkdownFile } = require('../bridge/src/faq-update');
const ROOT = path.resolve(__dirname, '..');
const env = {};
for (const line of fs.readFileSync(path.join(ROOT, '.env'), 'utf8').split('\n')) {
if (!line.includes('=') || line.trim().startsWith('#')) continue;
const i = line.indexOf('=');
env[line.slice(0, i).trim()] = line.slice(i + 1).trim();
}
const cfg = loadConfig({ ...env, ...process.env });
const force = process.argv.includes('--force');
const localOnly = process.argv.includes('--local-only');
const KB_DIR = path.join(ROOT, 'bridge', 'kb');
const FAQ_CONFIG = path.join(ROOT, 'bridge', 'config', 'faq.json');
(async () => {
if (!cfg.ragPgUrl) throw new Error('RAG_PG_URL is not configured');
if (!cfg.embApiKey || !cfg.embBaseUrl) throw new Error('EMB_* is not configured');
const faqCfg = loadFaqConfig(FAQ_CONFIG);
if (!faqCfg.enabled) throw new Error('faq.json disabled');
let changedFiles = 0;
let added = 0;
let updated = 0;
if (!localOnly) {
const fetcher = createFaqFetcher(faqCfg);
const fetched = await fetcher.fetchAll();
for (const page of fetched) {
if (page.error || !page.questions.length) {
console.log(JSON.stringify({ ts: new Date().toISOString(), faq_fetch: 'skip', file: page.file, error: page.error || 'no_questions' }));
continue;
}
const filePath = path.join(KB_DIR, page.file);
const res = updateMarkdownFile(filePath, page.questions);
if (res.changed) changedFiles += 1;
added += res.added || 0;
updated += res.updated || 0;
console.log(JSON.stringify({ ts: new Date().toISOString(), faq_fetch: 'ok', file: page.file, questions: page.questions.length, added: res.added || 0, updated: res.updated || 0 }));
}
}
if (!localOnly && !force && changedFiles === 0) {
console.log(JSON.stringify({ ts: new Date().toISOString(), kb_sync: 'skipped', reason: 'no_faq_changes' }));
return;
}
const emb = createEmbeddingClient(cfg);
const indexer = createKbIndexer({ pool: emb.pool, embeddingClient: emb, kbDir: KB_DIR, dim: cfg.embDim });
try {
const result = await indexer.reindex({ note: `sync-kb-online ${new Date().toISOString().slice(0, 10)}` });
console.log(JSON.stringify({ ts: new Date().toISOString(), kb_sync: 'done', changed_files: changedFiles, added, updated, ...result }));
} finally {
await emb.pool.end().catch(() => {});
}
})().catch((err) => {
console.error('[sync-kb-online]', err.message);
process.exit(1);
});