guanwei 1.1.1

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.
Files changed (157) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +202 -0
  3. package/README.md +247 -0
  4. package/dist/assets/index-CsOD5rSo.js +102 -0
  5. package/dist/assets/index-CsOD5rSo.js.map +1 -0
  6. package/dist/assets/index-DQx62TUr.css +1 -0
  7. package/dist/favicon.svg +12 -0
  8. package/dist/index.html +15 -0
  9. package/eslint.config.js +28 -0
  10. package/index.html +25 -0
  11. package/package.json +96 -0
  12. package/postcss.config.js +10 -0
  13. package/public/favicon.svg +12 -0
  14. package/scripts/guanwei +245 -0
  15. package/scripts/install.sh +79 -0
  16. package/scripts/release.sh +42 -0
  17. package/scripts/setup.bat +36 -0
  18. package/scripts/setup.sh +123 -0
  19. package/server/src/data/db.json +12059 -0
  20. package/server/src/data/guanwei.db +0 -0
  21. package/server/src/data/lifeContext.ts +423 -0
  22. package/server/src/data/spreads.ts +255 -0
  23. package/server/src/data/tarotCards.ts +2353 -0
  24. package/server/src/index.ts +71 -0
  25. package/server/src/routes/ai.ts +376 -0
  26. package/server/src/routes/divine.ts +140 -0
  27. package/server/src/routes/hour.ts +40 -0
  28. package/server/src/routes/tarot.ts +129 -0
  29. package/server/src/routes/users.ts +140 -0
  30. package/server/src/services/chartBrief.ts +131 -0
  31. package/server/src/services/divineStore.ts +188 -0
  32. package/server/src/services/hourInference.ts +150 -0
  33. package/server/src/services/llmProvider.ts +181 -0
  34. package/server/src/services/promptBuilder.ts +327 -0
  35. package/server/src/services/relativesCheck.ts +139 -0
  36. package/server/src/services/sixRelatives.ts +70 -0
  37. package/server/src/services/skills.ts +116 -0
  38. package/server/src/types/index.ts +104 -0
  39. package/server/src/utils/semanticAnalyzer.ts +555 -0
  40. package/server/src/utils/tarotEngine.ts +849 -0
  41. package/server/tsconfig.json +18 -0
  42. package/shared/core/data/classics.ts +276 -0
  43. package/shared/core/data/courses.ts +211 -0
  44. package/shared/core/data/duanyu.ts +148 -0
  45. package/shared/core/data/ganzhi.ts +53 -0
  46. package/shared/core/data/gua64.ts +93 -0
  47. package/shared/core/data/liuren.ts +18 -0
  48. package/shared/core/data/liuyao.ts +73 -0
  49. package/shared/core/data/qimen.ts +27 -0
  50. package/shared/core/data/region-full.ts +3 -0
  51. package/shared/core/data/region.ts +310 -0
  52. package/shared/core/data/tarotSpreads.ts +78 -0
  53. package/shared/core/data/xiaoliuren.ts +18 -0
  54. package/shared/core/data/ziwei.ts +97 -0
  55. package/shared/core/data/zodiac.ts +20 -0
  56. package/shared/core/engine/astrology.ts +169 -0
  57. package/shared/core/engine/bazi.ts +306 -0
  58. package/shared/core/engine/calendar.ts +127 -0
  59. package/shared/core/engine/daily.ts +133 -0
  60. package/shared/core/engine/liuren.ts +86 -0
  61. package/shared/core/engine/liuyao.ts +93 -0
  62. package/shared/core/engine/meihua.ts +87 -0
  63. package/shared/core/engine/qimen.ts +78 -0
  64. package/shared/core/engine/questionFit.ts +94 -0
  65. package/shared/core/engine/tarot.ts +69 -0
  66. package/shared/core/engine/trueSolarTime.ts +86 -0
  67. package/shared/core/engine/xiaoliuren.ts +19 -0
  68. package/shared/core/engine/ziwei.ts +217 -0
  69. package/shared/core/engine/ziweiInterpret.ts +161 -0
  70. package/shared/core/types.ts +206 -0
  71. package/src/App.tsx +69 -0
  72. package/src/arts/registry.ts +31 -0
  73. package/src/assets/react.svg +1 -0
  74. package/src/components/Backdrop.tsx +27 -0
  75. package/src/components/BambooArt.tsx +41 -0
  76. package/src/components/CalendarInput.tsx +131 -0
  77. package/src/components/DateInput.tsx +103 -0
  78. package/src/components/Disclaimer.tsx +12 -0
  79. package/src/components/Empty.tsx +8 -0
  80. package/src/components/ErrorBoundary.tsx +38 -0
  81. package/src/components/HomeSideAnchor.tsx +62 -0
  82. package/src/components/InkVein.tsx +10 -0
  83. package/src/components/LifeEventsInput.tsx +47 -0
  84. package/src/components/LocationPicker.tsx +117 -0
  85. package/src/components/Motes.tsx +65 -0
  86. package/src/components/Navigation.tsx +80 -0
  87. package/src/components/ProfileForm.tsx +40 -0
  88. package/src/components/ProfilePicker.tsx +40 -0
  89. package/src/components/QuestionFields.tsx +47 -0
  90. package/src/components/ReportView.tsx +211 -0
  91. package/src/components/ResultCard.tsx +40 -0
  92. package/src/components/SealButton.tsx +18 -0
  93. package/src/components/SiteFooter.tsx +17 -0
  94. package/src/components/SiteNav.tsx +103 -0
  95. package/src/components/SongDialog.tsx +64 -0
  96. package/src/components/SongSearchSelect.tsx +81 -0
  97. package/src/components/SongSelect.tsx +61 -0
  98. package/src/components/StarField.tsx +119 -0
  99. package/src/components/TarotCard.tsx +129 -0
  100. package/src/components/TimeShichenInput.tsx +29 -0
  101. package/src/components/arts/AstrologyArt.tsx +141 -0
  102. package/src/components/arts/BaziArt.tsx +269 -0
  103. package/src/components/arts/LiurenArt.tsx +87 -0
  104. package/src/components/arts/LiuyaoArt.tsx +111 -0
  105. package/src/components/arts/MeihuaArt.tsx +105 -0
  106. package/src/components/arts/QimenArt.tsx +86 -0
  107. package/src/components/arts/TarotArt.tsx +110 -0
  108. package/src/components/arts/XiaoliurenArt.tsx +93 -0
  109. package/src/components/arts/ZiweiArt.tsx +296 -0
  110. package/src/data/arts.ts +84 -0
  111. package/src/data/lifeContext.ts +423 -0
  112. package/src/data/sample-report.json +146 -0
  113. package/src/data/shichen.ts +21 -0
  114. package/src/data/spreads.ts +255 -0
  115. package/src/data/tarotCards.ts +2353 -0
  116. package/src/hooks/useAIInterpret.ts +76 -0
  117. package/src/hooks/useDivine.ts +39 -0
  118. package/src/hooks/useTheme.ts +29 -0
  119. package/src/index.css +190 -0
  120. package/src/lib/utils.ts +6 -0
  121. package/src/main.tsx +12 -0
  122. package/src/pages/AboutPage.tsx +25 -0
  123. package/src/pages/AcademyPage.tsx +128 -0
  124. package/src/pages/AstrologyPage.tsx +33 -0
  125. package/src/pages/AuthPage.tsx +263 -0
  126. package/src/pages/BaZiPage.tsx +33 -0
  127. package/src/pages/ClassicsPage.tsx +120 -0
  128. package/src/pages/DemoPage.tsx +161 -0
  129. package/src/pages/HistoryPage.tsx +133 -0
  130. package/src/pages/Home.tsx +179 -0
  131. package/src/pages/HomePage.tsx +174 -0
  132. package/src/pages/ModulePage.tsx +320 -0
  133. package/src/pages/SpreadEditor.tsx +230 -0
  134. package/src/pages/SpreadEditorPage.tsx +105 -0
  135. package/src/pages/SpreadLibrary.tsx +199 -0
  136. package/src/pages/SpreadLibraryPage.tsx +17 -0
  137. package/src/pages/TarotPage.tsx +738 -0
  138. package/src/pages/ZiWeiPage.tsx +33 -0
  139. package/src/services/api.ts +342 -0
  140. package/src/stores/useDivinationStore.ts +22 -0
  141. package/src/styles/song.css +1066 -0
  142. package/src/styles/tokens.css +60 -0
  143. package/src/types/index.ts +104 -0
  144. package/src/utils/astrologyCalc.ts +61 -0
  145. package/src/utils/comboEngine.ts +134 -0
  146. package/src/utils/dst.ts +24 -0
  147. package/src/utils/panTone.ts +206 -0
  148. package/src/utils/recordStore.ts +60 -0
  149. package/src/utils/semanticAnalyzer.ts +555 -0
  150. package/src/utils/spreadStorage.ts +32 -0
  151. package/src/utils/storage.ts +27 -0
  152. package/src/utils/tarotEngine.ts +849 -0
  153. package/src/utils/userStore.ts +234 -0
  154. package/src/vite-env.d.ts +1 -0
  155. package/tailwind.config.js +52 -0
  156. package/tsconfig.json +40 -0
  157. package/vite.config.ts +34 -0
