guanwei 1.2.6 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Binary file
@@ -53,22 +53,38 @@ const PORT = process.env.PORT || 3018;
53
53
  app.use(cors());
54
54
  app.use(express.json());
55
55
 
56
- // ─── AI 接口限流(防 BYOK Key 被刷爆):per-IP 令牌桶 ───
57
- const AI_RATE_LIMIT = { windowMs: 60_000, max: 30 }; // 每分钟 30 次(本地单用户无感,公网防刷)
58
- const aiHits = new Map<string, { count: number; resetAt: number }>();
59
- app.use('/api/ai', (req, res, next) => {
60
- const ip = req.ip || req.socket.remoteAddress || 'unknown';
56
+ // ─── 限流(防 BYOK Key 被刷爆 / 无限建占刷库):per-IP 令牌桶 ───
57
+ // /api/ai:每分钟 30 次(AI 烧 token,重点防护)
58
+ // /api/divine 写:每分钟 60 次(防无限建占刷 SQLite)
59
+ const AI_RATE_LIMIT = { windowMs: 60_000, max: 30 };
60
+ const DIVINE_RATE_LIMIT = { windowMs: 60_000, max: 60 };
61
+ const hits = new Map<string, { count: number; resetAt: number }>();
62
+ // 定期清理过期桶(防内存泄漏,M-NEW3)
63
+ setInterval(() => {
61
64
  const now = Date.now();
62
- const rec = aiHits.get(ip);
63
- if (!rec || now > rec.resetAt) {
64
- aiHits.set(ip, { count: 1, resetAt: now + AI_RATE_LIMIT.windowMs });
65
- return next();
66
- }
67
- rec.count++;
68
- if (rec.count > AI_RATE_LIMIT.max) {
69
- return res.status(429).json({ error: 'AI_RATE_LIMITED', message: '请求过于频繁,请稍后再试' });
70
- }
71
- next();
65
+ for (const [k, v] of hits) if (now > v.resetAt) hits.delete(k);
66
+ }, 10 * 60 * 1000).unref();
67
+
68
+ function rateLimit(limit: { windowMs: number; max: number }) {
69
+ return (req: any, res: any, next: any) => {
70
+ const ip = req.ip || req.socket.remoteAddress || 'unknown';
71
+ const now = Date.now();
72
+ const rec = hits.get(ip);
73
+ if (!rec || now > rec.resetAt) {
74
+ hits.set(ip, { count: 1, resetAt: now + limit.windowMs });
75
+ return next();
76
+ }
77
+ rec.count++;
78
+ if (rec.count > limit.max) {
79
+ return res.status(429).json({ error: 'RATE_LIMITED', message: '请求过于频繁,请稍后再试' });
80
+ }
81
+ next();
82
+ };
83
+ }
84
+ app.use('/api/ai', rateLimit(AI_RATE_LIMIT));
85
+ app.use('/api/divine', (req, res, next) => {
86
+ if (req.method === 'GET') return next(); // 读操作不限制
87
+ return rateLimit(DIVINE_RATE_LIMIT)(req, res, next);
72
88
  });
73
89
 
74
90
  // 请求日志(联调排查用)
@@ -8,6 +8,8 @@ import { buildMessages, buildReportMessages, buildStep1Messages, buildStep2Messa
8
8
  import { chatOnce, chatStream, activeProvider, providerStatus, lastFinishReason } from '../services/llmProvider.js';
9
9
  import { getDivination, attachReport, markAiFailed } from '../services/divineStore.js';
10
10
  import { verifyRelatives, fixFamilyRelatives } from '../services/relativesCheck.js';
11
+ import { orchestrateZiweiDeep } from '../services/orchestrator.js';
12
+ import { MINGPAN_TEMPLATE } from '../services/promptBuilder.js';
11
13
 
12
14
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
15
 
@@ -22,6 +24,20 @@ function authedUsername(req: any): string | null {
22
24
  } catch { return null; }
23
25
  }
24
26
 
27
+
28
+ // 解读归属校验(与 divine.resolveOwner 同策略):正式账号须本人 token,占位账号兼容无 token
29
+ function canReadChart(req: any, recUsername: string, fallbackUsername: string): boolean {
30
+ const authed = authedUsername(req);
31
+ if (authed) return authed === recUsername;
32
+ // 无 token:仅当目标是占位账号(未正式注册)时允许 fallback username
33
+ if (fallbackUsername && fallbackUsername !== recUsername) return false;
34
+ try {
35
+ const db = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'data', 'db.json'), 'utf-8'));
36
+ const user = db.users.find((u: any) => u.username === recUsername);
37
+ return !user || !user.passHash; // 占位账号放行
38
+ } catch { return true; }
39
+ }
40
+
25
41
  // 非流式:token 优先归属