@@ -0,0 +1,71 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+
5
+ // 轻量 .env 加载(无第三方依赖)
6
+ (() => {
7
+ try {
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+ const envFile = path.join(__dirname, '..', '.env');
10
+ const content = fs.readFileSync(envFile, 'utf-8');
11
+ content.split('\n').forEach(line => {
12
+ const m = /^\s*([A-Z_]+)\s*=\s*(.+)\s*$/.exec(line);
13
+ if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2].replace(/^["']|["']$/g, '');
14
+ });
15
+ console.log('· 已加载 .env(LLM_PROVIDER=' + (process.env.LLM_PROVIDER || '未配置') + ')');
16
+ } catch { /* 无 .env 时忽略 */ }
17
+ })();
18
+
19
+ import express from 'express';
20
+ import cors from 'cors';
21
+ import tarotRouter from './routes/tarot.js';
22
+ import aiRouter from './routes/ai.js';
23
+ import usersRouter from './routes/users.js';
24
+ import divineRouter from './routes/divine.js';
25
+ import hourRouter from './routes/hour.js';
26
+
27
+ const app = express();
28
+ const PORT = process.env.PORT || 3018;
29
+
30
+ app.use(cors());
31
+ app.use(express.json());
32
+
33
+ // 请求日志(联调排查用)
34
+ app.use((req, res, next) => {
35
+ res.on('finish', () => {
36
+ console.log('[' + new Date().toLocaleTimeString() + '] ' + req.method + ' ' + req.originalUrl + ' → ' + res.statusCode);
37
+ });
38
+ next();
39
+ });
40
+
41
+ // /api 根信息(便于浏览器直接访问确认后端)
42
+ app.get('/api', (_req, res) => {
43
+ res.json({
44
+ name: '观微后端',
45
+ status: 'ok',
46
+ endpoints: ['/api/health', '/api/ai/providers', '/api/ai/interpret', '/api/ai/interpret/stream', '/api/users/*', '/api/divine'],
47
+ time: Date.now(),
48
+ });
49
+ });
50
+
51
+ app.get('/api/health', (_req, res) => {
52
+ res.json({ status: 'ok', timestamp: Date.now() });
53
+ });
54
+
55
+ app.use('/api/tarot', tarotRouter);
56
+ app.use('/api/ai', aiRouter);
57
+ app.use('/api/users', usersRouter);
58
+ app.use('/api/divine', divineRouter);
59
+ app.use('/api', hourRouter);
60
+
61
+ app.listen(PORT, () => {
62
+ console.log(`
63
+ 🎴 玄冥占星后端服务已启动
64
+
65
+ 地址: http://localhost:${PORT}
66
+ 健康检查: http://localhost:${PORT}/api/health
67
+ 塔罗API: http://localhost:${PORT}/api/tarot
68
+ `);
69
+ });
70
+
71
+ export default app;
@@ -0,0 +1,376 @@
1
+ // AI 解读路由:/api/ai/interpret(非流式)+ /api/ai/interpret/stream(SSE)
2
+ import { Router } from 'express';
3
+ import { buildMessages, buildReportMessages, buildStep1Messages, buildStep2Messages } from '../services/promptBuilder.js';
4
+ import { chatOnce, chatStream, activeProvider, providerStatus, lastFinishReason } from '../services/llmProvider.js';
5
+ import { getDivination, attachReport, markAiFailed } from '../services/divineStore.js';
6
+ import { verifyRelatives, fixFamilyRelatives } from '../services/relativesCheck.js';
7
+
8
+ const router = Router();
9
+
10
+ // Provider 健康状态
11
+ router.get('/providers', (_req, res) => {
12
+ res.json({ providers: providerStatus(), active: activeProvider()?.id ?? null });
13
+ });
14
+
15
+ // 非流式解读(保底通道)
16
+ router.post('/interpret', async (req, res) => {
17
+ const { artId, question, divineId, semantic, profile, report, fit, username } = req.body;
18
+ if (!artId) return res.status(400).json({ error: '缺少必要参数' });
19
+ try {
20
+ // 从 SQLite 读排盘数据(v6:不再信任前端直传 resultRaw)
21
+ let resultRaw: unknown = null;
22
+ let recProfile: unknown = undefined;
23
+ if (divineId) {
24
+ const rec = getDivination(divineId);
25
+ if (!rec) return res.status(400).json({ error: 'DIVINE_NOT_FOUND', message: '起占记录不存在,请重新起占' });
26
+ if (username && rec.username !== username) return res.status(403).json({ error: 'FORBIDDEN' });
27
+ resultRaw = rec.resultRaw;
28
+ recProfile = rec.profile;
29
+ } else {
30
+ return res.status(400).json({ error: 'DIVINE_REQUIRED', message: '请先起占(divineId 缺失)' });
31
+ }
32
+ // 统一报告模式:两步管线(Step1 盘面解析 → Step2 深度报告),Step1 失败降级单步
33
+ const kind = ['bazi', 'ziwei', 'astrology'].includes(artId) ? 'mingpan' : 'zhanwen';
34
+ let step1Text = '';
35
+ try {
36
+ step1Text = await getStep1(divineId, artId, question || '', resultRaw, recProfile);
37
+ } catch (e1: any) {
38
+ console.warn('[ai] Step1 盘面解析失败,降级为单步模式:', e1.message);
39
+ }
40
+ const messages = step1Text
41
+ ? buildStep2Messages(artId, question || '', resultRaw, step1Text, recProfile, semantic, fit)
42
+ : buildReportMessages(artId, question || '', resultRaw, profile, semantic, fit);
43
+ const text = await chatOnce(messages);
44
+ const parsed = parseReport(text, kind);
45
+ // 六亲事实校验:模型长输出常编造宫位/主星(如把父母宫写成无主星),检测到矛盾则定向重写 family 区块
46
+ const relErrs = verifyRelatives(artId, resultRaw, parsed);
47
+ if (relErrs.length && relErrs.length <= 6) {
48
+ console.warn('[ai] 六亲表述矛盾,定向修正:', relErrs.join(';'));
49
+ const fixed = await fixFamilyRelatives(artId, resultRaw, parsed, relErrs);
50
+ if (fixed) parsed.family = fixed as any;
51
+ } else if (relErrs.length > 6) {
52
+ console.warn('[ai] 六亲校验异常项过多(' + relErrs.length + ' 条,疑误报),跳过修正:', relErrs.slice(0, 3).join(';'));
53
+ }
54
+ if (parsed.quality === 'poor') {
55
+ console.error('[ai] 输出不符合报告结构,拒绝返回(不入库)');
56
+ markAiFailed(divineId, artId, kind, '质量评分未达标', text.slice(0, 4000));
57
+ return res.status(502).json({ error: 'AI_REPORT_INVALID', message: 'AI 输出不符合报告结构,请重试' });
58
+ }
59
+ // 达标 → 报告回写存储(quality=ok 才入库)
60
+ attachReport(divineId, parsed, 'ok');
61
+ res.json({ provider: activeProvider()?.id, report: parsed, sections: parseSections(text) });
62
+ } catch (e: any) {
63
+ if (e.message === 'AI_UNCONFIGURED') {
64
+ return res.status(503).json({ error: 'AI_UNCONFIGURED', message: '未配置 AI 服务,请设置 LLM_PROVIDER 与对应 Key' });
65
+ }
66
+ console.error('[ai/interpret]', e);
67
+ res.status(502).json({ error: 'AI_FAILED', message: 'AI 解读暂未应机' });
68
+ }
69
+ });
70
+
71
+ // SSE 流式解读:逐字推送(含 start / done 事件)
72
+ router.post('/interpret/stream', async (req, res) => {
73
+ const { artId, question, divineId, semantic, profile, report, fit, username } = req.body;
74
+ if (!artId) {
75
+ res.status(400).json({ error: '缺少必要参数' });
76
+ return;
77
+ }
78
+ // 从 SQLite 读排盘数据(v6:divineId 必填)
79
+ const rec = divineId ? getDivination(divineId) : null;
80
+ if (!rec) {
81
+ res.status(400).json({ error: 'DIVINE_NOT_FOUND', message: '起占记录不存在,请重新起占' });
82
+ return;
83
+ }
84
+ if (username && rec.username !== username) {
85
+ res.status(403).json({ error: 'FORBIDDEN' });
86
+ return;
87
+ }
88
+ const resultRaw = rec.resultRaw;
89
+ try {
90
+ // 两步管线(2026-08-20):Step1 盘面解析(内部非流式,短输出,带缓存)→ Step2 深度报告(流式)
91
+ let step1Text = '';
92
+ try {
93
+ step1Text = await getStep1(divineId, artId, question || '', resultRaw, rec.profile);
94
+ } catch (e1: any) {
95
+ console.warn('[ai] Step1 盘面解析失败,降级为单步模式:', e1.message);
96
+ step1Text = '';
97
+ }
98
+ const messages = step1Text
99
+ ? buildStep2Messages(artId, question || '', resultRaw, step1Text, rec.profile, semantic, fit)
100
+ : buildReportMessages(artId, question || '', resultRaw, profile, semantic, fit);
101
+ res.setHeader('Content-Type', 'text/event-stream');
102
+ res.setHeader('Cache-Control', 'no-cache');
103
+ res.setHeader('Connection', 'keep-alive');
104
+ res.write(`data: ${JSON.stringify({ type: 'start', provider: activeProvider()?.id })}\n\n`);
105
+ let full = '';
106
+ for await (const chunk of chatStream(messages)) {
107
+ full += chunk;
108
+ res.write(`data: ${JSON.stringify({ type: 'char', char: chunk })}\n\n`);
109
+ }
110
+ // 统一结构化报告:后端完成文本清洗与结构归一,前端只做渲染
111
+ const kind2 = ['bazi', 'ziwei', 'astrology'].includes(artId) ? 'mingpan' : 'zhanwen';
112
+ const rep = parseReport(full, kind2);
113
+ // 六亲事实校验与修正(同非流式路径)
114
+ const relErrs = verifyRelatives(artId, resultRaw, rep);
115
+ if (relErrs.length && relErrs.length <= 6) {
116
+ console.warn('[ai/stream] 六亲表述矛盾,定向修正:', relErrs.join(';'));
117
+ const fixed = await fixFamilyRelatives(artId, resultRaw, rep, relErrs);
118
+ if (fixed) rep.family = fixed as any;
119
+ } else if (relErrs.length > 6) {
120
+ console.warn('[ai/stream] 六亲校验异常项过多(' + relErrs.length + ' 条,疑误报),跳过修正:', relErrs.slice(0, 3).join(';'));
121
+ }
122
+ const sections = parseSections(full);
123
+ const truncated = lastFinishReason === 'length';
124
+ // 质量门槛:ok → 报告回写入库;poor/截断 → 不入库 + fail 留档
125
+ if (rep.quality === 'ok' && !truncated) {
126
+ attachReport(divineId, rep, 'ok');
127
+ } else {
128
+ const reason = truncated ? '输出截断(finish_reason=length)' : '质量评分未达标';
129
+ markAiFailed(divineId, artId, kind2, reason, full.slice(0, 4000));
130
+ }
131
+ res.write(`data: ${JSON.stringify({ type: 'done', report: rep, sections, full, truncated, quality: rep.quality || 'ok' })}\n\n`);
132
+ res.end();
133
+ } catch (e: any) {
134
+ if (e.message === 'AI_UNCONFIGURED') {
135
+ // 流已开始则发送错误事件;否则返回 503
136
+ if (res.headersSent) {
137
+ res.write(`data: ${JSON.stringify({ type: 'error', code: 'AI_UNCONFIGURED', message: '未配置 AI 服务' })}\n\n`);
138
+ res.end();
139
+ } else {
140
+ res.status(503).json({ error: 'AI_UNCONFIGURED' });
141
+ }
142
+ return;
143
+ }
144
+ console.error('[ai/stream]', e);
145
+ if (res.headersSent) {
146
+ res.write(`data: ${JSON.stringify({ type: 'error', code: 'AI_FAILED', message: 'AI 解读暂未应机' })}\n\n`);
147
+ res.end();
148
+ } else {
149
+ res.status(502).json({ error: 'AI_FAILED' });
150
+ }
151
+ }
152
+ });
153
+
154
+
155
+ // 报告解析:按两套 Schema 归一化(命盘类/占问类),字段缺失兜底
156
+ interface RawReading { summary: string; keyPoints: string[] }
157
+
158
+ // ─── Step1 盘面解析缓存 ───
159
+ // 同一 divineId + 同一问题复用同一份盘面解析:多次解读的输入一致 → 输出不再互相矛盾
160
+ // (Step1 每次调用结果都不同是「一会严格一会宽松」的主因之一)
161
+ const step1Cache = new Map<string, string>();
162
+ const STEP1_CACHE_MAX = 300;
163
+ async function getStep1(divineId: string, artId: string, question: string, resultRaw: unknown, profile: unknown): Promise<string> {
164
+ const key = divineId + '|' + (question || '');
165
+ const hit = step1Cache.get(key);
166
+ if (hit) return hit;
167
+ try {
168
+ const text = await chatOnce(buildStep1Messages(artId, question, resultRaw, profile));
169
+ if (step1Cache.size > STEP1_CACHE_MAX) step1Cache.clear();
170
+ step1Cache.set(key, text);
171
+ return text;
172
+ } catch (e: any) {
173
+ console.warn('[ai] Step1 盘面解析失败,降级为单步模式:', e.message);
174
+ return '';
175
+ }
176
+ }
177
+
178
+ export interface AIReportNormalized {
179
+ kind: 'mingpan' | 'zhanwen';
180
+ title: string;
181
+ overview: string;
182
+ rawReading: RawReading;
183
+ character?: { summary: string; traits: { name: string; desc: string }[]; coreConflict?: string; emotion?: string };
184
+ family?: { background?: string; parents?: string; imprint?: string };
185
+ mind?: { action?: string; pattern?: string; growth?: string };
186
+ lifeStages?: { stage: string; age: string; summary: string }[];
187
+ career?: { summary: string; direction: string; advice: string };
188
+ love?: { summary: string; advice: string };
189
+ wealth?: { summary: string; advice: string };
190
+ health?: { summary: string; advice: string };
191
+ situation?: string;
192
+ trend?: string;
193
+ timing?: string;
194
+ advice: string;
195
+ conclusion: string;
196
+ disclaimer: string;
197
+ suitability?: { suitable: boolean | 'partial'; note: string; suggestion: string };
198
+ quality?: 'ok' | 'poor';
199
+ }
200
+
201
+ // 后端文本清洗:剥离 Markdown 格式标记(**、*、#、>、- 等),保留纯文本
202
+ function stripMd(s: string): string {
203
+ return s
204
+ .replace(/\*\*/g, '')
205
+ .replace(/^#{1,6}\s+/gm, '')
206
+ .replace(/^>\s*/gm, '')
207
+ .replace(/^[-*+]\s+/gm, '')
208
+ .replace(/^\d+\.\s+/gm, '')
209
+ .replace(/`([^`]*)`/g, '$1')
210
+ .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
211
+ .replace(/\s{2,}/g, ' ')
212
+ .trim();
213
+ }
214
+
215
+ function str(v: unknown, fb = ''): string {
216
+ return v === undefined || v === null ? fb : stripMd(String(v));
217
+ }
218
+
219
+ // 严格判断输出是否满足 Schema 关键要求(命盘:character+lifeStages 或 career/love/wealth;占问:situation/trend/timing)
220
+ function reportMeetsSchema(obj: any, kind: 'mingpan' | 'zhanwen'): boolean {
221
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return false;
222
+ if (kind === 'mingpan') {
223
+ const ch = obj.character;
224
+ const okChar = ch && (typeof ch.summary === 'string' && ch.summary.length > 10 || Array.isArray(ch.traits) && ch.traits.length > 0);
225
+ const okLife = Array.isArray(obj.lifeStages) && obj.lifeStages.length > 0;
226
+ const okDomains = obj.career || obj.love || obj.wealth;
227
+ return okChar && okLife && okDomains;
228
+ }
229
+ return typeof obj.situation === 'string' && obj.situation.length > 10
230
+ && typeof obj.trend === 'string' && obj.trend.length > 10
231
+ && typeof obj.timing === 'string' && obj.timing.length > 10;
232
+ }
233
+ function stripJson(text: string): string {
234
+ const t = text.trim().replace(/^```json\s*/i, '').replace(/\s*```$/, '');
235
+ return t;
236
+ }
237
+
238
+ export function parseReport(text: string, kind: 'mingpan' | 'zhanwen'): AIReportNormalized {
239
+ const cleaned = text.trim().replace(/^```json\s*/i, '').replace(/```\s*$/, '');
240
+ let obj: any = {};
241
+ try { obj = JSON.parse(cleaned); } catch {
242
+ return { kind, title: '观微解读报告', overview: cleaned, rawReading: { summary: cleaned, keyPoints: [] }, advice: '', conclusion: '', disclaimer: '凡占问所得,仅供修身养性、怡情遣兴之用,不构成决策依据。' };
243
+ }
244
+ if (Array.isArray(obj.chapters) && obj.chapters.length && !obj.rawReading) {
245
+ const body = obj.chapters.map((ch: any) => '【' + (ch.skill || '') + '】' + str(ch.content)).join('\n');
246
+ return {
247
+ kind,
248
+ title: str(obj.title, '观微解读报告'),
249
+ overview: str(obj.overview) || body.slice(0, 200),
250
+ rawReading: { summary: body, keyPoints: [] },
251
+ advice: str(obj.conclusion, ''),
252
+ conclusion: str(obj.conclusion, ''),
253
+ disclaimer: str(obj.disclaimer, '凡占问所得,仅供修身养性、怡情遣兴之用,不构成决策依据。'),
254
+ };
255
+ }
256
+ const raw = (obj.rawReading || {}) as any;
257
+ const out: AIReportNormalized = {
258
+ kind,
259
+ title: str(obj.title, '观微解读报告'),
260
+ overview: str(obj.overview),
261
+ rawReading: { summary: str(raw.summary), keyPoints: Array.isArray(raw.keyPoints) ? raw.keyPoints.map((k: unknown) => String(k)) : [] },
262
+ advice: str(obj.advice),
263
+ conclusion: str(obj.conclusion),
264
+ disclaimer: str(obj.disclaimer, '凡占问所得,仅供修身养性、怡情遣兴之用,不构成决策依据。'),
265
+ };
266
+ if (kind === 'mingpan') {
267
+ const ch = (obj.character || {}) as any;
268
+ out.character = {
269
+ summary: str(ch.summary),
270
+ traits: Array.isArray(ch.traits) ? ch.traits.filter((t: any) => t && t.name).map((t: any) => ({ name: String(t.name), desc: str(t.desc) })) : [],
271
+ coreConflict: str(ch.coreConflict),
272
+ emotion: str(ch.emotion),
273
+ };
274
+ const fam = (obj.family || {}) as any;
275
+ out.family = { background: str(fam.background), parents: str(fam.parents), imprint: str(fam.imprint) };
276
+ const mind = (obj.mind || {}) as any;
277
+ out.mind = { action: str(mind.action), pattern: str(mind.pattern), growth: str(mind.growth) };
278
+ out.lifeStages = Array.isArray(obj.lifeStages) ? obj.lifeStages.filter((s: any) => s && s.stage).map((s: any) => ({ stage: String(s.stage), age: str(s.age), summary: str(s.summary) })) : [];
279
+ const career = (obj.career || {}) as any;
280
+ out.career = { summary: str(career.summary), direction: str(career.direction), advice: str(career.advice) };
281
+ const love = (obj.love || {}) as any;
282
+ out.love = { summary: str(love.summary), advice: str(love.advice) };
283
+ const wealth = (obj.wealth || {}) as any;
284
+ const health = (obj.health || {}) as any;
285
+ out.health = { summary: str(health.summary), advice: str(health.advice) };
286
+ out.wealth = { summary: str(wealth.summary), advice: str(wealth.advice) };
287
+ } else {
288
+ out.situation = str(obj.situation);
289
+ out.trend = str(obj.trend);
290
+ out.timing = str(obj.timing);
291
+ }
292
+ // 兜底:AI 返回的自定义字段(如 minggong/dayun/liunian)转为通用章节,保证任何输出都有结构化展示
293
+ const KNOWN = new Set(['title', 'overview', 'rawReading', 'character', 'family', 'mind', 'lifeStages', 'career', 'love', 'wealth', 'health', 'advice', 'conclusion', 'disclaimer', 'suitability', 'situation', 'trend', 'timing', 'chapters', 'sections', 'kind']);
294
+ const extra: { skill: string; content: string }[] = [];
295
+ Object.keys(obj).forEach((k: string) => {
296
+ if (KNOWN.has(k)) return;
297
+ const v = obj[k];
298
+ if (typeof v === 'string' && v.length > 10) extra.push({ skill: k, content: v });
299
+ });
300
+ if (extra.length) (out as any).extraSections = extra;
301
+ const sui = (obj.suitability || {}) as any;
302
+ if (obj.suitability) {
303
+ out.suitability = {
304
+ suitable: sui.suitable === true || sui.suitable === 'partial' ? sui.suitable : false,
305
+ note: str(sui.note),
306
+ suggestion: str(sui.suggestion),
307
+ };
308
+ }
309
+ // 质量评分:关键字段缺失/为空 -> poor(前端据此提示本次解读可能不完整)
310
+ let score = 0;
311
+ const has = (v: unknown) => typeof v === 'string' && stripMd(v).length > 10;
312
+ if (has(out.overview)) score += 15;
313
+ if (out.rawReading && (has(out.rawReading.summary) || (out.rawReading.keyPoints?.length || 0) > 0)) score += 15;
314
+ if (kind === 'mingpan') {
315
+ if (out.character && (has(out.character.summary) || (out.character.traits?.length || 0) > 0)) score += 20;
316
+ if (out.lifeStages && out.lifeStages.length > 0 && out.lifeStages.some(s => has(s.summary))) score += 20;
317
+ if (has(out.career?.summary) || has(out.love?.summary) || has(out.wealth?.summary)) score += 15;
318
+ if (has(out.health?.summary)) score += 5;
319
+ // 模板要求的扩展维度(原生家庭 / 心智模式):AI 已认真展开的内容不应被丢弃或判劣
320
+ if (out.family && (has(out.family.background) || has(out.family.parents) || has(out.family.imprint))) score += 5;
321
+ if (out.mind && (has(out.mind.action) || has(out.mind.pattern) || has(out.mind.growth))) score += 5;
322
+ } else {
323
+ if (has(out.situation)) score += 20;
324
+ if (has(out.trend)) score += 20;
325
+ if (has(out.timing)) score += 15;
326
+ }
327
+ if (has(out.advice)) score += 10;
328
+ if (has(out.conclusion)) score += 5;
329
+ out.quality = score >= 60 ? 'ok' : 'poor';
330
+ return out;
331
+ }
332
+ export function parseSections(text: string): { title: string; content: string }[] {
333
+ const cleaned = text.trim().replace(/^```json\s*/i, '').replace(/\s*```$/, '');
334
+ try {
335
+ const obj = JSON.parse(cleaned);
336
+ if (Array.isArray(obj.sections)) {
337
+ return obj.sections.filter((s: any) => s && s.title && s.content).map((s: any) => ({ title: String(s.title), content: String(s.content) }));
338
+ }
339
+ // 结构化报告 JSON → 多章节(兼容旧 JS 不读 report 字段时也能分节展示)
340
+ const out: { title: string; content: string }[] = [];
341
+ const str = (v: unknown): string => (typeof v === 'string' ? v : v ? JSON.stringify(v) : '');
342
+ const push = (t: string, v: unknown) => { const s = str(v); if (s && s.length > 2) out.push({ title: t, content: s }); };
343
+ push('总览', obj.overview);
344
+ if (obj.rawReading) push('原始解读', typeof obj.rawReading === 'string' ? obj.rawReading : obj.rawReading.summary);
345
+ if (obj.character) {
346
+ const ch = obj.character;
347
+ const traits = Array.isArray(ch.traits) ? ch.traits.map((t: any) => t?.name + ':' + (t?.desc || '')).join('\n') : '';
348
+ push('性格', ch.summary + (traits ? '\n' + traits : ''));
349
+ }
350
+ if (obj.family) {
351
+ const fam = obj.family;
352
+ const parts = [fam.background && '家境与氛围:' + fam.background, fam.parents && '父母关系:' + fam.parents, fam.imprint && '家庭印记:' + fam.imprint].filter(Boolean);
353
+ push('原生家庭', parts.join('\n\n'));
354
+ }
355
+ if (obj.mind) {
356
+ const mind = obj.mind;
357
+ const parts = [mind.action && '行动力与坚持:' + mind.action, mind.pattern && '行为循环:' + mind.pattern, mind.growth && '成长方向:' + mind.growth].filter(Boolean);
358
+ push('心智与行动模式', parts.join('\n\n'));
359
+ }
360
+ if (Array.isArray(obj.lifeStages)) push('人生阶段', obj.lifeStages.map((s: any) => '【' + s.stage + (s.age ? '(' + s.age + ')' : '') + '】' + s.summary).join('\n'));
361
+ if (obj.career) push('事业', (obj.career.summary || '') + (obj.career.direction ? '\n方向:' + obj.career.direction : '') + (obj.career.advice ? '\n建议:' + obj.career.advice : ''));
362
+ if (obj.love) push('爱情', (obj.love.summary || '') + (obj.love.advice ? '\n建议:' + obj.love.advice : ''));
363
+ if (obj.wealth) push('财富', (obj.wealth.summary || '') + (obj.wealth.advice ? '\n建议:' + obj.wealth.advice : ''));
364
+ push('现状', obj.situation);
365
+ push('趋势', obj.trend);
366
+ push('时机', obj.timing);
367
+ push('建议', obj.advice);
368
+ push('结语', obj.conclusion);
369
+ push('声明', obj.disclaimer);
370
+ if (out.length) return out;
371
+ } catch { /* 非 JSON,走散文兜底 */ }
372
+ // 兜底:整段为一节
373
+ return [{ title: 'AI 参详', content: cleaned || 'AI 未返回内容' }];
374
+ }
375
+
376
+ export default router;
@@ -0,0 +1,140 @@
1
+ // 排盘路由:登录用户起占 → 后端引擎计算 → SQLite 入库 → 返回 resultRaw + display
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';
13
+ import { createDivination, listDivinations, getDivination, deleteDivination } from '../services/divineStore.js';
14
+ import fs from 'fs';
15
+ import path from 'path';
16
+ import { fileURLToPath } from 'url';
17
+
18
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
19
+ const DB_FILE = path.join(__dirname, '..', 'data', 'db.json');
20
+
21
+ const router = Router();
22
+ const MINGPAN_ARTS = ['bazi', 'ziwei', 'astrology'];
23
+
24
+ // 宽松建档:前端本地注册的用户在此同步建档(与 users.ts upsert 同策略)
25
+ function ensureUser(username: string): boolean {
26
+ if (!username || typeof username !== 'string') return false;
27
+ try {
28
+ const db = JSON.parse(fs.readFileSync(DB_FILE, 'utf-8'));
29
+ if (db.users.some((u: any) => u.username === username)) return true;
30
+ db.users.push({ username, passHash: '', createdAt: Date.now(), profile: {}, samples: [], records: [] });
31
+ fs.writeFileSync(DB_FILE, JSON.stringify(db, null, 2));
32
+ console.log('[divine] 自动建档:', username);
33
+ return true;
34
+ } catch { return false; }
35
+ }
36
+
37
+ // POST /api/divine —— 起占入库
38
+ router.post('/', (req, res) => {
39
+ const { artId, inputs, profile, question, username, profileId } = req.body || {};
40
+ // 游客不允许
41
+ if (!ensureUser(username)) {
42
+ return res.status(401).json({ error: 'UNAUTHORIZED', message: '请先入馆(登录)再起占' });
43
+ }
44
+ if (!artId || !inputs) return res.status(400).json({ error: '缺少必要参数' });
45
+
46
+ let resultRaw: unknown;
47
+ try {
48
+ switch (artId) {
49
+ case 'bazi': {
50
+ const i = inputs as any;
51
+ resultRaw = baziCalc({ y: i.y, m: i.m, d: i.d, hourIndex: i.hourIndex, time: i.time, gender: i.gender, location: i.location });
52
+ break;
53
+ }
54
+ case 'ziwei': {
55
+ const i = inputs as any;
56
+ 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 });
57
+ break;
58
+ }
59
+ case 'astrology': {
60
+ const i = inputs as any;
61
+ resultRaw = astrologyCalc(i.y, i.m, i.d, i.hour || 0, i.min || 0, i.lng, i.lat);
62
+ break;
63
+ }
64
+ case 'qimen': {
65
+ resultRaw = qimenCalc({ datetime: (inputs as any).datetime || new Date() });
66
+ break;
67
+ }
68
+ case 'meihua': {
69
+ const i = inputs as any;
70
+ resultRaw = meihuaCalc({ mode: i.mode || 'time', n1: i.n1, n2: i.n2, n3: i.n3, now: i.now ? new Date(i.now) : undefined });
71
+ break;
72
+ }
73
+ case 'liuyao': {
74
+ const now = new Date();
75
+ resultRaw = liuyaoCalc(undefined, { y: now.getFullYear(), m: now.getMonth() + 1, d: now.getDate() });
76
+ break;
77
+ }
78
+ case 'liuren': {
79
+ resultRaw = liurenCalc((inputs as any).datetime || new Date());
80
+ break;
81
+ }
82
+ case 'xiaoliuren': {
83
+ const i = inputs as any;
84
+ resultRaw = xiaoliurenCalc(i.mode || 'time', i.m, i.d, i.h, i.n1, i.n2, i.n3);
85
+ break;
86
+ }
87
+ case 'tarot': {
88
+ const i = inputs as any;
89
+ const cards = tarotDraw(Math.max(1, i.n || 3));
90
+ const spread = allTarotSpreads().find((s: any) => s.id === i.spread) || allTarotSpreads()[0];
91
+ resultRaw = { spread: spread || { id: 'three', name: '圣三角', description: '', positions: [] }, cards };
92
+ break;
93
+ }
94
+ default:
95
+ return res.status(400).json({ error: '术无此名' });
96
+ }
97
+ } catch (e: any) {
98
+ console.error('[divine] 推演异常:', e);
99
+ return res.status(500).json({ error: 'DIVINE_FAILED', message: '推演未应机' });
100
+ }
101
+
102
+ const kind = MINGPAN_ARTS.includes(artId) ? 'mingpan' : 'zhanwen';
103
+ const rec = createDivination({
104
+ username, artId, kind,
105
+ question: question || undefined,
106
+ profileId: profileId || 'main',
107
+ profile: profile || undefined,
108
+ params: inputs,
109
+ resultRaw,
110
+ });
111
+ res.json({ ok: true, divineId: rec.id, resultRaw, display: rec.display });
112
+ });
113
+
114
+ // GET /api/divine?username=&page=&pageSize= —— 占卜历史
115
+ router.get('/', (req, res) => {
116
+ const username = String(req.query.username || '');
117
+ const page = Number(req.query.page || 1);
118
+ const pageSize = Number(req.query.pageSize || 20);
119
+ if (!ensureUser(username)) return res.status(401).json({ error: 'UNAUTHORIZED' });
120
+ res.json(listDivinations(username, page, pageSize, String(req.query.profileId || '')));
121
+ });
122
+
123
+ // GET /api/divine/:id —— 详情(排盘 + AI 报告)
124
+ router.get('/:id', (req, res) => {
125
+ const rec = getDivination(req.params.id);
126
+ if (!rec) return res.status(404).json({ error: 'DIVINE_NOT_FOUND' });
127
+ // 归属校验:query username 必须与记录一致
128
+ if (req.query.username !== rec.username) return res.status(403).json({ error: 'FORBIDDEN' });
129
+ res.json({ ...rec, resultRaw: rec.resultRaw, display: rec.display, report: rec.report || null });
130
+ });
131
+
132
+ // DELETE /api/divine/:id?username= —— 删除(校验归属)
133
+ router.delete('/:id', (req, res) => {
134
+ const username = String(req.query.username || '');
135
+ const ok = deleteDivination(req.params.id, username);
136
+ if (!ok) return res.status(404).json({ error: 'DIVINE_NOT_FOUND' });
137
+ res.json({ ok: true });
138
+ });
139
+
140
+ export default router;
@@ -0,0 +1,40 @@
1
+ // 时辰反推路由:POST /api/hour-infer
2
+ import { Router } from 'express';
3
+ import { inferHour, type HourInferEvent } from '../services/hourInference.js';
4
+
5
+ const router = Router();
6
+
7
+ router.post('/hour-infer', (req, res) => {
8
+ const { y, m, d, gender, location, candidates, events } = req.body || {};
9
+ if (!y || !m || !d || !gender) {
10
+ return res.status(400).json({ error: '缺少必要参数(y/m/d/gender)' });
11
+ }
12
+ if (!Array.isArray(events) || events.length === 0) {
13
+ return res.status(400).json({ error: '请至少提供一条关键人生事件' });
14
+ }
15
+ const cleanEvents: HourInferEvent[] = events
16
+ .map((e: any) => ({
17
+ year: Number(e?.year),
18
+ text: String(e?.text || '').slice(0, 60),
19
+ type: ['health', 'love', 'job', 'family', 'money', 'study', 'move', 'breakup'].includes(e?.type) ? e.type : undefined,
20
+ }))
21
+ .filter((e: HourInferEvent) => Number.isFinite(e.year) && e.year >= 1900 && e.year <= 2100 && e.text);
22
+ if (cleanEvents.length === 0) {
23
+ return res.status(400).json({ error: '事件格式不正确(需 年份 + 描述)' });
24
+ }
25
+ try {
26
+ const result = inferHour({
27
+ y: Number(y), m: Number(m), d: Number(d),
28
+ gender: gender === '女' ? '女' : '男',
29
+ location: location || null,
30
+ candidates: Array.isArray(candidates) ? candidates.map(Number).filter(n => Number.isInteger(n) && n >= 0 && n <= 11) : undefined,
31
+ events: cleanEvents,
32
+ });
33
+ res.json(result);
34
+ } catch (e: any) {
35
+ console.error('[hour-infer]', e);
36
+ res.status(500).json({ error: 'INFER_FAILED', message: '时辰推演未应机' });
37
+ }
38
+ });
39
+
40
+ export default router;