26
42
  const router = Router();
27
43
 
@@ -32,7 +48,7 @@ router.get('/providers', (_req, res) => {
32
48
 
33
49
  // 非流式解读(保底通道)
34
50
  router.post('/interpret', async (req, res) => {
35
- const { artId, question, divineId, semantic, profile, report, fit, username } = req.body;
51
+ const { artId, question, divineId, semantic, profile, report, fit, username, orchestrate } = req.body;
36
52
  if (!artId) return res.status(400).json({ error: '缺少必要参数' });
37
53
  try {
38
54
  // 从 SQLite 读排盘数据(v6:不再信任前端直传 resultRaw)
@@ -41,13 +57,29 @@ router.post('/interpret', async (req, res) => {
41
57
  if (divineId) {
42
58
  const rec = getDivination(divineId);
43
59
  if (!rec) return res.status(400).json({ error: 'DIVINE_NOT_FOUND', message: '起占记录不存在,请重新起占' });
44
- const authed = authedUsername(req);
45
- if (authed ? authed !== rec.username : (username && rec.username !== username)) return res.status(403).json({ error: 'FORBIDDEN' });
60
+ if (!canReadChart(req, rec.username, String(username || ''))) return res.status(403).json({ error: 'FORBIDDEN' });
46
61
  resultRaw = rec.resultRaw;
47
62
  recProfile = rec.profile;
48
63
  } else {
49
64
  return res.status(400).json({ error: 'DIVINE_REQUIRED', message: '请先起占(divineId 缺失)' });
50
65
  }
66
+ // 真·多 Agent 编排模式(v1:紫微深度三步链式)
67
+ if (orchestrate === 'ziwei-deep' && artId === 'ziwei') {
68
+ const { full } = await orchestrateZiweiDeep(resultRaw, question, MINGPAN_TEMPLATE, (recProfile as any)?.gender || (profile as any)?.gender);
69
+ const parsed2 = parseReport(full, 'mingpan');
70
+ const relErrs2 = verifyRelatives('ziwei', resultRaw, parsed2);
71
+ if (relErrs2.length && relErrs2.length <= 6) {
72
+ console.warn('[ai] 六亲表述矛盾,定向修正:', relErrs2.join(';'));
73
+ const fixed2 = await fixFamilyRelatives('ziwei', resultRaw, parsed2, relErrs2);
74
+ if (fixed2) parsed2.family = fixed2 as any;
75
+ }
76
+ if (parsed2.quality === 'ok') {
77
+ attachReport(divineId, parsed2, 'ok');
78
+ return res.json({ provider: activeProvider()?.id, report: parsed2, sections: parseSections(full), orchestrated: true });
79
+ }
80
+ markAiFailed(divineId, 'ziwei', 'mingpan', '编排质量未达标', full.slice(0, 4000));
81
+ return res.status(502).json({ error: 'AI_REPORT_INVALID', message: 'AI 输出不符合报告结构,请重试' });
82
+ }
51
83
  // 统一报告模式:两步管线(Step1 盘面解析 → Step2 深度报告),Step1 失败降级单步
52
84
  const kind = ['bazi', 'ziwei', 'astrology'].includes(artId) ? 'mingpan' : 'zhanwen';
53
85
  let step1Text = '';
@@ -106,8 +138,7 @@ router.post('/interpret/stream', async (req, res) => {
106
138
  res.status(400).json({ error: 'DIVINE_NOT_FOUND', message: '起占记录不存在,请重新起占' });
107
139
  return;
108
140
  }
109
- const authed = authedUsername(req);
110
- if (authed ? authed !== rec.username : (username && rec.username !== username)) {
141
+ if (!canReadChart(req, rec.username, String(username || ''))) {
111
142
  res.status(403).json({ error: 'FORBIDDEN' });
112
143
  return;
113
144
  }
@@ -1,15 +1,6 @@
1
1
  // 排盘路由:登录用户起占 → 后端引擎计算 → SQLite 入库 → 返回 resultRaw + display
2
2
  import { Router } from 'express';
3
- import { baziCalc } from '../../../shared/core/engine/bazi.js';
4
- import { ziweiCalc } from '../../../shared/core/engine/ziwei.js';
5
- import { astrologyCalc } from '../../../shared/core/engine/astrology.js';
6
- import { qimenCalc } from '../../../shared/core/engine/qimen.js';
7
- import { meihuaCalc } from '../../../shared/core/engine/meihua.js';
8
- import { liuyaoCalc } from '../../../shared/core/engine/liuyao.js';
9
- import { liurenCalc } from '../../../shared/core/engine/liuren.js';
10
- import { xiaoliurenCalc } from '../../../shared/core/engine/xiaoliuren.js';
11
- import { tarotDraw } from '../../../shared/core/engine/tarot.js';
12
- import { allTarotSpreads } from '../../../shared/core/data/tarotSpreads.js';
3
+ import { chartCalc } from '../../../shared/core/engine/chart.js';
13
4
  import { createDivination, listDivinations, getDivination, deleteDivination } from '../services/divineStore.js';
14
5
  import fs from 'fs';
15
6
  import path from 'path';
@@ -23,20 +14,21 @@ const router = Router();
23
14
  const MINGPAN_ARTS = ['bazi', 'ziwei', 'astrology'];
24
15
 
25
16
  // 宽松建档:前端本地注册的用户在此同步建档(与 users.ts upsert 同策略;建档即发 claimToken 防抢占)
26
- function ensureUser(username: string): boolean {
27
- if (!username || typeof username !== 'string') return false;
17
+ // 返回 null=失败;否则 { existed: 是否已存在, claimToken: 该账号当前 token(新建时返回,供前端保存用于后续注册升级)}
18
+ function ensureUser(username: string): { existed: boolean; claimToken: string } | null {
19
+ if (!username || typeof username !== 'string') return null;
28
20
  try {
29
21
  const db = JSON.parse(fs.readFileSync(DB_FILE, 'utf-8'));
30
22
  const exist = db.users.find((u: any) => u.username === username);
31
- if (exist) return true;
23
+ if (exist) return { existed: true, claimToken: exist.token || '' };
32
24
  const token = crypto.randomBytes(32).toString('hex');
33
- db.users.push({ username, passHash: '', createdAt: Date.now(), profile: {}, samples: [], records: [], token });
25
+ db.users.push({ username, passHash: '', createdAt: Date.now(), profile: {}, samples: [], records: [], token, tokenExpires: Date.now() + 30 * 24 * 3600 * 1000 });
34
26
  fs.writeFileSync(DB_FILE, JSON.stringify(db, null, 2));
35
27
  console.log('[divine] 自动建档:', username);
36
- return true;
28
+ return { existed: false, claimToken: token };
37
29
  } catch (e: any) {
38
30
  console.error('[divine] ensureUser 异常:', e?.message || e);
39
- return false;
31
+ return null;
40
32
  }
41
33
  }
42
34
 
@@ -51,110 +43,97 @@ function authedUsername(req: any): string | null {
51
43
  } catch { return null; }
52
44
  }
53
45
 
46
+ // 归属校验统一入口:
47
+ // - 带有效 token → 以 token 用户为准(堵自报 username 越权)
48
+ // - 无 token:
49
+ // · 目标为占位账号(passHash 空,自动建档/未正式注册)→ 允许(本地单机流程)
50
+ // · 目标为正式账号(注册过,passHash 非空)→ 401(必须持本人 token,H-NEW1~3)
51
+ // 返回 { owner, isPlaceholder };null = 校验失败(已 res 响应)
52
+ function resolveOwner(req: any, res: any, fallbackUsername: string): { owner: string; isPlaceholder: boolean } | null {
53
+ const authed = authedUsername(req);
54
+ let owner = authed || String(fallbackUsername || '');
55
+ if (!owner) { res.status(401).json({ error: 'UNAUTHORIZED', message: '请先入馆(登录)' }); return null; }
56
+ let user: any = null;
57
+ try {
58
+ const db = JSON.parse(fs.readFileSync(DB_FILE, 'utf-8'));
59
+ user = db.users.find((u: any) => u.username === owner) || null;
60
+ } catch { /* 读失败按占位处理 */ }
61
+ const isPlaceholder = !user || !user.passHash;
62
+ if (!authed && !isPlaceholder) {
63
+ // 无 token 但目标是正式账号 → 拒绝(防自报他人 username 越权)
64
+ res.status(401).json({ error: 'AUTH_REQUIRED', message: '请先入馆(登录)后操作' });
65
+ return null;
66
+ }
67
+ return { owner, isPlaceholder };
68
+ }
69
+
54
70
  // POST /api/divine —— 起占入库
55
71
  router.post('/', (req, res) => {
56
72
  const { artId, inputs, profile, question, username, profileId } = req.body || {};
57
- const authed = authedUsername(req);
58
- const owner = authed || String(username || ''); // 有 token 以 token 为准,防自报他人名
59
- // 游客不允许
60
- if (!ensureUser(owner)) {
73
+ const resolved = resolveOwner(req, res, String(username || ''));
74
+ if (!resolved) return;
75
+ const owner = resolved.owner;
76
+ const ensured = ensureUser(owner);
77
+ if (!ensured) {
61
78
  return res.status(401).json({ error: 'UNAUTHORIZED', message: '请先入馆(登录)再起占' });
62
79
  }
80
+ const claimToken = ensured.claimToken; // 新建占位账号时返回,供前端保存(L-NEW1)
63
81
  if (!artId || !inputs) return res.status(400).json({ error: '缺少必要参数' });
64
82
 
65
83
  let resultRaw: unknown;
66
84
  try {
67
- switch (artId) {
68
- case 'bazi': {
69
- const i = inputs as any;
70
- resultRaw = baziCalc({ y: i.y, m: i.m, d: i.d, hourIndex: i.hourIndex, time: i.time, gender: i.gender, location: i.location });
71
- break;
72
- }
73
- case 'ziwei': {
74
- const i = inputs as any;
75
- resultRaw = ziweiCalc({ ganzhi: i.ganzhi, month: i.month, day: i.day, hour: i.hour, time: i.time, location: i.location, gender: i.gender, birthYear: i.birthYear });
76
- break;
77
- }
78
- case 'astrology': {
79
- const i = inputs as any;
80
- resultRaw = astrologyCalc(i.y, i.m, i.d, i.hour || 0, i.min || 0, i.lng, i.lat);
81
- break;
82
- }
83
- case 'qimen': {
84
- resultRaw = qimenCalc({ datetime: (inputs as any).datetime || new Date() });
85
- break;
86
- }
87
- case 'meihua': {
88
- const i = inputs as any;
89
- resultRaw = meihuaCalc({ mode: i.mode || 'time', n1: i.n1, n2: i.n2, n3: i.n3, now: i.now ? new Date(i.now) : undefined });
90
- break;
91
- }
92
- case 'liuyao': {
93
- const now = new Date();
94
- resultRaw = liuyaoCalc(undefined, { y: now.getFullYear(), m: now.getMonth() + 1, d: now.getDate() });
95
- break;
96
- }
97
- case 'liuren': {
98
- resultRaw = liurenCalc((inputs as any).datetime || new Date());
99
- break;
100
- }
101
- case 'xiaoliuren': {
102
- const i = inputs as any;
103
- resultRaw = xiaoliurenCalc(i.mode || 'time', i.m, i.d, i.h, i.n1, i.n2, i.n3);
104
- break;
105
- }
106
- case 'tarot': {
107
- const i = inputs as any;
108
- const cards = tarotDraw(Math.max(1, i.n || 3));
109
- const spread = allTarotSpreads().find((s: any) => s.id === i.spread) || allTarotSpreads()[0];
110
- resultRaw = { spread: spread || { id: 'three', name: '圣三角', description: '', positions: [] }, cards };
111
- break;
112
- }
113
- default:
114
- return res.status(400).json({ error: '术无此名' });
115
- }
85
+ resultRaw = chartCalc(artId, inputs);
116
86
  } catch (e: any) {
87
+ if (e?.message?.includes('术无此名')) return res.status(400).json({ error: '术无此名' });
117
88
  console.error('[divine] 推演异常:', e);
118
89
  return res.status(500).json({ error: 'DIVINE_FAILED', message: '推演未应机' });
119
90
  }
120
91
 
121
92
  const kind = MINGPAN_ARTS.includes(artId) ? 'mingpan' : 'zhanwen';
122
93
  const rec = createDivination({
123
- username, artId, kind,
94
+ username: owner, artId, kind,
124
95
  question: question || undefined,
125
96
  profileId: profileId || 'main',
126
97
  profile: profile || undefined,
127
98
  params: inputs,
128
99
  resultRaw,
129
100
  });
130
- res.json({ ok: true, divineId: rec.id, resultRaw, display: rec.display });
101
+ const authed = authedUsername(req);
102
+ res.json({
103
+ ok: true, divineId: rec.id, resultRaw, display: rec.display,
104
+ // L-NEW1:请求未带 token(本地占位流程)→ 返回 claimToken,前端保存后注册可升级;带 token 则无需
105
+ claimToken: authed ? undefined : ensured.claimToken,
106
+ });
131
107
  });
132
108
 
133
- // GET /api/divine?username=&page=&pageSize= —— 占卜历史
109
+ // GET /api/divine?username=&page=&pageSize= —— 占卜历史(H-NEW2:正式账号须本人 token)
134
110
  router.get('/', (req, res) => {
135
111
  const username = String(req.query.username || '');
136
112
  const page = Number(req.query.page || 1);
137
113
  const pageSize = Number(req.query.pageSize || 20);
138
- if (!ensureUser(username)) return res.status(401).json({ error: 'UNAUTHORIZED' });
139
- res.json(listDivinations(username, page, pageSize, String(req.query.profileId || '')));
114
+ const resolved = resolveOwner(req, res, username);
115
+ if (!resolved) return;
116
+ if (!ensureUser(resolved.owner)) return res.status(401).json({ error: 'UNAUTHORIZED' });
117
+ res.json(listDivinations(resolved.owner, page, pageSize, String(req.query.profileId || '')));
140
118
  });
141
119
 
142
- // GET /api/divine/:id —— 详情(排盘 + AI 报告)
120
+ // GET /api/divine/:id —— 详情(排盘 + AI 报告;正式账号须本人 token)
143
121
  router.get('/:id', (req, res) => {
144
122
  const rec = getDivination(req.params.id);
145
123
  if (!rec) return res.status(404).json({ error: 'DIVINE_NOT_FOUND' });
146
- // 归属校验:token 优先;无 token 时 query username 必须与记录一致(向后兼容本地旧前端)
147
- const authed = authedUsername(req);
148
- if (authed ? authed !== rec.username : String(req.query.username || '') !== rec.username) {
149
- return res.status(403).json({ error: 'FORBIDDEN' });
150
- }
124
+ // 归属校验:token 优先;无 token 仅占位账号可经 query username 访问
125
+ const resolved = resolveOwner(req, res, String(req.query.username || ''));
126
+ if (!resolved) return;
127
+ if (resolved.owner !== rec.username) return res.status(403).json({ error: 'FORBIDDEN' });
151
128
  res.json({ ...rec, resultRaw: rec.resultRaw, display: rec.display, report: rec.report || null });
152
129
  });
153
130
 
154
- // DELETE /api/divine/:id?username= —— 删除(校验归属)
131
+ // DELETE /api/divine/:id?username= —— 删除(H-NEW3:正式账号须本人 token)
155
132
  router.delete('/:id', (req, res) => {
156
133
  const username = String(req.query.username || '');
157
- const ok = deleteDivination(req.params.id, username);
134
+ const resolved = resolveOwner(req, res, username);
135
+ if (!resolved) return;
136
+ const ok = deleteDivination(req.params.id, resolved.owner);
158
137
  if (!ok) return res.status(404).json({ error: 'DIVINE_NOT_FOUND' });
159
138
  res.json({ ok: true });
160
139
  });
@@ -23,6 +23,8 @@ interface DbUser {
23
23
  records: Record<string, unknown>[];
24
24
  /** 登录态 token(注册/登录时签发,云同步写接口须携带校验归属) */
25
25
  token?: string;
26
+ /** token 过期时间(ms;30 天滚动,登录时轮换续期) */
27
+ tokenExpires?: number;
26
28
  }
27
29
 
28
30
  interface Db { users: DbUser[] }
@@ -47,13 +49,15 @@ async function hashPassword(pw: string): Promise<string> {
47
49
  return 'scrypt$' + salt.toString('hex') + '$' + key.toString('hex');
48
50
  }
49
51
 
50
- // 登录态 token:随机 32 字节 hex;写接口(profile/records/samples)须携带且归属匹配
52
+ const TOKEN_TTL = 30 * 24 * 3600 * 1000; // 30 天
53
+
51
54
  function newToken(): string {
52
55
  return crypto.randomBytes(32).toString('hex');
53
56
  }
54
57
 
55
58
  function tokenMatches(user: DbUser, token: string | undefined): boolean {
56
59
  if (!token || !user.token) return false;
60
+ if (user.tokenExpires && Date.now() > user.tokenExpires) return false; // 过期即失效
57
61
  // 恒时比较,防时序侧信道
58
62
  const a = Buffer.from(token), b = Buffer.from(user.token);
59
63
  return a.length === b.length && crypto.timingSafeEqual(a, b);
@@ -84,7 +88,7 @@ function upsertUser(username: string): DbUser {
84
88
  const db = loadDb();
85
89
  let user = db.users.find((x: any) => x.username === username);
86
90
  if (!user) {
87
- user = { username, passHash: '', createdAt: Date.now(), profile: {}, samples: [], records: [], token: newToken() };
91
+ user = { username, passHash: '', createdAt: Date.now(), profile: {}, samples: [], records: [], token: newToken(), tokenExpires: Date.now() + TOKEN_TTL };
88
92
  db.users.push(user);
89
93
  saveDb(db);
90
94
  console.log(`[users] 自动建档: ${username}`);
@@ -108,13 +112,14 @@ router.post('/register', async (req, res) => {
108
112
  }
109
113
  existing.passHash = await hashPassword(String(password));
110
114
  existing.token = newToken();
115
+ existing.tokenExpires = Date.now() + TOKEN_TTL;
111
116
  if (profile) existing.profile = { ...existing.profile, ...profile };
112
117
  saveDb(db);
113
118
  return res.json({ ok: true, upgraded: true, token: existing.token, user: { username: existing.username, profile: existing.profile, samples: existing.samples } });
114
119
  }
115
120
  return res.status(400).json({ error: '此名号已有人用' });
116
121
  }
117
- const user: DbUser = { username: name, passHash: await hashPassword(String(password)), createdAt: Date.now(), profile: profile || {}, samples: [], records: [], token: newToken() };
122
+ const user: DbUser = { username: name, passHash: await hashPassword(String(password)), createdAt: Date.now(), profile: profile || {}, samples: [], records: [], token: newToken(), tokenExpires: Date.now() + TOKEN_TTL };
118
123
  db.users.push(user);
119
124
  saveDb(db);
120
125
  res.json({ ok: true, token: user.token, user: { username: user.username, profile: user.profile, samples: user.samples } });
@@ -134,6 +139,10 @@ router.post('/login', async (req, res) => {
134
139
  saveDb(db);
135
140
  console.log(`[users] 密码哈希已升级为 scrypt: ${user.username}`);
136
141
  }
142
+ // 登录成功 → 轮换 token(旧 token 即失效;防泄露长期有效)
143
+ user.token = newToken();
144
+ user.tokenExpires = Date.now() + TOKEN_TTL;
145
+ saveDb(db);
137
146
  res.json({ ok: true, token: user.token, user: { username: user.username, profile: user.profile, samples: user.samples } });
138
147
  });
139
148
 
@@ -0,0 +1,106 @@
1
+ // 真·多 Agent 编排 v1(guanwei-pro 雏形):
2
+ // 命盘类分步链式推理——Step A 结构解构 → Step B 宫位/星曜专题 → Step C 汇总成报告
3
+ // 与单轮/两步管线区别:每一步独立 LLM 调用、上一步输出作为下一步唯一事实源,步间事实约束
4
+ // 设计:每步 prompt 短而聚焦(降低单次输出幻觉面),关键事实链(chartBrief 十二宫清单)全程逐字引用
5
+ import { chartBrief } from './chartBrief.js';
6
+ import { duanyuPromptBlock } from './duanyu.js';
7
+ import { chatOnce } from './llmProvider.js';
8
+ import { nowFact } from './promptBuilder.js';
9
+
10
+ export type OrchestrateMode = 'off' | 'ziwei-deep';
11
+
12
+ // 每步输出 ≤ 400 字的事实性专题,不做人生论断(论断集中在最后汇总步)
13
+ function stepMessages(agentName: string, step: string, brief: string, prior: string[], question?: string): { role: string; content: string }[] {
14
+ const priorBlock = prior.length
15
+ ? prior.map((p, i) => `【上一步结论 ${i + 1}】${p}`).join('\n')
16
+ : '(本步为首步)';
17
+ const system = [
18
+ '你是一位' + agentName + ',负责紫微斗数分步推理中的一个环节。',
19
+ '【最高约束】所有盘面事实(宫位/主星/辅星/四化/亮度)必须逐字引用下方【盘面事实】,不得推算、不得编造。',
20
+ '【本步任务】' + step,
21
+ '【输出】只输出一个 JSON 对象 {"points": ["要点1", "要点2", ...]}(3-6 条,每条 ≤80 字,只陈述盘面结构与通义,不做人生论断)。',
22
+ ].join('\n');
23
+ const user = [
24
+ question ? '【所问】' + question + '(仅作背景,本步不回答)' : '',
25
+ '【盘面事实(不可更改)】',
26
+ brief,
27
+ '',
28
+ nowFact(),
29
+ '',
30
+ priorBlock,
31
+ '',
32
+ '请输出本步 JSON。',
33
+ ].filter(Boolean).join('\n');
34
+ return [
35
+ { role: 'system', content: system },
36
+ { role: 'user', content: user },
37
+ ];
38
+ }
39
+
40
+ function parsePoints(text: string): string[] {
41
+ try {
42
+ const obj = JSON.parse(text);
43
+ if (Array.isArray(obj.points)) return obj.points.map(String);
44
+ } catch { /* 非 JSON 时按行切 */ }
45
+ return text.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('{') && !l.startsWith('}')).slice(0, 8);
46
+ }
47
+
48
+ // 汇总步:把前面各步要点 + 盘面事实汇总成完整解读
49
+ function finalMessages(agentName: string, artName: string, schemaTemplate: string, brief: string, steps: string[], question?: string, gender?: string): { role: string; content: string }[] {
50
+ const stepBlock = steps.map((s, i) => `【分步推演 ${i + 1}】${s}`).join('\n\n');
51
+ const system = [
52
+ '你是一位' + agentName + '。',
53
+ '【任务】基于下面给出的【盘面事实】与【分步推演结论】,生成完整解读报告(JSON)。',
54
+ '【盘面事实一致性 · 最高约束】所有盘面事实必须与【盘面事实】逐字一致,不得编造或自行推算;六亲/宫位论断须注明盘面依据。',
55
+ '【质量】内容充实、具体到场景与行为;不作绝对化断言;结尾附免责声明。',
56
+ '【输出 Schema(字段名不可更改)】' + schemaTemplate,
57
+ ].join('\n');
58
+ const user = [
59
+ '【性别语境】' + (gender || '未录'),
60
+ question ? '【所问之事】' + question : '',
61
+ '【盘面事实(不可更改)】',
62
+ brief,
63
+ '',
64
+ stepBlock,
65
+ '',
66
+ '请输出完整解读报告 JSON。',
67
+ ].filter(Boolean).join('\n');
68
+ return [
69
+ { role: 'system', content: system },
70
+ { role: 'user', content: user },
71
+ ];
72
+ }
73
+
74
+ /**
75
+ * 紫微深度编排:三步独立推理后汇总。
76
+ * 返回 { full, steps }(full 为最终报告文本;steps 为各步要点便于调试/审计)
77
+ */
78
+ export async function orchestrateZiweiDeep(
79
+ resultRaw: unknown,
80
+ question: string | undefined,
81
+ schemaTemplate: string,
82
+ gender?: string,
83
+ ): Promise<{ full: string; steps: string[] }> {
84
+ const brief = chartBrief('ziwei', resultRaw);
85
+ const duanyu = duanyuPromptBlock('ziwei', resultRaw);
86
+ const steps: string[] = [];
87
+
88
+ const STEPS = [
89
+ '解构命盘骨架:命宫地支/五行局/紫微落宫/身宫/生年四化/主星布局格局,提炼盘的总体气质。',
90
+ '专题拆解:依十二宫主题(命/兄弟/夫妻/子女/财帛/疾厄/迁移/交友/官禄/田宅/福德/父母)逐宫提炼其星曜组合的侧重,命宫重点。',
91
+ '行运推演:结合当前大限(年龄段/宫位/主星)与流年宫位,提炼行运主题与关键节点。',
92
+ ];
93
+ const prior: string[] = [];
94
+ for (const [i, step] of STEPS.entries()) {
95
+ const msgs = stepMessages('紫微斗数专家', step, brief, prior, question);
96
+ const text = await chatOnce(msgs as any);
97
+ const pts = parsePoints(text);
98
+ prior.push(pts.join(';'));
99
+ steps.push(pts.join(';'));
100
+ console.log(`[orchestrator] Step ${i + 1} 完成(${pts.length} 要点)`);
101
+ }
102
+ // 汇总步:含断语引证
103
+ const finalSys = finalMessages('紫微斗数专家', '紫微斗数', schemaTemplate, brief + (duanyu ? '\n' + duanyu : ''), prior, question, gender);
104
+ const full = await chatOnce(finalSys as any);
105
+ return { full, steps };
106
+ }
@@ -53,7 +53,7 @@ const ZHANWEN_SAMPLE = {
53
53
  disclaimer: '免责声明',
54
54
  suitability: { suitable: true, note: '适问性说明 2-3 句', suggestion: '换问建议或无' },
55
55
  };
56
- const MINGPAN_TEMPLATE = JSON.stringify(MINGPAN_SAMPLE, null, 2);
56
+ export const MINGPAN_TEMPLATE = JSON.stringify(MINGPAN_SAMPLE, null, 2);
57
57
  const ZHANWEN_TEMPLATE = JSON.stringify(ZHANWEN_SAMPLE, null, 2);
58
58
  const MINGPAN_ARTS = ['bazi', 'ziwei', 'astrology'];
59
59
 
@@ -80,7 +80,7 @@ function hourUnknownNote(profile?: unknown): string {
80
80
 
81
81
  // 命主已知人生经历 → 解读校准注入(报告须呼应该年份事件、不得与其矛盾)
82
82
  // 当前公历时间事实:注入给 AI,防止其自行推算年份(占问类无出生档案,AI 不知道"现在是哪年")
83
- function nowFact(): string {
83
+ export function nowFact(): string {
84
84
  const d = new Date();
85
85
  const p = (n: number) => String(n).padStart(2, '0');
86
86
  const week = '日一二三四五六'[d.getDay()];
@@ -0,0 +1,76 @@
1
+ // 九术排盘统一调度:artId + inputs → resultRaw
2
+ // 唯一算法副本(divine 路由 / packages/guanwei-api / MCP / 未来托管共用,禁止各层另写 switch)
3
+ import { baziCalc } from './bazi.js';
4
+ import { ziweiCalc } from './ziwei.js';
5
+ import { astrologyCalc } from './astrology.js';
6
+ import { qimenCalc } from './qimen.js';
7
+ import { meihuaCalc } from './meihua.js';
8
+ import { liuyaoCalc } from './liuyao.js';
9
+ import { liurenCalc } from './liuren.js';
10
+ import { xiaoliurenCalc } from './xiaoliuren.js';
11
+ import { tarotDraw } from './tarot.js';
12
+ import { allTarotSpreads } from '../data/tarotSpreads.js';
13
+
14
+ export const CHART_ARTS = ['bazi', 'ziwei', 'astrology', 'qimen', 'meihua', 'liuyao', 'liuren', 'xiaoliuren', 'tarot'] as const;
15
+ export type ChartArtId = typeof CHART_ARTS[number];
16
+
17
+ /**
18
+ * 九术排盘(纯函数):artId + inputs → resultRaw
19
+ * 抛错:未知术名 / 输入不合法 → Error(调用方转 400)
20
+ */
21
+ export function chartCalc(artId: string, inputs: any): unknown {
22
+ switch (artId) {
23
+ case 'bazi': {
24
+ const i = inputs || {};
25
+ if (i.y == null || i.m == null || i.d == null) throw new Error('BaziInput 需 y/m/d');
26
+ return baziCalc({ y: i.y, m: i.m, d: i.d, hourIndex: i.hourIndex, time: i.time, gender: i.gender, location: i.location });
27
+ }
28
+ case 'ziwei': {
29
+ const i = inputs || {};
30
+ return ziweiCalc({ ganzhi: i.ganzhi, month: i.month, day: i.day, hour: i.hour, time: i.time, location: i.location, gender: i.gender, birthYear: i.birthYear });
31
+ }
32
+ case 'astrology': {
33
+ const i = inputs || {};
34
+ return astrologyCalc(i.y, i.m, i.d, i.hour || 0, i.min || 0, i.lng, i.lat);
35
+ }
36
+ case 'qimen': {
37
+ return qimenCalc({ datetime: inputs?.datetime ? new Date(inputs.datetime) : new Date() });
38
+ }
39
+ case 'meihua': {
40
+ const i = inputs || {};
41
+ return meihuaCalc({ mode: i.mode || 'time', n1: i.n1, n2: i.n2, n3: i.n3, now: i.now ? new Date(i.now) : undefined });
42
+ }
43
+ case 'liuyao': {
44
+ const now = new Date();
45
+ return liuyaoCalc(undefined, { y: now.getFullYear(), m: now.getMonth() + 1, d: now.getDate() });
46
+ }
47
+ case 'liuren': {
48
+ return liurenCalc(inputs?.datetime ? new Date(inputs.datetime) : new Date());
49
+ }
50
+ case 'xiaoliuren': {
51
+ const i = inputs || {};
52
+ return xiaoliurenCalc(i.mode || 'time', i.m, i.d, i.h, i.n1, i.n2, i.n3);
53
+ }
54
+ case 'tarot': {
55
+ const i = inputs || {};
56
+ const cards = tarotDraw(Math.max(1, i.n || 3));
57
+ const spread = allTarotSpreads().find((s: any) => s.id === i.spread) || allTarotSpreads()[0];
58
+ return { spread: spread || { id: 'three', name: '圣三角', description: '', positions: [] }, cards };
59
+ }
60
+ default:
61
+ throw new Error('术无此名: ' + artId);
62
+ }
63
+ }
64
+
65
+ /** 每术输入参数说明(/v1/arts 能力清单用) */
66
+ export const CHART_INPUT_SCHEMA: Record<string, Record<string, string>> = {
67
+ bazi: { y: '公历年', m: '公历月', d: '公历日', hourIndex: '时辰 0-11(0=子)', gender: '男/女', location: '{lng,lat,province,city,district}' },
68
+ ziwei: { ganzhi: '年干支', month: '农历月', day: '农历日', hour: '时辰 0-11', gender: '男/女', location: '{lng,lat}' },
69
+ astrology: { y: '公历年', m: '月', d: '日', hour: '时 0-23', min: '分', lng: '经度', lat: '纬度' },
70
+ qimen: { datetime: '起局时刻 ISO 字符串' },
71
+ meihua: { mode: 'time|number', n1: '报数1', n2: '报数2', n3: '报数3' },
72
+ liuyao: {},
73
+ liuren: { datetime: '起课时刻 ISO 字符串' },
74
+ xiaoliuren: { mode: 'time', m: '农历月', d: '农历日', h: '时辰 0-11' },
75
+ tarot: { n: '抽牌数(默认3)', spread: '牌阵 id' },
76
+ };