diff --git a/.trae/skills/acmetone-design-specs/SKILL.md b/.trae/skills/acmetone-design-specs/SKILL.md
new file mode 100644
index 0000000..d7c1f9c
--- /dev/null
+++ b/.trae/skills/acmetone-design-specs/SKILL.md
@@ -0,0 +1,53 @@
+---
+name: acmetone-design-specs
+description: Acmetone项目专属设计规范(颜色、字体、特色UI组件)。在创建新页面、组件或修改样式时务必调用此技能,以保证UI风格一致和最小化改动。
+---
+
+# Acmetone 设计规范 (Design Specifications)
+
+在为 Acmetone 项目开发新功能、新增页面或修改 UI 组件时,请务必遵循以下设计规范,确保“基于最小改动”并“尽量在原基础上复用”。
+
+## 1. 颜色规范 (Color Palette)
+- **基础背景**:主要为纯白 (`#ffffff`),特殊页面(如 AirplanMode)使用深灰 (`#333333`)。
+- **文本颜色**:
+ - 主文本:黑色 (`#000000`) 或 `#111111`
+ - 次要/辅助文本:各级灰色(`#555555`, `#666666`, `#888888`)
+- **边框与装饰线**:浅灰色(`#cccccc`, `#dddddd`)
+
+## 2. 字体与排版 (Typography)
+- **基础字体**:系统默认无衬线字体栈 `var(--font-family)` (`-apple-system, BlinkMacSystemFont, "SF Pro Display", "PingFang SC" ...`)。
+- **特效/高亮字体**:`DotGothic16`(点阵字体),常用于超大标题或特殊高亮,自带复古科技感。
+- **典型字号层级**:
+ - 超大标题 (Hero Title):`72px`
+ - 模块标题 (Section Title):`48px` / `36px`
+ - 正文描述 (Body Text):`16px` / `14px`
+ - 小标签/附加信息 (Labels):`13px` / `12px`(配合 `letter-spacing: 1px` 或 `1.5px`)
+
+## 3. 特色 UI 元素 (Signature UI Elements)
+- **科技感边角 (Tech Brackets / Corner Crosses)**:
+ - 项目显著特征。在按钮 (`.btn-container`)、图标包裹盒 (`.ms-icon-box`) 边缘使用四个直角边框进行装饰。
+ - DOM 结构参考:包含四个 ``、`` 等元素。
+ - 交互:Hover 时伴随边角向外扩散的动效 (`transform: translate(...)`)。
+- **按钮体系**:
+ - `light` 模式:透明背景 + 黑字 (`.tech-btn.light`)
+ - `dark` 模式:黑底白字 + 柔和底阴影 (`box-shadow: 0 15px 35px rgba(0,0,0,0.15)`) (`.tech-btn.dark`)
+- **高亮十字星 (Highlight Crosses)**:使用 `.highlight` 配合伪元素或 `.cross-tl`, `.cross-br` 实现角落的十字线装饰。
+
+## 4. 布局与视觉动效 (Layout & Effects)
+- **环境光效 (Ambient Glow)**:页面顶部常驻半透明的径向渐变光效 (`.ambient-glow`)。
+- **层级管理 (Z-Index)**:
+ - 底层粒子画布 `#particleCanvas`:`z-index: 1`,`pointer-events: none`。
+ - 顶层导航:`z-index: 100`。
+- **微交互动效**:
+ - 图标上下缓动悬浮 (`@keyframes floatIcon`)。
+ - 按钮内箭头的水平位移动效 (`.btn-container:hover .btn-arrow { transform: translateX(4px); }`)。
+
+## 5. 开发原则
+- **复用优先**:优先使用 `style.css` 中已有的类名(如 `.btn-container`, `.ms-icon-box`, `.flex-center`),避免重复写样式。
+- **Tailwind 结合**:项目已配置 Tailwind CSS 4.x,基础布局可使用 Tailwind 类名,但特色组件(如带边角的按钮)请复用自定义 CSS 类以保持一致性。
+
+## 6. 核心资源参考 (Core Resources)
+为了确保设计规范的严格执行,本 Skill 附带了项目核心的样式与配置资源。在需要深入了解具体样式实现或 CSS 变量时,请参考以下文件:
+- **全局样式与特色组件**:`resources/style.css` (包含了所有自定义的 UI 组件如 `.corner`, `.btn-container` 等的具体实现)
+- **Tailwind 配置**:`resources/tailwind.config.js`
+- **视觉展示网站**:`website/index.html` (包含完整设计系统视觉指南与交互演示)
\ No newline at end of file
diff --git a/OnceLove/oncelove-graphrag/api/src/controllers/graphrag.controller.js b/OnceLove/oncelove-graphrag/api/src/controllers/graphrag.controller.js
index 40a2690..ebe7529 100644
--- a/OnceLove/oncelove-graphrag/api/src/controllers/graphrag.controller.js
+++ b/OnceLove/oncelove-graphrag/api/src/controllers/graphrag.controller.js
@@ -17,18 +17,97 @@ const sendServiceResult = async (reply, action) => {
/**
* GraphRAG 控制器:负责请求转发与响应封装。
*/
-export const createGraphRagController = (service) => ({
+export const createGraphRagController = (service, multiAgentService) => ({
health: async (_request, reply) => reply.send({ ok: true }),
ready: async (_request, reply) => sendServiceResult(reply, () => service.ready()),
bootstrap: async (_request, reply) => sendServiceResult(reply, () => service.bootstrap()),
+ listUsers: async (request, reply) => sendServiceResult(reply, () => service.listUsers(request.query.limit || 200)),
getGraphStats: async (request, reply) => sendServiceResult(reply, () => service.getGraphStats(request.query.userId || 'default')),
ingest: async (request, reply) => sendServiceResult(reply, () => service.ingest(request.body)),
queryTimeline: async (request, reply) =>
sendServiceResult(reply, () => service.queryTimeline(request.body)),
queryGraphRag: async (request, reply) =>
sendServiceResult(reply, () => service.queryGraphRag(request.body)),
+ queryGraphRagMultiRound: async (request, reply) =>
+ sendServiceResult(reply, () => service.queryGraphRagMultiRound(request.body)),
+ queryGraphRagMultiRoundStream: async (request, reply) => {
+ reply.hijack();
+ const raw = reply.raw;
+ const requestOrigin = request.headers.origin;
+ raw.setHeader('Access-Control-Allow-Origin', requestOrigin || '*');
+ raw.setHeader('Vary', 'Origin');
+ raw.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
+ raw.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
+ raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
+ raw.setHeader('Cache-Control', 'no-cache, no-transform');
+ raw.setHeader('Connection', 'keep-alive');
+ raw.setHeader('X-Accel-Buffering', 'no');
+ raw.flushHeaders?.();
+ const push = (event, data) => {
+ raw.write(`event: ${event}\n`);
+ raw.write(`data: ${JSON.stringify(data)}\n\n`);
+ };
+ try {
+ const result = await service.queryGraphRagMultiRound(request.body, {
+ onProgress: (event) => push('progress', event)
+ });
+ push('done', result);
+ } catch (error) {
+ push('error', {
+ ok: false,
+ statusCode: Number(error?.statusCode) || 500,
+ message: error?.message || 'internal error'
+ });
+ } finally {
+ raw.end();
+ }
+ },
analyzeAndIngest: async (request, reply) =>
sendServiceResult(reply, () => service.incrementalUpdate(request.body.text, request.body.userId || 'default')),
+ reindexUserVectors: async (request, reply) =>
+ sendServiceResult(reply, () => service.reindexUserVectors({
+ userId: request.body.userId || 'default',
+ limit: request.body.limit
+ })),
+ analyzeAndIngestStream: async (request, reply) => {
+ reply.hijack();
+ const raw = reply.raw;
+ const requestOrigin = request.headers.origin;
+ raw.setHeader('Access-Control-Allow-Origin', requestOrigin || '*');
+ raw.setHeader('Vary', 'Origin');
+ raw.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
+ raw.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
+ raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
+ raw.setHeader('Cache-Control', 'no-cache, no-transform');
+ raw.setHeader('Connection', 'keep-alive');
+ raw.setHeader('X-Accel-Buffering', 'no');
+ raw.flushHeaders?.();
+ const push = (event, data) => {
+ raw.write(`event: ${event}\n`);
+ raw.write(`data: ${JSON.stringify(data)}\n\n`);
+ };
+ try {
+ push('progress', { stage: 'start' });
+ const result = await service.incrementalUpdate(
+ request.body.text,
+ request.body.userId || 'default',
+ {
+ parallelism: request.body.parallelism,
+ expertReview: request.body.expertReview,
+ onProgress: (event) => push('progress', event)
+ }
+ );
+ push('done', result);
+ } catch (error) {
+ push('error', {
+ ok: false,
+ statusCode: Number(error?.statusCode) || 500,
+ message: error?.message || 'internal error'
+ });
+ } finally {
+ raw.end();
+ }
+ },
queryHistory: async (request, reply) =>
sendServiceResult(reply, () => service.queryRelationshipHistory(
request.body.userId || 'default',
@@ -36,5 +115,9 @@ export const createGraphRagController = (service) => ({
request.body.limit || 20
)),
getAdvice: async (request, reply) =>
- sendServiceResult(reply, () => service.getRelationshipAdvice(request.body.userId || 'default'))
+ sendServiceResult(reply, () => service.getRelationshipAdvice(request.body.userId || 'default')),
+ decomposeMultiAgentTask: async (request, reply) =>
+ sendServiceResult(reply, () => multiAgentService.decomposeTask(request.body)),
+ executeMultiAgentTask: async (request, reply) =>
+ sendServiceResult(reply, () => multiAgentService.executeTaskWorkflow(request.body))
});
diff --git a/OnceLove/oncelove-graphrag/api/src/routes/graphrag.route.js b/OnceLove/oncelove-graphrag/api/src/routes/graphrag.route.js
index 96fb0d4..01e017b 100644
--- a/OnceLove/oncelove-graphrag/api/src/routes/graphrag.route.js
+++ b/OnceLove/oncelove-graphrag/api/src/routes/graphrag.route.js
@@ -48,6 +48,24 @@ export const registerGraphRagRoutes = async (app, controller) => {
* description: 初始化成功
*/
app.post("/bootstrap", controller.bootstrap);
+ /**
+ * @openapi
+ * /users:
+ * get:
+ * tags:
+ * - GraphRAG
+ * summary: 获取用户管理列表(含各用户图谱统计)
+ * parameters:
+ * - in: query
+ * name: limit
+ * schema:
+ * type: integer
+ * required: false
+ * responses:
+ * 200:
+ * description: 用户列表
+ */
+ app.get("/users", controller.listUsers);
/**
* @openapi
* /graph/stats:
@@ -244,7 +262,97 @@ export const registerGraphRagRoutes = async (app, controller) => {
* description: 参数错误
*/
app.post("/query/graphrag", controller.queryGraphRag);
+ /**
+ * @openapi
+ * /query/graphrag/multi:
+ * post:
+ * tags:
+ * - GraphRAG
+ * summary: 多轮 GraphRAG 检索 + 关系整理 + 终审判别
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * userId:
+ * type: string
+ * query_text:
+ * type: string
+ * top_k:
+ * type: integer
+ * timeline_limit:
+ * type: integer
+ * max_rounds:
+ * type: integer
+ * responses:
+ * 200:
+ * description: 查询成功
+ * 400:
+ * description: 参数错误
+ */
+ app.post("/query/graphrag/multi", controller.queryGraphRagMultiRound);
+ /**
+ * @openapi
+ * /query/graphrag/multi/stream:
+ * post:
+ * tags:
+ * - GraphRAG
+ * summary: 多轮 GraphRAG 流式过程输出(SSE)
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * userId:
+ * type: string
+ * query_text:
+ * type: string
+ * top_k:
+ * type: integer
+ * timeline_limit:
+ * type: integer
+ * max_rounds:
+ * type: integer
+ * responses:
+ * 200:
+ * description: 流式输出 progress/done/error 事件
+ */
+ app.post("/query/graphrag/multi/stream", controller.queryGraphRagMultiRoundStream);
app.post("/analyze", controller.analyzeAndIngest);
+ app.post("/vectors/reindex", controller.reindexUserVectors);
+ /**
+ * @openapi
+ * /analyze/stream:
+ * post:
+ * tags:
+ * - GraphRAG
+ * summary: 流式分析并增量入图(SSE)
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * text:
+ * type: string
+ * userId:
+ * type: string
+ * parallelism:
+ * type: integer
+ * expertReview:
+ * type: boolean
+ * responses:
+ * 200:
+ * description: 流式输出 progress/done/error 事件
+ * 400:
+ * description: 参数错误
+ */
+ app.post("/analyze/stream", controller.analyzeAndIngestStream);
/**
* @openapi
@@ -294,4 +402,70 @@ export const registerGraphRagRoutes = async (app, controller) => {
* description: 建议生成成功
*/
app.post("/query/advice", controller.getAdvice);
+ /**
+ * @openapi
+ * /multi-agent/decompose:
+ * post:
+ * tags:
+ * - GraphRAG
+ * summary: 使用多 Agent 方式拆解复杂任务
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * required:
+ * - task
+ * properties:
+ * task:
+ * type: string
+ * context:
+ * type: string
+ * constraints:
+ * type: array
+ * items:
+ * type: string
+ * max_agents:
+ * type: integer
+ * responses:
+ * 200:
+ * description: 拆解成功
+ * 400:
+ * description: 参数错误
+ */
+ app.post("/multi-agent/decompose", controller.decomposeMultiAgentTask);
+ /**
+ * @openapi
+ * /multi-agent/execute:
+ * post:
+ * tags:
+ * - GraphRAG
+ * summary: 执行多 Agent 协作流程(自动拆解并串行执行)
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * task:
+ * type: string
+ * context:
+ * type: string
+ * constraints:
+ * type: array
+ * items:
+ * type: string
+ * max_agents:
+ * type: integer
+ * plan:
+ * type: object
+ * responses:
+ * 200:
+ * description: 执行成功
+ * 400:
+ * description: 参数错误
+ */
+ app.post("/multi-agent/execute", controller.executeMultiAgentTask);
};
diff --git a/OnceLove/oncelove-graphrag/api/src/server.js b/OnceLove/oncelove-graphrag/api/src/server.js
index a7144e2..02dddc0 100644
--- a/OnceLove/oncelove-graphrag/api/src/server.js
+++ b/OnceLove/oncelove-graphrag/api/src/server.js
@@ -5,7 +5,7 @@ import swaggerUi from "@fastify/swagger-ui";
import { env } from "./config/env.js";
import { createClients } from "./config/clients.js";
import { createSwaggerSpec } from "./config/swagger.js";
-import { EmbeddingService, RerankService, GraphRagService, LLMService } from "./services/index.js";
+import { EmbeddingService, RerankService, GraphRagService, LLMService, MultiAgentService } from "./services/index.js";
import { createGraphRagController } from "./controllers/index.js";
import { registerRoutes } from "./routes/index.js";
@@ -42,7 +42,11 @@ export const createServer = async () => {
llmService,
env
});
- const controller = createGraphRagController(service);
+ const multiAgentService = new MultiAgentService({
+ llmService,
+ logger: app.log
+ });
+ const controller = createGraphRagController(service, multiAgentService);
await registerRoutes(app, controller, env);
diff --git a/OnceLove/oncelove-graphrag/api/src/services/embedding.service.js b/OnceLove/oncelove-graphrag/api/src/services/embedding.service.js
index 47b9162..4bafe9a 100644
--- a/OnceLove/oncelove-graphrag/api/src/services/embedding.service.js
+++ b/OnceLove/oncelove-graphrag/api/src/services/embedding.service.js
@@ -10,6 +10,10 @@ export class EmbeddingService {
this.apiKey = env.EMBEDDING_API_KEY ?? "";
this.model = env.EMBEDDING_MODEL ?? "";
this.dimension = env.EMBEDDING_DIM;
+ this.fallbackModels = ["text-embedding-v3", "text-embedding-v2"];
+ this.endpoint = this.baseUrl.endsWith("/v1")
+ ? `${this.baseUrl}/embeddings`
+ : `${this.baseUrl}/v1/embeddings`;
}
isEnabled() {
@@ -26,28 +30,41 @@ export class EmbeddingService {
throw createHttpError(400, "embedding 输入文本不能为空");
}
- const response = await fetch(`${this.baseUrl}/v1/embeddings`, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- Authorization: `Bearer ${this.apiKey}`
- },
- body: JSON.stringify({
- model: this.model,
- input: cleaned
- })
- });
+ const candidates = [this.model, ...this.fallbackModels]
+ .map((item) => String(item || "").trim())
+ .filter((item, index, arr) => item && arr.indexOf(item) === index);
+ let lastErrorText = "";
+ for (const model of candidates) {
+ const response = await fetch(this.endpoint, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${this.apiKey}`
+ },
+ body: JSON.stringify({
+ model,
+ input: cleaned
+ })
+ });
- if (!response.ok) {
- const errorText = await response.text();
- throw createHttpError(response.status, `embedding 请求失败: ${errorText}`);
- }
+ if (!response.ok) {
+ const errorText = await response.text();
+ lastErrorText = errorText || lastErrorText;
+ const maybeModelIssue = (response.status === 400 || response.status === 404) && /model_not_supported|unsupported model/i.test(errorText || "");
+ if (maybeModelIssue) continue;
+ throw createHttpError(response.status, `embedding 请求失败: ${errorText}`);
+ }
- const data = await response.json();
- const vector = data?.data?.[0]?.embedding;
- if (!Array.isArray(vector) || vector.length !== this.dimension) {
- throw createHttpError(400, `embedding 维度异常,期望 ${this.dimension}`);
+ const data = await response.json();
+ const vector = data?.data?.[0]?.embedding;
+ if (!Array.isArray(vector) || vector.length !== this.dimension) {
+ throw createHttpError(400, `embedding 维度异常,期望 ${this.dimension}`);
+ }
+ if (model !== this.model) {
+ this.model = model;
+ }
+ return vector;
}
- return vector;
+ throw createHttpError(400, `embedding 请求失败: ${lastErrorText}`);
}
}
diff --git a/OnceLove/oncelove-graphrag/api/src/services/graphrag.service.js b/OnceLove/oncelove-graphrag/api/src/services/graphrag.service.js
index 988ee4a..0cee2c5 100644
--- a/OnceLove/oncelove-graphrag/api/src/services/graphrag.service.js
+++ b/OnceLove/oncelove-graphrag/api/src/services/graphrag.service.js
@@ -1,4 +1,5 @@
import neo4j from "neo4j-driver";
+import { createHash } from "node:crypto";
const toTimestamp = (value) => {
if (!value) return null;
@@ -24,8 +25,80 @@ const createHttpError = (statusCode, message) => {
return error;
};
-/**
- * GraphRAG 服务 - MiroFish 风格的知识图谱构建
+const toNumber = (value) => {
+ if (value == null) return null;
+ if (typeof value === "number") return value;
+ if (typeof value === "string") {
+ const num = Number(value);
+ return Number.isNaN(num) ? null : num;
+ }
+ if (typeof value.toNumber === "function") {
+ try {
+ return value.toNumber();
+ } catch {
+ return null;
+ }
+ }
+ const num = Number(value);
+ return Number.isNaN(num) ? null : num;
+};
+
+const normalizeGender = (value) => {
+ const raw = String(value || "").trim().toLowerCase();
+ if (!raw) return "unknown";
+ if (["male", "m", "man", "boy", "男", "男性", "男生"].includes(raw)) return "male";
+ if (["female", "f", "woman", "girl", "女", "女性", "女生"].includes(raw)) return "female";
+ return "unknown";
+};
+
+const CORRECTION_INTENT_RE = /(纠错|误会|说错|说反|搞错|澄清|撤回|更正|改口|并非|不是|不是真的|并没有|其实没有|冤枉|假消息|传言)/i;
+
+const normalizeSummaryPart = (value) => String(value || "").replace(/\s+/g, " ").trim();
+
+const mergeCompactSummary = (existingSummary, incomingSummary, options = {}) => {
+ const maxParts = Math.min(Math.max(Number(options.maxParts || 4), 1), 12);
+ const maxLen = Math.min(Math.max(Number(options.maxLen || 240), 40), 1000);
+ const seen = new Set();
+ const parts = [];
+ const appendPart = (part) => {
+ const normalized = normalizeSummaryPart(part);
+ if (!normalized) return;
+ const key = normalized.toLowerCase();
+ if (seen.has(key)) return;
+ seen.add(key);
+ parts.push(normalized);
+ };
+ String(existingSummary || "").split(/[||]/).forEach(appendPart);
+ String(incomingSummary || "").split(/[||]/).forEach(appendPart);
+ if (parts.length === 0) return "";
+ const compact = parts.slice(0, maxParts).join(" | ");
+ return compact.length > maxLen ? `${compact.slice(0, maxLen - 1)}…` : compact;
+};
+
+const tryParseJson = (rawText) => {
+ const text = String(rawText || "").trim();
+ if (!text) return null;
+ const candidates = [text];
+ const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
+ if (fenced?.[1]) candidates.push(fenced[1].trim());
+ const startIdx = text.indexOf("{");
+ const endIdx = text.lastIndexOf("}");
+ if (startIdx >= 0 && endIdx > startIdx) candidates.push(text.slice(startIdx, endIdx + 1));
+ for (const candidate of candidates) {
+ try {
+ return JSON.parse(candidate);
+ } catch {}
+ }
+ return null;
+};
+
+const toQdrantPointId = (seed) => {
+ const hex = createHash("sha1").update(String(seed || "")).digest("hex");
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
+};
+
+/*
+ * GraphRAG 服务 - 知识图谱构建
*/
export class GraphRagService {
constructor({ driver, qdrantClient, embeddingService, rerankService, llmService, env }) {
@@ -49,6 +122,1129 @@ export class GraphRagService {
}
}
+ async bootstrap() {
+ const session = this.driver.session();
+ const constraints = [
+ "CREATE CONSTRAINT person_id_unique IF NOT EXISTS FOR (p:Person) REQUIRE (p.id, p.user_id) IS UNIQUE",
+ "CREATE CONSTRAINT org_id_unique IF NOT EXISTS FOR (o:Organization) REQUIRE (o.id, o.user_id) IS UNIQUE",
+ "CREATE CONSTRAINT event_id_unique IF NOT EXISTS FOR (e:Event) REQUIRE (e.id, e.user_id) IS UNIQUE",
+ "CREATE CONSTRAINT topic_name_user_unique IF NOT EXISTS FOR (t:Topic) REQUIRE (t.name, t.user_id) IS UNIQUE"
+ ];
+ try {
+ for (const stmt of constraints) {
+ await session.run(stmt);
+ }
+ const collections = await this.qdrantClient.getCollections();
+ const exists = (collections?.collections || []).some((c) => c.name === this.collection);
+ if (!exists) {
+ await this.qdrantClient.createCollection(this.collection, {
+ vectors: { size: this.env.EMBEDDING_DIM, distance: "Cosine" }
+ });
+ }
+ try {
+ await this.qdrantClient.createPayloadIndex(this.collection, { field_name: "user_id", field_schema: "keyword" });
+ } catch {}
+ try {
+ await this.qdrantClient.createPayloadIndex(this.collection, { field_name: "occurred_at_ts", field_schema: "integer" });
+ } catch {}
+ return { ok: true, collection: this.collection };
+ } finally {
+ await session.close();
+ }
+ }
+
+ async ingest(payload = {}) {
+ const userId = payload.userId || "default";
+ const persons = Array.isArray(payload.persons) ? payload.persons : [];
+ const organizations = Array.isArray(payload.organizations) ? payload.organizations : [];
+ const events = Array.isArray(payload.events) ? payload.events : [];
+ const topics = Array.isArray(payload.topics) ? payload.topics : [];
+ const relations = Array.isArray(payload.relations) ? payload.relations : [];
+ const chunks = Array.isArray(payload.chunks) ? payload.chunks : [];
+
+ const session = this.driver.session();
+ const stats = {
+ created: { persons: 0, organizations: 0, events: 0, topics: 0, relations: 0, chunks: 0 },
+ updated: { persons: 0, organizations: 0 }
+ };
+ const personIdMap = new Map();
+ const orgIdMap = new Map();
+
+ try {
+ for (const person of persons) {
+ const result = await this._upsertPerson(session, person, userId);
+ if (result.created) stats.created.persons += 1;
+ if (result.updated) stats.updated.persons += 1;
+ if (person?.id) personIdMap.set(person.id, result.id);
+ }
+ for (const org of organizations) {
+ const result = await this._upsertOrganization(session, org, userId);
+ if (result.created) stats.created.organizations += 1;
+ if (result.updated) stats.updated.organizations += 1;
+ if (org?.id) orgIdMap.set(org.id, result.id);
+ }
+ for (const event of events) {
+ const normalizedEvent = {
+ ...event,
+ participants: (event.participants || []).map((pid) => personIdMap.get(pid) || orgIdMap.get(pid) || pid)
+ };
+ const created = await this._createEvent(session, normalizedEvent, userId);
+ if (created) stats.created.events += 1;
+ }
+ for (const topic of topics) {
+ const created = await this._createTopic(session, topic, userId);
+ if (created) stats.created.topics += 1;
+ }
+ for (const rel of relations) {
+ const normalizedRel = {
+ ...rel,
+ source: personIdMap.get(rel.source) || orgIdMap.get(rel.source) || rel.source,
+ target: personIdMap.get(rel.target) || orgIdMap.get(rel.target) || rel.target
+ };
+ const created = await this._createRelation(session, normalizedRel, userId);
+ if (created) stats.created.relations += 1;
+ }
+ } finally {
+ await session.close();
+ }
+
+ const points = [];
+ for (const [chunkIndex, chunk] of chunks.entries()) {
+ const vector = Array.isArray(chunk.vector) ? chunk.vector : await this.embeddingService.embed(chunk.text || "");
+ const payloadData = {
+ ...(chunk.payload || {}),
+ user_id: userId
+ };
+ const occurredAt = payloadData.occurred_at || chunk.occurred_at;
+ const occurredAtTs = toTimestamp(occurredAt);
+ if (occurredAtTs != null) payloadData.occurred_at_ts = occurredAtTs;
+ if (occurredAt) payloadData.occurred_at = normalizeOccurredAt(occurredAt);
+ points.push({
+ id: toQdrantPointId(`${userId}:chunk:${chunk.id || chunk.text || "empty"}:${chunkIndex}`),
+ vector,
+ payload: {
+ ...payloadData,
+ text: chunk.text || payloadData.text || ""
+ }
+ });
+ }
+ if (points.length > 0) {
+ await this.qdrantClient.upsert(this.collection, { points, wait: true });
+ stats.created.chunks += points.length;
+ }
+ return { ok: true, stats };
+ }
+
+ async reindexUserVectors(payload = {}) {
+ const userId = payload.userId || "default";
+ const limit = Math.min(Math.max(Number(payload.limit || 500), 1), 5000);
+ if (!this.embeddingService?.isEnabled?.()) {
+ throw createHttpError(400, "embedding 服务未配置,无法回填向量");
+ }
+ const session = this.driver.session();
+ let events = [];
+ try {
+ const result = await session.run(
+ `
+ MATCH (e:Event {user_id: $userId})
+ WHERE coalesce(e.status, 'active') <> 'invalidated'
+ OPTIONAL MATCH (p)-[:PARTICIPATES_IN]->(e)
+ OPTIONAL MATCH (e)-[:ABOUT]->(t:Topic)
+ WITH e, collect(DISTINCT coalesce(p.name, p.id)) AS participants, collect(DISTINCT t.name) AS topics
+ RETURN e.id AS id, e.type AS type, e.summary AS summary, e.occurred_at AS occurred_at, e.importance AS importance, participants, topics
+ ORDER BY e.occurred_at DESC
+ LIMIT $limit
+ `,
+ { userId, limit: neo4j.int(limit) }
+ );
+ events = result.records.map((r) => ({
+ id: r.get("id"),
+ type: r.get("type") || "general",
+ summary: r.get("summary") || "",
+ occurred_at: r.get("occurred_at")?.toString?.() || r.get("occurred_at"),
+ importance: toNumber(r.get("importance")) ?? 5,
+ participants: (r.get("participants") || []).filter(Boolean),
+ topics: (r.get("topics") || []).filter(Boolean)
+ }));
+ } finally {
+ await session.close();
+ }
+ if (!events.length) {
+ return { ok: true, userId, collection: this.collection, indexed: 0 };
+ }
+ const points = [];
+ for (const event of events) {
+ const text = [
+ `事件类型:${event.type}`,
+ `事件摘要:${event.summary || "无"}`,
+ `参与者:${(event.participants || []).join("、") || "无"}`,
+ `主题:${(event.topics || []).join("、") || "无"}`
+ ].join("\n");
+ const vector = await this.embeddingService.embed(text);
+ const occurredAt = event.occurred_at || null;
+ const occurredAtTs = toTimestamp(occurredAt);
+ points.push({
+ id: toQdrantPointId(`${userId}:event:${event.id}`),
+ vector,
+ payload: {
+ user_id: userId,
+ event_id: event.id,
+ type: event.type,
+ importance: event.importance,
+ participants: event.participants,
+ topics: event.topics,
+ occurred_at: occurredAt,
+ ...(occurredAtTs != null ? { occurred_at_ts: occurredAtTs } : {}),
+ text
+ }
+ });
+ }
+ await this.qdrantClient.upsert(this.collection, { points, wait: true });
+ return {
+ ok: true,
+ userId,
+ collection: this.collection,
+ indexed: points.length
+ };
+ }
+
+ async queryTimeline(payload = {}) {
+ const userId = payload.userId || "default";
+ const aId = payload.a_id;
+ const bId = payload.b_id;
+ const limit = Math.min(Math.max(Number(payload.limit || 20), 1), 100);
+ const start = payload.start ? normalizeOccurredAt(payload.start) : null;
+ const end = payload.end ? normalizeOccurredAt(payload.end) : null;
+ if (!aId || !bId) {
+ throw createHttpError(400, "a_id 和 b_id 不能为空");
+ }
+
+ const session = this.driver.session();
+ try {
+ const params = { userId, aId, bId, limit: neo4j.int(limit), start, end };
+ const result = await session.run(
+ `
+ MATCH (a {id: $aId, user_id: $userId})-[:PARTICIPATES_IN]->(e:Event)<-[:PARTICIPATES_IN]-(b {id: $bId, user_id: $userId})
+ WHERE coalesce(e.status, 'active') <> 'invalidated'
+ AND ($start IS NULL OR e.occurred_at >= datetime($start))
+ AND ($end IS NULL OR e.occurred_at <= datetime($end))
+ OPTIONAL MATCH (p)-[:PARTICIPATES_IN]->(e)
+ WITH e, collect(DISTINCT coalesce(p.id, p.name)) AS participants
+ RETURN e.id AS id, e.type AS type, e.summary AS summary, e.occurred_at AS occurred_at, e.importance AS importance, participants
+ ORDER BY e.occurred_at DESC
+ LIMIT $limit
+ `,
+ params
+ );
+ const events = result.records.map((r) => ({
+ id: r.get("id"),
+ type: r.get("type"),
+ summary: r.get("summary"),
+ occurred_at: r.get("occurred_at"),
+ importance: toNumber(r.get("importance")) ?? 5,
+ participants: r.get("participants") || []
+ }));
+ return { ok: true, events, total: events.length };
+ } finally {
+ await session.close();
+ }
+ }
+
+ async queryGraphRag(payload = {}) {
+ const userId = payload.userId || "default";
+ const topK = Math.min(Math.max(Number(payload.top_k || 8), 1), 50);
+ const timelineLimit = Math.min(Math.max(Number(payload.timeline_limit || 20), 1), 100);
+ const rawQueryText = payload.query_text || payload.query || "";
+ const hasSelfPronoun = this._containsSelfPronoun(rawQueryText);
+ const selfPerson = hasSelfPronoun ? await this._resolveSelfPerson(userId) : null;
+ const queryText = rawQueryText;
+ const aId = payload.a_id || (hasSelfPronoun ? (selfPerson?.id || null) : null);
+ const bId = payload.b_id || null;
+ const startTs = toTimestamp(payload.start);
+ const endTs = toTimestamp(payload.end);
+ const retrievalModeRequestedRaw = String(payload.retrieval_mode || "hybrid").trim().toLowerCase();
+ const retrievalModeRequested = ["vector", "graph", "hybrid"].includes(retrievalModeRequestedRaw)
+ ? retrievalModeRequestedRaw
+ : "hybrid";
+ const vectorEnabled = retrievalModeRequested !== "graph";
+ const graphEnabled = retrievalModeRequested !== "vector";
+ const rerankRuntime = this.rerankService?.getRuntimeInfo?.() || {
+ configured: false,
+ enabled: false,
+ model: null,
+ disabled_reason: null
+ };
+
+ let queryVector = Array.isArray(payload.query_vector) ? payload.query_vector : null;
+ if (vectorEnabled && !queryVector) {
+ if (!queryText || !queryText.trim()) {
+ throw createHttpError(400, "query_text 或 query_vector 至少提供一个");
+ }
+ if (this.embeddingService?.isEnabled?.()) {
+ try {
+ queryVector = await this.embeddingService.embed(queryText);
+ } catch {
+ queryVector = null;
+ }
+ }
+ }
+
+ const must = [{ key: "user_id", match: { value: userId } }];
+ if (startTs != null || endTs != null) {
+ must.push({
+ key: "occurred_at_ts",
+ range: {
+ ...(startTs != null ? { gte: startTs } : {}),
+ ...(endTs != null ? { lte: endTs } : {})
+ }
+ });
+ }
+
+ const searchResult = vectorEnabled && queryVector
+ ? await this.qdrantClient.search(this.collection, {
+ vector: queryVector,
+ limit: Math.max(topK * 3, topK),
+ with_payload: true,
+ score_threshold: payload.score_threshold ?? undefined,
+ filter: { must }
+ })
+ : [];
+
+ let chunks = (searchResult || []).map((hit) => {
+ const p = hit.payload || {};
+ const occurredAt = p.occurred_at || null;
+ const occurredAtTs = p.occurred_at_ts ?? toTimestamp(occurredAt);
+ return {
+ id: hit.id?.toString?.() || String(hit.id),
+ text: p.text || p.content || "",
+ score: toNumber(hit.score) ?? 0,
+ occurred_at: occurredAt,
+ occurred_at_ts: occurredAtTs,
+ event_id: p.event_id || null,
+ payload: p
+ };
+ });
+
+ let retrievalMode = vectorEnabled
+ ? (queryVector ? "vector" : "vector_unavailable")
+ : "graph_nohit";
+ if (vectorEnabled && !chunks.length && queryText) {
+ const keywords = this._buildKeywordCandidates(queryText, 10);
+ const scoreByKeywords = (text) => {
+ const content = String(text || "").toLowerCase();
+ if (!content || !keywords.length) return 0;
+ let hitCount = 0;
+ let coveredLen = 0;
+ keywords.forEach((kw) => {
+ if (kw && content.includes(kw)) {
+ hitCount += 1;
+ coveredLen += kw.length;
+ }
+ });
+ const hitRate = hitCount / keywords.length;
+ const coverage = Math.min(1, coveredLen / Math.max(content.length, 1) * 6);
+ return hitRate * 0.8 + coverage * 0.2;
+ };
+ try {
+ const fallback = await this.qdrantClient.scroll(this.collection, {
+ filter: { must },
+ with_payload: true,
+ with_vector: false,
+ limit: Math.max(topK * 20, 120)
+ });
+ const points = Array.isArray(fallback?.points) ? fallback.points : [];
+ chunks = points
+ .map((point) => {
+ const p = point.payload || {};
+ const occurredAt = p.occurred_at || null;
+ const occurredAtTs = p.occurred_at_ts ?? toTimestamp(occurredAt);
+ const text = p.text || p.content || "";
+ return {
+ id: point.id?.toString?.() || String(point.id),
+ text,
+ score: scoreByKeywords(text),
+ occurred_at: occurredAt,
+ occurred_at_ts: occurredAtTs,
+ event_id: p.event_id || null,
+ payload: p
+ };
+ })
+ .filter((item) => item.score > 0)
+ .sort((a, b) => b.score - a.score)
+ .slice(0, Math.max(topK * 3, topK));
+ if (chunks.length) retrievalMode = "vector_store_keyword_fallback";
+ } catch {}
+ }
+
+ if (queryText && this.rerankService?.isEnabled?.() && chunks.length) {
+ const reranked = await this.rerankService.rerank(queryText, chunks.slice(0, Math.max(topK * 2, topK)));
+ const rerankScoreById = new Map(reranked.map((item) => [item.id, toNumber(item.relevance_score) ?? toNumber(item.score) ?? 0]));
+ chunks = chunks.map((item) => ({
+ ...item,
+ rerank_score: rerankScoreById.get(item.id) ?? item.score
+ }));
+ } else {
+ chunks = chunks.map((item) => ({ ...item, rerank_score: item.score }));
+ }
+
+ const nowTs = Math.floor(Date.now() / 1000);
+ const halfLifeDays = Number(payload.half_life_days || 45);
+ const decayLambda = Math.log(2) / (halfLifeDays * 86400);
+ let timeline = [];
+ if (graphEnabled) {
+ const session = this.driver.session();
+ try {
+ const eventIds = [...new Set(chunks.map((c) => c.event_id).filter(Boolean))];
+ const timelineKeyword = queryText
+ ? (this._buildKeywordCandidates(queryText, 6).sort((a, b) => a.length - b.length)[0] || null)
+ : null;
+ const timelineParams = {
+ userId,
+ eventIds,
+ aId,
+ bId,
+ queryText: timelineKeyword,
+ start: payload.start ? normalizeOccurredAt(payload.start) : null,
+ end: payload.end ? normalizeOccurredAt(payload.end) : null,
+ limit: neo4j.int(timelineLimit)
+ };
+ const timelineResult = await session.run(
+ `
+ MATCH (e:Event {user_id: $userId})
+ WHERE coalesce(e.status, 'active') <> 'invalidated'
+ AND (size($eventIds) = 0 OR e.id IN $eventIds)
+ AND ($aId IS NULL OR EXISTS { MATCH ({id: $aId, user_id: $userId})-[:PARTICIPATES_IN]->(e) })
+ AND ($bId IS NULL OR EXISTS { MATCH ({id: $bId, user_id: $userId})-[:PARTICIPATES_IN]->(e) })
+ AND ($queryText IS NULL OR toLower(coalesce(e.summary, '')) CONTAINS $queryText OR toLower(coalesce(e.type, '')) CONTAINS $queryText)
+ AND ($start IS NULL OR e.occurred_at >= datetime($start))
+ AND ($end IS NULL OR e.occurred_at <= datetime($end))
+ OPTIONAL MATCH (p)-[:PARTICIPATES_IN]->(e)
+ WITH e, collect(DISTINCT coalesce(p.id, p.name)) AS participants
+ RETURN e.id AS id, e.summary AS summary, e.type AS type, e.occurred_at AS occurred_at, e.importance AS importance, participants
+ ORDER BY e.occurred_at DESC
+ LIMIT $limit
+ `,
+ timelineParams
+ );
+ timeline = timelineResult.records.map((r) => {
+ const occurredAt = r.get("occurred_at");
+ const occurredAtStr = occurredAt?.toString?.() || occurredAt;
+ return {
+ id: r.get("id"),
+ summary: r.get("summary"),
+ type: r.get("type"),
+ occurred_at: occurredAtStr,
+ occurred_at_ts: toTimestamp(occurredAtStr),
+ importance: toNumber(r.get("importance")) ?? 5,
+ participants: r.get("participants") || []
+ };
+ });
+ if (!chunks.length && timeline.length) {
+ const keywords = this._buildKeywordCandidates(queryText, 10);
+ chunks = timeline.map((event, idx) => {
+ const text = `${event.summary || ""} ${(event.participants || []).join(" ")}`.trim();
+ const content = String(text || "").toLowerCase();
+ let hitCount = 0;
+ keywords.forEach((kw) => {
+ if (kw && content.includes(kw)) hitCount += 1;
+ });
+ const keywordScore = keywords.length ? hitCount / keywords.length : 0.3;
+ return {
+ id: `timeline_${event.id || idx}`,
+ text,
+ score: keywordScore,
+ occurred_at: event.occurred_at || null,
+ occurred_at_ts: event.occurred_at_ts || null,
+ event_id: event.id || null,
+ payload: {
+ event_id: event.id || null,
+ text
+ },
+ rerank_score: keywordScore
+ };
+ }).slice(0, Math.max(topK * 3, topK));
+ retrievalMode = "neo4j_timeline_fallback";
+ }
+ } finally {
+ await session.close();
+ }
+ }
+ if (!graphEnabled && !chunks.length && vectorEnabled && queryText) {
+ retrievalMode = "vector_only_nohit";
+ } else if (graphEnabled && !vectorEnabled && timeline.length) {
+ retrievalMode = "graph";
+ if (chunks.length) retrievalMode = "graph_with_chunk_projection";
+ } else if (graphEnabled && vectorEnabled && chunks.length && timeline.length) {
+ retrievalMode = "hybrid";
+ }
+ if (graphEnabled && !vectorEnabled && queryText) {
+ const strictName = (String(queryText).match(/^([\u4e00-\u9fa5]{2,4})的/) || [])[1] || "";
+ if (strictName) {
+ const matched = timeline.some((event) => {
+ const participantText = Array.isArray(event?.participants) ? event.participants.join(" ") : "";
+ const summaryText = String(event?.summary || "");
+ return participantText.includes(strictName) || summaryText.includes(strictName);
+ });
+ if (!matched) {
+ timeline = [];
+ chunks = [];
+ retrievalMode = "graph_nohit";
+ }
+ }
+ }
+
+ const eventById = new Map(timeline.map((e) => [e.id, e]));
+ const scoredChunks = chunks.map((chunk) => {
+ const event = chunk.event_id ? eventById.get(chunk.event_id) : null;
+ const eventTs = event?.occurred_at_ts ?? chunk.occurred_at_ts;
+ const ageSec = eventTs ? Math.max(0, nowTs - eventTs) : null;
+ const temporalScore = ageSec == null ? 0.5 : Math.exp(-decayLambda * ageSec);
+ const importanceScore = Math.min(1, Math.max(0, (event?.importance ?? 5) / 10));
+ const semanticScore = Math.min(1, Math.max(0, chunk.rerank_score ?? chunk.score ?? 0));
+ const finalScore = semanticScore * 0.6 + temporalScore * 0.3 + importanceScore * 0.1;
+ return {
+ ...chunk,
+ temporal_score: temporalScore,
+ importance_score: importanceScore,
+ final_score: finalScore
+ };
+ });
+
+ scoredChunks.sort((a, b) => b.final_score - a.final_score);
+ const selectedChunks = scoredChunks.slice(0, topK);
+
+ let answer = "已完成时序检索";
+ if (!selectedChunks.length && !timeline.length) {
+ answer = "当前图谱检索未命中可用数据。请先补充该用户的图谱数据,或切换到混合检索/向量检索。";
+ } else if (this.llmService?.isEnabled() && queryText) {
+ try {
+ const context = selectedChunks.map((c, idx) => {
+ const event = c.event_id ? eventById.get(c.event_id) : null;
+ const t = event?.occurred_at || c.occurred_at || "未知时间";
+ return `${idx + 1}. 时间:${t}\n事件:${event?.summary || "无"}\n片段:${c.text}`;
+ }).join("\n\n");
+ const resp = await this.llmService.chat([
+ { role: "system", content: "你是恋爱关系时序分析助手。请基于提供的时间上下文,给出简洁结论。优先考虑近期高相关事件。" },
+ { role: "user", content: `问题:${queryText}\n\n时序上下文:\n${context}` }
+ ], 0.2);
+ answer = resp?.choices?.[0]?.message?.content || answer;
+ } catch {}
+ }
+
+ return {
+ ok: true,
+ answer,
+ query: queryText || null,
+ chunks: selectedChunks,
+ timeline,
+ meta: {
+ top_k: topK,
+ timeline_limit: timelineLimit,
+ half_life_days: halfLifeDays,
+ retrieval_mode_requested: retrievalModeRequested,
+ retrieval_mode: retrievalMode,
+ vector_hits: Array.isArray(searchResult) ? searchResult.length : 0,
+ rerank: rerankRuntime
+ }
+ };
+ }
+
+ async _planMultiRoundQueries(question, maxRounds = 3) {
+ const fallbackParts = String(question || "")
+ .split(/[??。;;\n]/)
+ .map((s) => s.trim())
+ .filter(Boolean);
+ const fallback = [String(question || "").trim(), ...fallbackParts]
+ .filter(Boolean)
+ .slice(0, maxRounds);
+ if (!this.llmService?.isEnabled?.()) return fallback;
+ try {
+ const resp = await this.llmService.chat([
+ {
+ role: "system",
+ content: "你是知识图谱问答拆解器。请把用户问题拆成 2-4 条可检索子问题,返回 JSON:{\"sub_queries\":[\"...\"],\"focus\":[\"...\"]}。不要输出其它文字。"
+ },
+ { role: "user", content: `问题:${question}` }
+ ], 0.2);
+ const content = resp?.choices?.[0]?.message?.content || "";
+ const parsed = tryParseJson(content);
+ const candidates = Array.isArray(parsed?.sub_queries) ? parsed.sub_queries : [];
+ const merged = [...candidates, ...fallback]
+ .map((item) => String(item || "").trim())
+ .filter(Boolean);
+ const unique = [];
+ const seen = new Set();
+ for (const query of merged) {
+ const key = query.toLowerCase();
+ if (seen.has(key)) continue;
+ seen.add(key);
+ unique.push(query);
+ if (unique.length >= maxRounds) break;
+ }
+ return unique.length ? unique : fallback;
+ } catch {
+ return fallback;
+ }
+ }
+
+ _buildKeywordCandidates(question, maxCount = 10) {
+ const text = String(question || "").trim();
+ if (!text) return [];
+ const cjkBlocks = (text.match(/[\u4e00-\u9fff]{2,8}/g) || [])
+ .map((s) => s.trim())
+ .filter(Boolean);
+ const latinBlocks = (text.match(/[a-zA-Z0-9_]{3,32}/g) || [])
+ .map((s) => s.trim().toLowerCase())
+ .filter(Boolean);
+ const chunks = text
+ .split(/[\s,,。;;!!??\n\r\t]+/)
+ .map((s) => s.trim())
+ .filter(Boolean);
+ const phraseBlocks = chunks
+ .flatMap((chunk) => chunk.split(/[的和与跟及同]/))
+ .map((s) => s.trim())
+ .filter((s) => s.length >= 2 && s.length <= 32);
+ const derivedCjk = [];
+ const relationSuffixes = ["关系网", "关系", "图谱", "检索", "问答", "情况", "变化", "风险", "问题", "事件", "怎么样", "如何", "怎样", "现在", "目前", "最近"];
+ for (const block of [...cjkBlocks, ...phraseBlocks]) {
+ if (block.includes("的")) {
+ block.split("的").forEach((part) => {
+ const clean = String(part || "").trim();
+ if (clean.length >= 2 && clean.length <= 8) derivedCjk.push(clean);
+ });
+ }
+ let reduced = block;
+ let changed = true;
+ while (changed) {
+ changed = false;
+ for (const suffix of relationSuffixes) {
+ if (reduced.endsWith(suffix)) {
+ reduced = reduced.slice(0, reduced.length - suffix.length).trim();
+ changed = true;
+ }
+ }
+ }
+ if (reduced.length >= 2 && reduced.length <= 8 && (reduced !== block || reduced.length <= 4)) {
+ derivedCjk.push(reduced);
+ }
+ }
+ const stopWords = new Set(["应该", "还是", "到底", "我们", "你们", "他们", "她们", "是否", "怎么", "什么", "哪里", "没有", "不是", "不能", "可以", "关系", "关系网", "图谱", "检索", "问答", "情况", "变化", "风险", "问题", "事件"]);
+ const merged = [...derivedCjk, ...phraseBlocks, ...cjkBlocks, ...latinBlocks, ...chunks]
+ .map((s) => String(s || "").trim())
+ .filter((s) => s && !stopWords.has(s) && s.length <= 32);
+ const unique = [];
+ const seen = new Set();
+ for (const item of merged) {
+ const key = item.toLowerCase();
+ if (seen.has(key)) continue;
+ seen.add(key);
+ unique.push(key);
+ if (unique.length >= maxCount) break;
+ }
+ return unique;
+ }
+
+ _containsSelfPronoun(question) {
+ const text = String(question || "");
+ return /(?:我(?!们)|本人|自己)/.test(text);
+ }
+
+ _buildSelfAliasKeywords({ userId, inferredAId, hasSelfPronoun, selfPerson } = {}) {
+ const aliases = [];
+ if (hasSelfPronoun) {
+ aliases.push("user", "本人", "自己");
+ if (userId) aliases.push(userId, `${userId}__user`);
+ }
+ if (inferredAId) aliases.push(inferredAId);
+ if (selfPerson?.id) aliases.push(selfPerson.id);
+ const seen = new Set();
+ return aliases
+ .map((item) => String(item || "").trim().toLowerCase())
+ .filter((item) => item.length >= 2 && item.length <= 64)
+ .filter((item) => {
+ if (seen.has(item)) return false;
+ seen.add(item);
+ return true;
+ });
+ }
+
+ async _resolveSelfPerson(userId) {
+ const session = this.driver.session();
+ try {
+ const direct = await session.run(
+ `MATCH (p:Person {user_id: $userId})
+ WHERE p.id IN $candidateIds
+ RETURN p.id AS id, p.name AS name
+ LIMIT 1`,
+ {
+ userId,
+ candidateIds: ["user", `${userId}__user`]
+ }
+ );
+ if (direct.records.length > 0) {
+ return {
+ id: direct.records[0].get("id"),
+ name: direct.records[0].get("name")
+ };
+ }
+ const fallback = await session.run(
+ `MATCH (p:Person {user_id: $userId})
+ WHERE toLower(coalesce(p.name, '')) IN $selfNames
+ RETURN p.id AS id, p.name AS name
+ LIMIT 1`,
+ {
+ userId,
+ selfNames: ["我", "本人", "自己"]
+ }
+ );
+ if (fallback.records.length > 0) {
+ return {
+ id: fallback.records[0].get("id"),
+ name: fallback.records[0].get("name")
+ };
+ }
+ return null;
+ } finally {
+ await session.close();
+ }
+ }
+
+ async _probeGraphNeighborhood(userId, question, options = {}) {
+ const nodeLimit = Math.min(Math.max(Number(options.nodeLimit || 12), 1), 30);
+ const keywords = [...this._buildKeywordCandidates(question, 12), ...(Array.isArray(options.aliasKeywords) ? options.aliasKeywords : [])]
+ .map((item) => String(item || "").trim().toLowerCase())
+ .filter(Boolean)
+ .filter((item, idx, arr) => arr.indexOf(item) === idx)
+ .slice(0, 20);
+ const result = {
+ keywords,
+ entities: [],
+ counts: {
+ entityCount: 0,
+ neighborCount: 0,
+ eventCount: 0
+ }
+ };
+ if (!keywords.length) {
+ return result;
+ }
+ const session = this.driver.session();
+ try {
+ const queryResult = await session.run(
+ `MATCH (n)
+ WHERE n.user_id = $userId
+ AND (
+ any(k IN $keywords WHERE toLower(coalesce(n.name, '')) CONTAINS k)
+ OR any(k IN $keywords WHERE toLower(coalesce(n.id, '')) CONTAINS k)
+ OR any(k IN $keywords WHERE toLower(coalesce(n.summary, '')) CONTAINS k)
+ )
+ WITH DISTINCT n
+ LIMIT $nodeLimit
+ OPTIONAL MATCH (n)-[r]-(m)
+ WHERE m.user_id = $userId
+ OPTIONAL MATCH (n)-[:PARTICIPATES_IN]->(e:Event {user_id: $userId})
+ WHERE coalesce(e.status, 'active') <> 'invalidated'
+ RETURN
+ coalesce(n.id, n.name, elementId(n)) AS id,
+ coalesce(n.name, n.id, elementId(n)) AS name,
+ n.summary AS summary,
+ CASE
+ WHEN 'Person' IN labels(n) THEN 'person'
+ WHEN 'Organization' IN labels(n) THEN 'organization'
+ WHEN 'Event' IN labels(n) THEN 'event'
+ WHEN 'Topic' IN labels(n) THEN 'topic'
+ ELSE 'entity'
+ END AS type,
+ collect(DISTINCT {
+ id: coalesce(m.id, m.name, elementId(m)),
+ name: coalesce(m.name, m.id, elementId(m)),
+ type: CASE
+ WHEN m IS NULL THEN null
+ WHEN 'Person' IN labels(m) THEN 'person'
+ WHEN 'Organization' IN labels(m) THEN 'organization'
+ WHEN 'Event' IN labels(m) THEN 'event'
+ WHEN 'Topic' IN labels(m) THEN 'topic'
+ ELSE 'entity'
+ END,
+ relType: type(r)
+ }) AS neighbors,
+ collect(DISTINCT {
+ id: e.id,
+ summary: e.summary,
+ occurred_at: toString(e.occurred_at),
+ importance: e.importance
+ }) AS events`,
+ {
+ userId,
+ keywords,
+ nodeLimit: neo4j.int(nodeLimit)
+ }
+ );
+ result.entities = queryResult.records.map((r) => {
+ const neighbors = (r.get("neighbors") || [])
+ .filter((n) => n && n.id)
+ .slice(0, 12);
+ const events = (r.get("events") || [])
+ .filter((e) => e && e.id)
+ .map((e) => ({
+ id: e.id,
+ summary: e.summary || "",
+ occurred_at: e.occurred_at || null,
+ importance: toNumber(e.importance) ?? 0
+ }))
+ .slice(0, 10);
+ return {
+ id: r.get("id"),
+ name: r.get("name"),
+ summary: r.get("summary") || "",
+ type: r.get("type") || "entity",
+ neighbors,
+ events
+ };
+ });
+ result.counts.entityCount = result.entities.length;
+ result.counts.neighborCount = result.entities.reduce((sum, item) => sum + (item.neighbors?.length || 0), 0);
+ result.counts.eventCount = result.entities.reduce((sum, item) => sum + (item.events?.length || 0), 0);
+ return result;
+ } finally {
+ await session.close();
+ }
+ }
+
+ async queryGraphRagMultiRound(payload = {}, options = {}) {
+ const userId = payload.userId || "default";
+ const rawQuestion = String(payload.query_text || payload.query || "").trim();
+ if (!rawQuestion) {
+ throw createHttpError(400, "query_text 不能为空");
+ }
+ const hasSelfPronoun = this._containsSelfPronoun(rawQuestion);
+ const selfPerson = hasSelfPronoun ? await this._resolveSelfPerson(userId) : null;
+ const question = rawQuestion;
+ const inferredAId = payload.a_id || (hasSelfPronoun ? (selfPerson?.id || null) : null);
+ const topK = Math.min(Math.max(Number(payload.top_k || 8), 2), 30);
+ const timelineLimit = Math.min(Math.max(Number(payload.timeline_limit || 20), 5), 80);
+ const maxRounds = Math.min(Math.max(Number(payload.max_rounds || 3), 2), 5);
+ const retrievalMode = String(payload.retrieval_mode || "hybrid").trim().toLowerCase();
+ const onProgress = typeof options.onProgress === "function" ? options.onProgress : () => {};
+
+ const rerankRuntime = this.rerankService?.getRuntimeInfo?.() || {
+ configured: false,
+ enabled: false,
+ model: null,
+ disabled_reason: null
+ };
+ onProgress({
+ stage: "pipeline_start",
+ status: "done",
+ question,
+ original_question: rawQuestion,
+ userId,
+ rerank: rerankRuntime,
+ retrieval_mode_requested: retrievalMode
+ });
+ let subQueries = await this._planMultiRoundQueries(question, maxRounds);
+ const selfAliasKeywords = this._buildSelfAliasKeywords({
+ userId,
+ inferredAId,
+ hasSelfPronoun,
+ selfPerson
+ });
+ const graphProbe = await this._probeGraphNeighborhood(userId, question, {
+ nodeLimit: 12,
+ aliasKeywords: selfAliasKeywords
+ });
+ onProgress({
+ stage: "graph_probe",
+ status: "done",
+ keywordCount: graphProbe.keywords.length,
+ ...graphProbe.counts,
+ entities: graphProbe.entities.slice(0, 6).map((item) => ({
+ id: item.id,
+ name: item.name,
+ type: item.type,
+ neighborCount: item.neighbors?.length || 0,
+ eventCount: item.events?.length || 0
+ }))
+ });
+ const graphSeedQueries = graphProbe.entities
+ .map((item) => item?.name)
+ .filter(Boolean)
+ .slice(0, 2)
+ .map((name) => `${name} 相关关系变化`);
+ subQueries = [question, ...graphSeedQueries, ...subQueries]
+ .map((item) => String(item || "").trim())
+ .filter(Boolean);
+ const querySeen = new Set();
+ subQueries = subQueries.filter((item) => {
+ const key = item.toLowerCase();
+ if (querySeen.has(key)) return false;
+ querySeen.add(key);
+ return true;
+ }).slice(0, maxRounds);
+ onProgress({ stage: "query_plan", status: "done", queries: subQueries });
+
+ const roundResults = [];
+ const chunkById = new Map();
+ const timelineById = new Map();
+
+ for (let i = 0; i < subQueries.length; i += 1) {
+ const subQuery = subQueries[i];
+ onProgress({ stage: "knowledge_retrieval", status: "running", round: i + 1, totalRounds: subQueries.length, subQuery });
+ const oneRound = await this.queryGraphRag({
+ userId,
+ query_text: subQuery,
+ top_k: topK,
+ timeline_limit: timelineLimit,
+ retrieval_mode: retrievalMode,
+ a_id: inferredAId,
+ b_id: payload.b_id,
+ start: payload.start,
+ end: payload.end
+ });
+ const chunks = Array.isArray(oneRound?.chunks) ? oneRound.chunks : [];
+ const timeline = Array.isArray(oneRound?.timeline) ? oneRound.timeline : [];
+ chunks.forEach((item) => {
+ const id = item?.id || `${i}_${item?.event_id || ""}_${Math.random().toString(36).slice(2, 8)}`;
+ const prev = chunkById.get(id);
+ const score = Number(item?.final_score ?? item?.rerank_score ?? item?.score ?? 0);
+ if (!prev || score > Number(prev?.final_score ?? prev?.rerank_score ?? prev?.score ?? 0)) {
+ chunkById.set(id, { ...item, id });
+ }
+ });
+ timeline.forEach((event) => {
+ if (!event?.id) return;
+ if (!timelineById.has(event.id)) timelineById.set(event.id, event);
+ });
+ const roundResult = {
+ round: i + 1,
+ subQuery,
+ answer: oneRound?.answer || "",
+ retrievalMode: oneRound?.meta?.retrieval_mode || null,
+ rerankModel: oneRound?.meta?.rerank?.model || null,
+ rerankEnabled: Boolean(oneRound?.meta?.rerank?.enabled),
+ chunkCount: chunks.length,
+ timelineCount: timeline.length,
+ topChunks: chunks.slice(0, 3).map((c) => ({
+ id: c.id,
+ text: String(c.text || "").slice(0, 180),
+ score: Number(c.final_score ?? c.rerank_score ?? c.score ?? 0)
+ }))
+ };
+ roundResults.push(roundResult);
+ onProgress({ stage: "knowledge_retrieval", status: "done", ...roundResult });
+ }
+
+ const mergedTimeline = [...timelineById.values()].sort((a, b) => {
+ const tsA = Number(a?.occurred_at_ts || 0);
+ const tsB = Number(b?.occurred_at_ts || 0);
+ return tsB - tsA;
+ });
+ const mergedChunks = [...chunkById.values()]
+ .sort((a, b) => Number(b?.final_score ?? b?.rerank_score ?? b?.score ?? 0) - Number(a?.final_score ?? a?.rerank_score ?? a?.score ?? 0))
+ .slice(0, Math.max(topK * 2, 12));
+
+ const relationMap = new Map();
+ mergedTimeline.forEach((event) => {
+ const participants = [...new Set((event?.participants || []).filter(Boolean))];
+ for (let i = 0; i < participants.length; i += 1) {
+ for (let j = i + 1; j < participants.length; j += 1) {
+ const a = participants[i];
+ const b = participants[j];
+ const pair = a < b ? `${a}__${b}` : `${b}__${a}`;
+ const current = relationMap.get(pair) || {
+ pair: [a, b],
+ coEventCount: 0,
+ recentEvent: null,
+ samples: []
+ };
+ current.coEventCount += 1;
+ if (!current.recentEvent || Number(event?.occurred_at_ts || 0) > Number(current.recentEvent?.occurred_at_ts || 0)) {
+ current.recentEvent = { id: event.id, summary: event.summary || "", occurred_at: event.occurred_at || null, occurred_at_ts: event.occurred_at_ts || null };
+ }
+ if (current.samples.length < 3) {
+ current.samples.push({
+ id: event.id,
+ summary: event.summary || "",
+ occurred_at: event.occurred_at || null
+ });
+ }
+ relationMap.set(pair, current);
+ }
+ }
+ });
+ const relationHints = [...relationMap.values()]
+ .sort((a, b) => b.coEventCount - a.coEventCount)
+ .slice(0, 8)
+ .map((item) => ({
+ pair: item.pair,
+ coEventCount: item.coEventCount,
+ recentEvent: item.recentEvent?.summary || "",
+ recentAt: item.recentEvent?.occurred_at || null,
+ samples: item.samples
+ }));
+ if (relationHints.length === 0 && graphProbe.entities.length) {
+ const fromNeighbors = [];
+ for (const entity of graphProbe.entities) {
+ const nbs = entity.neighbors || [];
+ for (const nb of nbs) {
+ fromNeighbors.push({
+ pair: [entity.name || entity.id, nb.name || nb.id],
+ coEventCount: entity.events?.length || 0,
+ recentEvent: entity.events?.[0]?.summary || "",
+ recentAt: entity.events?.[0]?.occurred_at || null,
+ samples: (entity.events || []).slice(0, 2).map((e) => ({
+ id: e.id,
+ summary: e.summary,
+ occurred_at: e.occurred_at
+ }))
+ });
+ if (fromNeighbors.length >= 8) break;
+ }
+ if (fromNeighbors.length >= 8) break;
+ }
+ relationHints.push(...fromNeighbors);
+ }
+ onProgress({ stage: "relation_organize", status: "done", relationHints });
+
+ let evidence = mergedChunks.slice(0, 10).map((item, idx) => ({
+ index: idx + 1,
+ id: item.id,
+ event_id: item.event_id || null,
+ score: Number(item?.final_score ?? item?.rerank_score ?? item?.score ?? 0),
+ text: String(item.text || "").slice(0, 260),
+ occurred_at: item.occurred_at || null
+ }));
+ if (evidence.length === 0 && graphProbe.entities.length) {
+ const probeEvidence = [];
+ graphProbe.entities.forEach((entity) => {
+ if (entity.summary) {
+ probeEvidence.push({
+ id: entity.id,
+ event_id: null,
+ score: 0.35,
+ text: `[图谱实体] ${entity.name}: ${entity.summary}`,
+ occurred_at: null
+ });
+ }
+ (entity.events || []).slice(0, 3).forEach((event) => {
+ probeEvidence.push({
+ id: `${entity.id}_${event.id}`,
+ event_id: event.id,
+ score: 0.4,
+ text: `[图谱事件] ${entity.name}: ${event.summary || "无摘要"}`,
+ occurred_at: event.occurred_at || null
+ });
+ });
+ });
+ evidence = probeEvidence.slice(0, 10).map((item, idx) => ({ ...item, index: idx + 1 }));
+ }
+ onProgress({ stage: "evidence_synthesis", status: "done", evidencePreview: evidence.slice(0, 5), evidenceCount: evidence.length });
+
+ let finalReview = {
+ verdict: roundResults.map((r) => r.answer).filter(Boolean).join("\n"),
+ confidence: 0.62,
+ reasoning: "基于多轮检索结果聚合生成",
+ uncertainty: "缺少更细粒度背景信息时,结论可能偏保守",
+ next_actions: ["补充更多时间线文本并重新检索"],
+ citations: evidence.slice(0, 3).map((e) => e.index)
+ };
+
+ if (this.llmService?.isEnabled?.()) {
+ try {
+ const roundText = roundResults.map((r) => `第${r.round}轮 子问题:${r.subQuery}\n中间结论:${r.answer}`).join("\n\n");
+ const relationText = relationHints.map((r, idx) => `${idx + 1}. ${r.pair.join(" <-> ")} 共同事件:${r.coEventCount} 最近:${r.recentEvent}`).join("\n");
+ const evidenceText = evidence.map((e) => `[${e.index}] score=${e.score.toFixed(3)} time=${e.occurred_at || "未知"}\n${e.text}`).join("\n\n");
+ const judgeResp = await this.llmService.chat([
+ {
+ role: "system",
+ content: "你是图谱问答终审官。请综合多轮检索中间结论、关系线索和证据片段,输出 JSON:{\"verdict\":\"\",\"confidence\":0-1,\"reasoning\":\"\",\"uncertainty\":\"\",\"next_actions\":[\"\"],\"citations\":[1,2]}。不要输出其它文字。"
+ },
+ {
+ role: "user",
+ content: `用户问题:${question}\n\n多轮中间结论:\n${roundText}\n\n关系线索:\n${relationText || "无"}\n\n证据片段:\n${evidenceText || "无"}`
+ }
+ ], 0.2);
+ const parsed = tryParseJson(judgeResp?.choices?.[0]?.message?.content || "");
+ if (parsed && typeof parsed === "object") {
+ const confidence = Number(parsed.confidence);
+ finalReview = {
+ verdict: String(parsed.verdict || finalReview.verdict || "").trim() || finalReview.verdict,
+ confidence: Number.isFinite(confidence) ? Math.min(Math.max(confidence, 0), 1) : finalReview.confidence,
+ reasoning: String(parsed.reasoning || finalReview.reasoning || ""),
+ uncertainty: String(parsed.uncertainty || finalReview.uncertainty || ""),
+ next_actions: Array.isArray(parsed.next_actions) ? parsed.next_actions.slice(0, 5).map((x) => String(x || "").trim()).filter(Boolean) : finalReview.next_actions,
+ citations: Array.isArray(parsed.citations) ? parsed.citations.map((x) => Number(x)).filter((x) => Number.isFinite(x) && x > 0) : finalReview.citations
+ };
+ }
+ } catch {}
+ }
+
+ const answer = `结论:${finalReview.verdict}\n\n置信度:${(Number(finalReview.confidence || 0) * 100).toFixed(1)}%\n\n依据:${finalReview.reasoning}\n\n不确定性:${finalReview.uncertainty}`;
+ onProgress({ stage: "final_review", status: "done", finalReview: { ...finalReview, answer } });
+
+ return {
+ ok: true,
+ userId,
+ question,
+ answer,
+ plan: { queries: subQueries },
+ rounds: roundResults,
+ graph_probe: graphProbe,
+ relation_hints: relationHints,
+ evidence,
+ final_review: {
+ ...finalReview,
+ answer
+ },
+ meta: {
+ rounds: subQueries.length,
+ retrieval_mode_requested: retrievalMode,
+ retrieved_chunks: mergedChunks.length,
+ retrieved_timeline_events: mergedTimeline.length,
+ graph_probe_entities: graphProbe.counts.entityCount,
+ graph_probe_neighbors: graphProbe.counts.neighborCount,
+ graph_probe_events: graphProbe.counts.eventCount,
+ rerank: this.rerankService?.getRuntimeInfo?.() || null
+ }
+ };
+ }
+
+ async listUsers(limit = 200) {
+ const session = this.driver.session();
+ const cappedLimit = Math.min(Math.max(Number(limit || 200), 1), 1000);
+ try {
+ const result = await session.run(
+ `MATCH (n)
+ WHERE n.user_id IS NOT NULL AND trim(toString(n.user_id)) <> ''
+ WITH n.user_id AS userId,
+ sum(CASE WHEN 'Person' IN labels(n) THEN 1 ELSE 0 END) AS personCount,
+ sum(CASE WHEN 'Organization' IN labels(n) THEN 1 ELSE 0 END) AS organizationCount,
+ sum(CASE WHEN 'Event' IN labels(n) THEN 1 ELSE 0 END) AS eventCount,
+ sum(CASE WHEN 'Topic' IN labels(n) THEN 1 ELSE 0 END) AS topicCount,
+ count(n) AS nodeCount,
+ max(coalesce(n.updated_at, n.created_at)) AS lastActive
+ RETURN userId, personCount, organizationCount, eventCount, topicCount, nodeCount, lastActive
+ ORDER BY nodeCount DESC, userId ASC
+ LIMIT $limit`,
+ { limit: neo4j.int(cappedLimit) }
+ );
+ return {
+ ok: true,
+ users: result.records.map((r) => ({
+ userId: r.get('userId'),
+ nodeCount: toNumber(r.get('nodeCount')) ?? 0,
+ personCount: toNumber(r.get('personCount')) ?? 0,
+ organizationCount: toNumber(r.get('organizationCount')) ?? 0,
+ eventCount: toNumber(r.get('eventCount')) ?? 0,
+ topicCount: toNumber(r.get('topicCount')) ?? 0,
+ lastActive: r.get('lastActive')?.toString?.() || null
+ }))
+ };
+ } finally {
+ await session.close();
+ }
+ }
+
async getGraphStats(userId = 'default') {
console.log(`[DEBUG] getGraphStats called with userId: ${userId}`);
@@ -87,10 +1283,12 @@ export class GraphRagService {
OR EXISTS { MATCH ()-[r]->(n) WHERE r.user_id = $userId }
OR EXISTS { MATCH (n)-[r]->() WHERE r.user_id = $userId }
WITH n, [label IN labels(n) WHERE toLower(label) <> 'entity' | toLower(label)] AS labels
+ WHERE NOT ('event' IN labels AND coalesce(n.status, 'active') = 'invalidated')
RETURN DISTINCT
coalesce(n.id, n.name, elementId(n)) AS id,
coalesce(n.name, n.summary, n.id, elementId(n)) AS name,
n.summary AS summary,
+ n.gender AS gender,
CASE
WHEN size(labels) = 0 THEN 'entity'
WHEN 'person' IN labels THEN 'person'
@@ -106,8 +1304,10 @@ export class GraphRagService {
),
runQuery(
`MATCH (a)-[r]->(b)
- WHERE r.user_id = $userId
- OR (a.user_id = $userId AND b.user_id = $userId)
+ WHERE (r.user_id = $userId
+ OR (a.user_id = $userId AND b.user_id = $userId))
+ AND NOT ('Event' IN labels(a) AND coalesce(a.status, 'active') = 'invalidated')
+ AND NOT ('Event' IN labels(b) AND coalesce(b.status, 'active') = 'invalidated')
RETURN DISTINCT
coalesce(a.id, a.name, elementId(a)) AS source,
coalesce(b.id, b.name, elementId(b)) AS target,
@@ -126,6 +1326,7 @@ export class GraphRagService {
id,
name: r.get("name"),
summary: r.get("summary"),
+ gender: normalizeGender(r.get("gender")),
type: normalizeType(r.get("type")),
occurred_at: r.get("occurred_at"),
importance: r.get("importance")
@@ -143,13 +1344,29 @@ export class GraphRagService {
}))
.filter((l) => l.source && l.target && nodeIdSet.has(l.source) && nodeIdSet.has(l.target));
+ const nodeTypeStats = nodes.reduce((acc, node) => {
+ const type = normalizeType(node.type);
+ acc[type] = (acc[type] || 0) + 1;
+ return acc;
+ }, {});
+
+ const relationTypeStats = links.reduce((acc, link) => {
+ const type = typeof link.type === "string" && link.type.trim() ? link.type.trim() : "RELATED";
+ acc[type] = (acc[type] || 0) + 1;
+ return acc;
+ }, {});
+
console.log(`[DEBUG] getGraphStats completed: ${nodes.length} nodes, ${links.length} links`);
return {
ok: true,
nodes,
links,
- total: nodes.length
+ total: nodes.length,
+ stats: {
+ node_types: nodeTypeStats,
+ relation_types: relationTypeStats
+ }
};
} catch (error) {
console.error(`[ERROR] getGraphStats error:`, error.message);
@@ -173,45 +1390,62 @@ export class GraphRagService {
* - 第二次:"丽丽和女朋友吵架了" → 添加吵架事件,更新关系
* - 第三次:"丽丽的手机被丫丫偷了" → 添加丫丫、偷窃事件
*/
- async incrementalUpdate(text, userId = 'default') {
+ async incrementalUpdate(text, userId = 'default', options = {}) {
+ const onProgress = typeof options?.onProgress === 'function' ? options.onProgress : () => {};
if (!this.llmService?.isEnabled()) {
throw createHttpError(400, "LLM 服务未配置");
}
- // 1. LLM 分析(提取详细信息 + 识别是否与旧实体相关)
+ onProgress({ stage: 'start', userId });
const existingEntities = await this.getExistingEntities(userId);
- const analysis = await this.llmService.analyzeText(text, existingEntities);
+ onProgress({
+ stage: 'existing_entities_loaded',
+ persons: existingEntities.persons?.length || 0,
+ organizations: existingEntities.organizations?.length || 0
+ });
+ const analysis = await this.llmService.analyzeText(text, existingEntities, {
+ parallelism: options?.parallelism,
+ expertReview: options?.expertReview,
+ onProgress: (event) => onProgress({ stage: 'llm', ...event })
+ });
+ onProgress({
+ stage: 'analysis_ready',
+ persons: analysis.persons?.length || 0,
+ organizations: analysis.organizations?.length || 0,
+ events: analysis.events?.length || 0,
+ topics: analysis.topics?.length || 0,
+ relations: analysis.relations?.length || 0
+ });
console.log("[DEBUG] LLM analysis result:", JSON.stringify(analysis));
console.log("[DEBUG] Existing entities:", JSON.stringify(existingEntities));
const session = this.driver.session();
try {
- // 2. 增量创建/更新实体(不删除旧数据)
const stats = {
created: { persons: 0, organizations: 0, events: 0, topics: 0 },
updated: { persons: 0, organizations: 0, events: 0 },
- relations: 0
+ relations: 0,
+ corrected_events: 0
};
const personIdMap = new Map();
const orgIdMap = new Map();
- // 3. 创建或更新人物实体
for (const person of (analysis.persons || [])) {
const result = await this._upsertPerson(session, person, userId);
if (result.created) stats.created.persons++;
if (result.updated) stats.updated.persons++;
if (person.id) personIdMap.set(person.id, result.id);
}
+ onProgress({ stage: 'persons_done', created: stats.created.persons, updated: stats.updated.persons });
- // 4. 创建或更新组织实体
for (const org of (analysis.organizations || [])) {
const result = await this._upsertOrganization(session, org, userId);
if (result.created) stats.created.organizations++;
if (result.updated) stats.updated.organizations++;
if (org.id) orgIdMap.set(org.id, result.id);
}
+ onProgress({ stage: 'organizations_done', created: stats.created.organizations, updated: stats.updated.organizations });
- // 5. 创建事件(事件不更新,只创建新的)
for (const event of (analysis.events || [])) {
const normalizedEvent = {
...event,
@@ -220,14 +1454,14 @@ export class GraphRagService {
const created = await this._createEvent(session, normalizedEvent, userId);
if (created) stats.created.events++;
}
+ onProgress({ stage: 'events_done', created: stats.created.events });
- // 6. 创建主题
for (const topic of (analysis.topics || [])) {
const created = await this._createTopic(session, topic, userId);
if (created) stats.created.topics++;
}
+ onProgress({ stage: 'topics_done', created: stats.created.topics });
- // 7. 创建关系(自动去重)
for (const rel of (analysis.relations || [])) {
const normalizedRel = {
...rel,
@@ -237,12 +1471,59 @@ export class GraphRagService {
const created = await this._createRelation(session, normalizedRel, userId);
if (created) stats.relations++;
}
+ onProgress({ stage: 'relations_done', created: stats.relations });
- return {
+ const rawInput = String(text || "");
+ const keywordTriggered = CORRECTION_INTENT_RE.test(rawInput);
+ let semanticTriggered = false;
+ let semanticReason = "";
+ let recentEvents = [];
+ if (!keywordTriggered) {
+ recentEvents = await this._getRecentActiveEvents(session, userId, 6);
+ const detection = await this.llmService.detectImplicitCorrectionIntent(rawInput, recentEvents, {
+ maxEvents: 6,
+ minConfidence: 0.78
+ });
+ semanticTriggered = detection.trigger;
+ semanticReason = detection.reason || "";
+ }
+
+ if (keywordTriggered || semanticTriggered) {
+ onProgress({ stage: 'correction_review_start' });
+ if (!recentEvents.length) {
+ recentEvents = await this._getRecentActiveEvents(session, userId, 12);
+ }
+ const adjudication = await this.llmService.adjudicateEventCorrections(text, recentEvents, {
+ maxEvents: 12,
+ minConfidence: 0.72
+ });
+ for (const decision of (adjudication.decisions || [])) {
+ const applied = await this._applyEventDecision(session, userId, decision);
+ if (applied) stats.corrected_events += 1;
+ }
+ onProgress({
+ stage: 'correction_review_done',
+ trigger: keywordTriggered ? 'keyword' : 'semantic',
+ reason: semanticReason,
+ candidates: recentEvents.length,
+ corrected_events: stats.corrected_events
+ });
+ }
+
+ const result = {
ok: true,
message: "增量更新成功",
stats
};
+ try {
+ const vectorSync = await this.reindexUserVectors({ userId, limit: 300 });
+ result.vector_sync = vectorSync;
+ onProgress({ stage: 'vector_sync_done', indexed: vectorSync.indexed || 0 });
+ } catch (error) {
+ onProgress({ stage: 'vector_sync_skipped', reason: error?.message || '向量回填失败' });
+ }
+ onProgress({ stage: 'done', stats });
+ return result;
} finally {
await session.close();
}
@@ -256,7 +1537,7 @@ export class GraphRagService {
try {
const persons = await session.run(
`MATCH (p:Person {user_id: $userId})
- RETURN p.id AS id, p.name AS name, p.summary AS summary
+ RETURN p.id AS id, p.name AS name, p.summary AS summary, p.gender AS gender
ORDER BY p.created_at DESC
LIMIT 50`,
{ userId }
@@ -274,7 +1555,8 @@ export class GraphRagService {
persons: persons.records.map(r => ({
id: r.get('id'),
name: r.get('name'),
- summary: r.get('summary')
+ summary: r.get('summary'),
+ gender: normalizeGender(r.get('gender'))
})),
organizations: organizations.records.map(r => ({
id: r.get('id'),
@@ -295,13 +1577,14 @@ export class GraphRagService {
if (person.id && !person.id.startsWith('p_')) {
const byId = await session.run(
`MATCH (p:Person {id: $id, user_id: $userId})
- RETURN p.id AS id, p.summary AS summary`,
+ RETURN p.id AS id, p.summary AS summary, p.gender AS gender`,
{ id: person.id, userId }
);
if (byId.records.length > 0) {
const existingId = byId.records[0].get('id');
await this._updatePersonSummary(session, existingId, person.summary || person.description, userId);
+ await this._updatePersonGender(session, existingId, person.gender, userId);
return { created: false, updated: true, id: existingId };
}
}
@@ -310,7 +1593,7 @@ export class GraphRagService {
const existing = await session.run(
`MATCH (p:Person {user_id: $userId})
WHERE p.name = $name OR p.name CONTAINS $namePart OR $name CONTAINS p.name
- RETURN p.id AS id, p.summary AS summary
+ RETURN p.id AS id, p.summary AS summary, p.gender AS gender
LIMIT 1`,
{ name: person.name, namePart: person.name.split('的')[0], userId }
);
@@ -318,6 +1601,7 @@ export class GraphRagService {
if (existing.records.length > 0) {
const existingId = existing.records[0].get('id');
await this._updatePersonSummary(session, existingId, person.summary || person.description, userId);
+ await this._updatePersonGender(session, existingId, person.gender, userId);
return { created: false, updated: true, id: existingId };
}
@@ -328,6 +1612,7 @@ export class GraphRagService {
id: $id,
name: $name,
summary: $summary,
+ gender: $gender,
user_id: $userId,
created_at: datetime(),
updated_at: datetime()
@@ -335,7 +1620,8 @@ export class GraphRagService {
{
id: newId,
name: person.name,
- summary: person.summary || person.description || '',
+ summary: mergeCompactSummary("", person.summary || person.description || "", { maxParts: 4, maxLen: 240 }),
+ gender: normalizeGender(person.gender),
userId
}
);
@@ -354,7 +1640,8 @@ export class GraphRagService {
);
const existingSummary = current.records[0]?.get('summary') || '';
- if (existingSummary.includes(newSummary)) return;
+ const mergedSummary = mergeCompactSummary(existingSummary, newSummary, { maxParts: 4, maxLen: 240 });
+ if (!mergedSummary || mergedSummary === normalizeSummaryPart(existingSummary)) return;
await session.run(
`MATCH (p:Person {id: $id, user_id: $userId})
@@ -362,7 +1649,22 @@ export class GraphRagService {
{
id: personId,
userId,
- summary: existingSummary + ' | ' + newSummary
+ summary: mergedSummary
+ }
+ );
+ }
+
+ async _updatePersonGender(session, personId, newGender, userId) {
+ const normalizedGender = normalizeGender(newGender);
+ if (normalizedGender === "unknown") return;
+ await session.run(
+ `MATCH (p:Person {id: $id, user_id: $userId})
+ WHERE coalesce(p.gender, 'unknown') = 'unknown'
+ SET p.gender = $gender, p.updated_at = datetime()`,
+ {
+ id: personId,
+ userId,
+ gender: normalizedGender
}
);
}
@@ -373,12 +1675,21 @@ export class GraphRagService {
async _upsertOrganization(session, org, userId) {
const existing = await session.run(
`MATCH (o:Organization {name: $name, user_id: $userId})
- RETURN o.id AS id`,
+ RETURN o.id AS id, o.summary AS summary`,
{ name: org.name, userId }
);
if (existing.records.length > 0) {
- return { created: false, updated: false, id: existing.records[0].get('id') };
+ const existingId = existing.records[0].get('id');
+ const existingSummary = existing.records[0].get('summary') || '';
+ const updated = await this._updateOrganizationSummary(
+ session,
+ existingId,
+ existingSummary,
+ org.summary || org.description || '',
+ userId
+ );
+ return { created: false, updated, id: existingId };
}
const newId = this._scopedEntityId(org.id, userId, 'o') || `o_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
@@ -394,13 +1705,29 @@ export class GraphRagService {
{
id: newId,
name: org.name,
- summary: org.summary || org.description || '',
+ summary: mergeCompactSummary("", org.summary || org.description || "", { maxParts: 4, maxLen: 240 }),
userId
}
);
return { created: true, updated: false, id: newId };
}
+ async _updateOrganizationSummary(session, orgId, existingSummary, newSummary, userId) {
+ if (!newSummary) return false;
+ const mergedSummary = mergeCompactSummary(existingSummary, newSummary, { maxParts: 4, maxLen: 240 });
+ if (!mergedSummary || mergedSummary === normalizeSummaryPart(existingSummary)) return false;
+ await session.run(
+ `MATCH (o:Organization {id: $id, user_id: $userId})
+ SET o.summary = $summary, o.updated_at = datetime()`,
+ {
+ id: orgId,
+ userId,
+ summary: mergedSummary
+ }
+ );
+ return true;
+ }
+
/**
* 创建事件
*/
@@ -419,12 +1746,14 @@ export class GraphRagService {
e.summary = $summary,
e.occurred_at = datetime($occurred_at),
e.importance = $importance,
+ e.status = 'active',
e.created_at = datetime()
ON MATCH SET
e.type = $type,
e.summary = $summary,
e.occurred_at = datetime($occurred_at),
e.importance = $importance,
+ e.status = 'active',
e.updated_at = datetime()`,
{
id: newId,
@@ -483,6 +1812,74 @@ export class GraphRagService {
return true;
}
+ async _getRecentActiveEvents(session, userId, limit = 12) {
+ const cappedLimit = Math.min(Math.max(Number(limit || 12), 1), 30);
+ const result = await session.run(
+ `MATCH (e:Event {user_id: $userId})
+ WHERE coalesce(e.status, 'active') <> 'invalidated'
+ RETURN e.id AS id, e.type AS type, e.summary AS summary, e.occurred_at AS occurred_at, e.importance AS importance
+ ORDER BY e.occurred_at DESC
+ LIMIT $limit`,
+ { userId, limit: neo4j.int(cappedLimit) }
+ );
+ return result.records.map((r) => ({
+ id: r.get("id"),
+ type: r.get("type"),
+ summary: r.get("summary"),
+ occurred_at: r.get("occurred_at")?.toString?.() || r.get("occurred_at"),
+ importance: toNumber(r.get("importance")) ?? 5
+ }));
+ }
+
+ async _applyEventDecision(session, userId, decision = {}) {
+ const eventId = decision?.event_id;
+ if (!eventId) return false;
+ const action = String(decision?.action || "").trim();
+ if (action === "invalidate") {
+ const result = await session.run(
+ `MATCH (e:Event {id: $eventId, user_id: $userId})
+ WHERE coalesce(e.status, 'active') <> 'invalidated'
+ SET e.status = 'invalidated',
+ e.invalidated_reason = $reason,
+ e.invalidated_confidence = $confidence,
+ e.invalidated_at = datetime(),
+ e.updated_at = datetime()
+ RETURN e.id AS id`,
+ {
+ eventId,
+ userId,
+ reason: decision.reason || "",
+ confidence: Number(decision.confidence || 0)
+ }
+ );
+ return result.records.length > 0;
+ }
+ if (action === "update") {
+ const result = await session.run(
+ `MATCH (e:Event {id: $eventId, user_id: $userId})
+ WHERE coalesce(e.status, 'active') <> 'invalidated'
+ SET e.summary = CASE WHEN $summary = '' THEN e.summary ELSE $summary END,
+ e.type = CASE WHEN $type = '' THEN e.type ELSE $type END,
+ e.importance = CASE WHEN $importance IS NULL THEN e.importance ELSE $importance END,
+ e.correction_reason = $reason,
+ e.correction_confidence = $confidence,
+ e.updated_at = datetime()
+ RETURN e.id AS id`,
+ {
+ eventId,
+ userId,
+ summary: decision.new_summary || "",
+ type: decision.new_type || "",
+ importance: Number.isFinite(Number(decision.new_importance)) ? neo4j.int(Math.round(Number(decision.new_importance))) : null,
+ reason: decision.reason || "",
+ confidence: Number(decision.confidence || 0)
+ }
+ );
+ return result.records.length > 0;
+ }
+ return false;
+ }
+
_scopedEntityId(id, userId, generatedPrefix) {
if (!id || typeof id !== 'string') return null;
const cleanId = id.trim();
@@ -532,7 +1929,8 @@ export class GraphRagService {
case 'conflicts':
cypherQuery = `
MATCH (u:Person {id: 'user', user_id: $userId})-[:PARTICIPATES_IN]->(e:Event)
- WHERE e.type IN ['conflict', 'argument', 'fight'] OR e.emotional_tone = 'negative'
+ WHERE coalesce(e.status, 'active') <> 'invalidated'
+ AND (e.type IN ['conflict', 'argument', 'fight'] OR e.emotional_tone = 'negative')
RETURN e
ORDER BY e.occurred_at DESC
LIMIT $limit
@@ -542,7 +1940,8 @@ export class GraphRagService {
case 'positive':
cypherQuery = `
MATCH (u:Person {id: 'user', user_id: $userId})-[:PARTICIPATES_IN]->(e:Event)
- WHERE e.type IN ['date', 'gift', 'proposal', 'celebration'] OR e.emotional_tone = 'positive'
+ WHERE coalesce(e.status, 'active') <> 'invalidated'
+ AND (e.type IN ['date', 'gift', 'proposal', 'celebration'] OR e.emotional_tone = 'positive')
RETURN e
ORDER BY e.occurred_at DESC
LIMIT $limit
@@ -552,7 +1951,8 @@ export class GraphRagService {
case 'third_party':
cypherQuery = `
MATCH (u:Person {id: 'user', user_id: $userId})-[:PARTICIPATES_IN]->(e:Event)<-[:PARTICIPATES_IN]-(other:Person)
- WHERE other.id <> 'partner' AND other.role IN ['friend', 'family', 'colleague']
+ WHERE coalesce(e.status, 'active') <> 'invalidated'
+ AND other.id <> 'partner' AND other.role IN ['friend', 'family', 'colleague']
RETURN e, other
ORDER BY e.occurred_at DESC
LIMIT $limit
@@ -562,6 +1962,7 @@ export class GraphRagService {
default:
cypherQuery = `
MATCH (u:Person {id: 'user', user_id: $userId})-[:PARTICIPATES_IN]->(e:Event)
+ WHERE coalesce(e.status, 'active') <> 'invalidated'
RETURN e
ORDER BY e.occurred_at DESC
LIMIT $limit
@@ -597,6 +1998,7 @@ export class GraphRagService {
const eventStats = await session.run(
`
MATCH (u:Person {id: 'user', user_id: $userId})-[:PARTICIPATES_IN]->(e:Event)
+ WHERE coalesce(e.status, 'active') <> 'invalidated'
RETURN e.type AS type, count(e) AS count,
sum(CASE WHEN e.emotional_tone = 'positive' THEN 1 ELSE 0 END) AS positive,
sum(CASE WHEN e.emotional_tone = 'negative' THEN 1 ELSE 0 END) AS negative
@@ -607,6 +2009,7 @@ export class GraphRagService {
const recentEvents = await session.run(
`
MATCH (u:Person {id: 'user', user_id: $userId})-[:PARTICIPATES_IN]->(e:Event)
+ WHERE coalesce(e.status, 'active') <> 'invalidated'
RETURN e
ORDER BY e.occurred_at DESC
LIMIT 10
@@ -618,7 +2021,8 @@ export class GraphRagService {
`
MATCH (u:Person {id: 'user', user_id: $userId})
-[:PARTICIPATES_IN]->(e:Event)<-[:PARTICIPATES_IN]-(other:Person)
- WHERE other.id <> 'partner'
+ WHERE coalesce(e.status, 'active') <> 'invalidated'
+ AND other.id <> 'partner'
RETURN other.name AS name, other.role AS role, count(e) AS event_count
`,
{ userId }
diff --git a/OnceLove/oncelove-graphrag/api/src/services/index.js b/OnceLove/oncelove-graphrag/api/src/services/index.js
index 7e909d9..13ae3b6 100644
--- a/OnceLove/oncelove-graphrag/api/src/services/index.js
+++ b/OnceLove/oncelove-graphrag/api/src/services/index.js
@@ -2,3 +2,4 @@ export { EmbeddingService } from "./embedding.service.js";
export { RerankService } from "./rerank.service.js";
export { GraphRagService } from "./graphrag.service.js";
export { LLMService } from "./llm.service.js";
+export { MultiAgentService } from "./multiagent.service.js";
diff --git a/OnceLove/oncelove-graphrag/api/src/services/llm.service.js b/OnceLove/oncelove-graphrag/api/src/services/llm.service.js
index 60e8a32..92babbd 100644
--- a/OnceLove/oncelove-graphrag/api/src/services/llm.service.js
+++ b/OnceLove/oncelove-graphrag/api/src/services/llm.service.js
@@ -4,6 +4,41 @@ const createHttpError = (statusCode, message) => {
return error;
};
+const estimateTokens = (text) => {
+ if (!text) return 0;
+ const str = String(text);
+ return Math.ceil(str.length / 2);
+};
+
+const estimateMessageTokens = (messages = []) => {
+ return messages.reduce((sum, message) => {
+ return sum + estimateTokens(message?.content || "") + 6;
+ }, 0);
+};
+
+const parseJsonObject = (content) => {
+ if (!content || typeof content !== "string") {
+ throw new Error("empty content");
+ }
+ const direct = content.trim();
+ try {
+ return JSON.parse(direct);
+ } catch {}
+ const codeBlockMatch = direct.match(/```json\s*([\s\S]*?)```/i) || direct.match(/```\s*([\s\S]*?)```/i);
+ if (codeBlockMatch?.[1]) {
+ try {
+ return JSON.parse(codeBlockMatch[1].trim());
+ } catch {}
+ }
+ const start = direct.indexOf("{");
+ const end = direct.lastIndexOf("}");
+ if (start >= 0 && end > start) {
+ const candidate = direct.slice(start, end + 1);
+ return JSON.parse(candidate);
+ }
+ throw new Error("json not found");
+};
+
/**
* 文本预处理
*/
@@ -44,12 +79,179 @@ const splitTextIntoChunks = (text, chunkSize = 500, overlap = 50) => {
}
chunks.push(text.slice(start, chunkEnd).trim());
- start = chunkEnd - overlap;
+ if (chunkEnd >= text.length) {
+ break;
+ }
+ const nextStart = chunkEnd - overlap;
+ start = nextStart > start ? nextStart : chunkEnd;
}
return chunks;
};
+const ensureAnalysisShape = (parsed = {}) => {
+ if (!Array.isArray(parsed.persons)) parsed.persons = [];
+ if (!Array.isArray(parsed.organizations)) parsed.organizations = [];
+ if (!Array.isArray(parsed.events)) parsed.events = [];
+ if (!Array.isArray(parsed.topics)) parsed.topics = [];
+ if (!Array.isArray(parsed.relations)) parsed.relations = [];
+ return parsed;
+};
+
+const mergeByKey = (items, keyBuilder) => {
+ const map = new Map();
+ for (const item of items || []) {
+ const key = keyBuilder(item);
+ if (!key) continue;
+ if (!map.has(key)) {
+ map.set(key, item);
+ continue;
+ }
+ const oldValue = map.get(key) || {};
+ map.set(key, { ...oldValue, ...item });
+ }
+ return [...map.values()];
+};
+
+const mergeAnalyses = (analyses = []) => {
+ const normalized = analyses.map((a) => ensureAnalysisShape(a || {}));
+ const persons = mergeByKey(
+ normalized.flatMap((a) => a.persons || []),
+ (item) => item?.id || item?.name
+ );
+ const organizations = mergeByKey(
+ normalized.flatMap((a) => a.organizations || []),
+ (item) => item?.id || item?.name
+ );
+ const events = mergeByKey(
+ normalized.flatMap((a) => a.events || []),
+ (item) => item?.id || `${item?.type || ""}|${item?.summary || ""}|${item?.occurred_at || ""}`
+ );
+ const topics = mergeByKey(
+ normalized.flatMap((a) => a.topics || []),
+ (item) => item?.id || item?.name
+ );
+ const relations = mergeByKey(
+ normalized.flatMap((a) => a.relations || []),
+ (item) => `${item?.source || ""}|${item?.target || ""}|${item?.type || ""}|${item?.summary || ""}`
+ );
+ return { persons, organizations, events, topics, relations };
+};
+
+const runWithConcurrency = async (items, concurrency, worker) => {
+ const list = Array.isArray(items) ? items : [];
+ const results = new Array(list.length);
+ const maxConcurrency = Math.min(Math.max(Number(concurrency) || 1, 1), Math.max(list.length, 1));
+ let cursor = 0;
+ const workers = Array.from({ length: maxConcurrency }, async () => {
+ while (true) {
+ const index = cursor;
+ cursor += 1;
+ if (index >= list.length) {
+ break;
+ }
+ results[index] = await worker(list[index], index);
+ }
+ });
+ await Promise.all(workers);
+ return results;
+};
+
+const normalizeTextForMatch = (value) => String(value || "").toLowerCase().trim();
+
+const compactSummaryText = (value, maxLen = 80) => {
+ const raw = String(value || "").trim();
+ if (!raw) return "";
+ const parts = raw
+ .split(/[||;;。]/)
+ .map((p) => p.trim())
+ .filter(Boolean);
+ const seen = new Set();
+ const uniqueParts = [];
+ for (const part of parts) {
+ const key = normalizeTextForMatch(part);
+ if (!key || seen.has(key)) continue;
+ seen.add(key);
+ uniqueParts.push(part);
+ if (uniqueParts.length >= 2) break;
+ }
+ const merged = (uniqueParts.length > 0 ? uniqueParts.join(";") : raw).trim();
+ return merged.length > maxLen ? `${merged.slice(0, maxLen - 1)}…` : merged;
+};
+
+const extractMatchTokens = (text) => {
+ const raw = String(text || "");
+ const zh = raw.match(/[\u4e00-\u9fa5]{2,}/g) || [];
+ const en = raw.match(/[a-zA-Z][a-zA-Z0-9_-]{2,}/g) || [];
+ const set = new Set();
+ for (const token of [...zh, ...en]) {
+ const clean = normalizeTextForMatch(token);
+ if (!clean || clean.length < 2) continue;
+ set.add(clean);
+ if (set.size >= 24) break;
+ }
+ return [...set];
+};
+
+const buildExistingContext = (text, existingEntities = {}) => {
+ const persons = Array.isArray(existingEntities?.persons) ? existingEntities.persons : [];
+ const organizations = Array.isArray(existingEntities?.organizations) ? existingEntities.organizations : [];
+ if (persons.length === 0 && organizations.length === 0) return "";
+
+ const rawInput = String(text || "");
+ const normalizedInput = normalizeTextForMatch(rawInput);
+ const tokens = extractMatchTokens(rawInput);
+
+ const rankEntities = (list, type) => {
+ const dedup = new Map();
+ for (const item of list || []) {
+ const name = String(item?.name || "").trim();
+ if (!name) continue;
+ const dedupKey = `${type}:${normalizeTextForMatch(name)}`;
+ const summary = compactSummaryText(item?.summary || "");
+ const id = String(item?.id || "").trim();
+ const nameNorm = normalizeTextForMatch(name);
+ const coreHit = id === "user" || id === "partner" || /__user$|__partner$/i.test(id);
+ const nameHit = nameNorm && normalizedInput.includes(nameNorm);
+ let tokenHitCount = 0;
+ if (summary) {
+ const summaryNorm = normalizeTextForMatch(summary);
+ for (const t of tokens) {
+ if (summaryNorm.includes(t)) tokenHitCount += 1;
+ if (tokenHitCount >= 3) break;
+ }
+ }
+ const score = (coreHit ? 100 : 0) + (nameHit ? 60 : 0) + tokenHitCount * 5;
+ const current = dedup.get(dedupKey);
+ const next = { id, name, summary, score, nameHit, coreHit };
+ if (!current || next.score > current.score) dedup.set(dedupKey, next);
+ }
+ return [...dedup.values()].sort((a, b) => b.score - a.score || a.name.length - b.name.length);
+ };
+
+ const personTop = rankEntities(persons, "person").slice(0, 12);
+ const orgTop = rankEntities(organizations, "organization").slice(0, 8);
+ const matchedPersons = personTop.filter((p) => p.nameHit || p.coreHit).length;
+ const matchedOrgs = orgTop.filter((o) => o.nameHit || o.coreHit).length;
+
+ return `
+
+## 已有实体列表(命中优先 + 精简)
+**若文本提到下列人物/组织,请复用 ID,不要新建同名实体。**
+输入命中:人物 ${matchedPersons}/${personTop.length},组织 ${matchedOrgs}/${orgTop.length}
+
+已有的人物:
+${personTop.map((p) => `- ID: "${p.id}", 名字:"${p.name}", 摘要:${p.summary || "无"}`).join('\n')}
+
+已有的组织:
+${orgTop.map((o) => `- ID: "${o.id}", 名字:"${o.name}", 摘要:${o.summary || "无"}`).join('\n')}
+
+代词解析:
+- "我" 优先映射 user
+- "女朋友/男朋友/对象/她/他" 优先映射 partner(若上下文一致)
+- 出现同名实体时必须复用以上 ID`;
+};
+
export class LLMService {
constructor(env) {
this.baseUrl = (env.LLM_BASE_URL ?? "").replace(/\/+$/, "");
@@ -66,6 +268,9 @@ export class LLMService {
throw createHttpError(400, "LLM 服务未配置,请提供 LLM_BASE_URL/LLM_API_KEY/LLM_MODEL_NAME");
}
+ const promptTokensEstimated = estimateMessageTokens(messages);
+ console.log(`[TOKEN] model=${this.model} prompt_tokens_estimated=${promptTokensEstimated} max_tokens=4096`);
+
const response = await fetch(`${this.baseUrl}/chat/completions`, {
method: "POST",
headers: {
@@ -86,38 +291,17 @@ export class LLMService {
}
const data = await response.json();
+ const usage = data?.usage || {};
+ const promptTokens = usage.prompt_tokens;
+ const completionTokens = usage.completion_tokens;
+ const totalTokens = usage.total_tokens;
+ console.log(
+ `[TOKEN] model=${this.model} prompt_tokens_actual=${promptTokens ?? "n/a"} completion_tokens=${completionTokens ?? "n/a"} total_tokens=${totalTokens ?? "n/a"}`
+ );
return data;
}
- /**
- * 分析文本并提取详细的实体和关系(MiroFish 风格)
- * @param {string} text - 用户输入的文本
- * @param {object} existingEntities - 现有实体列表(用于识别是否已存在)
- */
- async analyzeText(text, existingEntities = {}) {
- if (!text?.trim()) {
- throw createHttpError(400, "分析文本不能为空");
- }
-
- const existingContext = existingEntities.persons?.length > 0 || existingEntities.organizations?.length > 0
- ? `
-
-## 已有实体列表(极其重要!)
-**如果文本中提到的人/组织已存在于下方列表中,必须复用相同的 ID,不要创建新实体!**
-
-已有的人物:
-${(existingEntities.persons || []).map(p => `- ID: "${p.id}", 名字:"${p.name}", 描述:${p.summary}`).join('\n')}
-
-已有的组织:
-${(existingEntities.organizations || []).map(o => `- ID: "${o.id}", 名字:"${o.name}", 描述:${o.summary}`).join('\n')}
-
-**代词解析指南**:
-- "我女朋友" = 已有实体中的"女朋友"(如果有)
-- "她" = 根据上下文推断指代哪个女性角色
-- "他" = 根据上下文推断指代哪个男性角色
-- "丽丽" = 如果已有实体中有"丽丽",复用 ID`
- : '';
-
+ async _analyzeTextSinglePass(text, existingContext) {
const systemPrompt = `你是一个恋爱关系知识图谱构建专家。从用户输入的文本中提取实体和关系,用于后续的恋爱决策建议。
## 核心原则
@@ -125,6 +309,7 @@ ${(existingEntities.organizations || []).map(o => `- ID: "${o.id}", 名字:"${
2. **其他人物**:朋友、闺蜜、同事等只需要记录基本信息(名字、与用户的关系)
3. **事件细节**:记录争吵、约会、礼物、重要对话等影响关系的事件
4. **情感线索**:提取情绪变化、态度、期望等软性信息
+5. **学校组织**:出现大学/学院/学校名称时,必须抽取为 organizations 节点
## 输出格式(严格 JSON)
{
@@ -133,7 +318,15 @@ ${(existingEntities.organizations || []).map(o => `- ID: "${o.id}", 名字:"${
"id": "p1",
"name": "人物名称",
"summary": "人物描述",
- "role": "用户 | 恋爱对象 | 朋友 | 家人 | 同事 | 其他"
+ "role": "用户 | 恋爱对象 | 朋友 | 家人 | 同事 | 其他",
+ "gender": "male | female | unknown"
+ }
+ ],
+ "organizations": [
+ {
+ "id": "o1",
+ "name": "组织名称(公司/学校/大学)",
+ "summary": "组织描述"
}
],
"events": [
@@ -175,6 +368,10 @@ ${(existingEntities.organizations || []).map(o => `- ID: "${o.id}", 名字:"${
4. **实体去重**:如果文本中提到的人已存在于"已有实体"列表中,**复用相同的 ID**
5. **时间标准化**:occurred_at 使用 ISO 格式
6. **情感标注**:emotional_tone 标注事件的情感倾向(positive/neutral/negative)
+7. 数量控制:persons 最多 8 个,organizations 最多 8 个,events 最多 8 个,topics 最多 8 个,relations 最多 16 个
+8. 长度控制:persons.summary 不超过 100 字;events.summary 不超过 120 字;relations.summary 不超过 80 字
+9. 人物性别:person.gender 只允许 male/female/unknown,无法确认时填 unknown
+10. 若文本出现“某人毕业于/就读于/来自某大学”,需创建该大学 organization,并补充关系(如 STUDIED_AT/ALUMNUS_OF)
只返回 JSON,不要有其他文字。`;
@@ -182,10 +379,9 @@ ${(existingEntities.organizations || []).map(o => `- ID: "${o.id}", 名字:"${
{ role: "system", content: systemPrompt },
{ role: "user", content: `${existingContext}\n\n## 待分析文本\n${text}` }
];
-
+
console.log("[DEBUG] LLM request messages:", JSON.stringify(messages));
const result = await this.chat(messages, 0.3);
-
const content = result?.choices?.[0]?.message?.content;
console.log("[DEBUG] LLM raw response:", content);
if (!content) {
@@ -194,18 +390,270 @@ ${(existingEntities.organizations || []).map(o => `- ID: "${o.id}", 名字:"${
let parsed;
try {
- const jsonMatch = content.match(/\{[\s\S]*\}/);
- parsed = jsonMatch ? JSON.parse(jsonMatch[0]) : JSON.parse(content);
+ parsed = parseJsonObject(content);
} catch (e) {
- throw createHttpError(500, `LLM 返回格式错误:${content.substring(0, 200)}`);
+ const repaired = await this.chat([
+ { role: "system", content: "你是 JSON 修复器。你会把不完整或格式错误的 JSON 修复为合法 JSON。只输出 JSON,不要输出其他内容。" },
+ {
+ role: "user",
+ content: `请把下面内容修复为合法 JSON,必须包含 persons/organizations/events/topics/relations 五个数组字段,缺失就补空数组:\n\n${content}`
+ }
+ ], 0.1);
+ const repairedContent = repaired?.choices?.[0]?.message?.content;
+ try {
+ parsed = parseJsonObject(repairedContent);
+ } catch {
+ throw createHttpError(500, `LLM 返回格式错误:${content.substring(0, 200)}`);
+ }
+ }
+ return ensureAnalysisShape(parsed);
+ }
+
+ async _expertReviewMergedAnalysis(merged, existingContext) {
+ const systemPrompt = `你是知识图谱质检专家。你会对已合并的抽取结果进行去重、补全与标准化,只返回 JSON。
+输出格式:
+{
+ "persons": [],
+ "organizations": [],
+ "events": [],
+ "topics": [],
+ "relations": []
+}
+规则:
+1) 保持与输入语义一致,不凭空捏造
+2) persons 最多 12,organizations 最多 12,events 最多 12,topics 最多 10,relations 最多 24
+3) summary 保持简洁
+4) 必须返回五个数组字段`;
+ const reviewed = await this.chat([
+ { role: "system", content: systemPrompt },
+ {
+ role: "user",
+ content: `${existingContext}\n\n请审查并标准化下面的合并结果:\n${JSON.stringify(ensureAnalysisShape(merged), null, 2)}`
+ }
+ ], 0.2);
+ const reviewedContent = reviewed?.choices?.[0]?.message?.content;
+ return ensureAnalysisShape(parseJsonObject(reviewedContent || ""));
+ }
+
+ /**
+ * 分析文本并提取详细的实体和关系(MiroFish 风格)
+ * @param {string} text - 用户输入的文本
+ * @param {object} existingEntities - 现有实体列表(用于识别是否已存在)
+ */
+ async analyzeText(text, existingEntities = {}, options = {}) {
+ if (!text?.trim()) {
+ throw createHttpError(400, "分析文本不能为空");
}
- return parsed;
+ const normalizedText = preprocessText(text);
+ const existingContext = buildExistingContext(normalizedText, existingEntities);
+ const onProgress = typeof options?.onProgress === "function" ? options.onProgress : () => {};
+ const parallelism = Math.min(Math.max(Number(options?.parallelism || 3), 1), 6);
+ const expertReviewEnabled = options?.expertReview !== false;
+ const estimatedInputTokens = estimateTokens(normalizedText) + estimateTokens(existingContext);
+ const shouldChunk = normalizedText.length > 1600 || estimatedInputTokens > 2200;
+
+ if (!shouldChunk) {
+ onProgress({ stage: "single_pass_start", estimated_tokens: estimatedInputTokens });
+ return await this._analyzeTextSinglePass(normalizedText, existingContext);
+ }
+
+ const chunks = splitTextIntoChunks(normalizedText, 1100, 120).filter(Boolean);
+ console.log(`[TOKEN] long_text_detected=1 chunks=${chunks.length} estimated_input_tokens=${estimatedInputTokens}`);
+ onProgress({
+ stage: "chunking",
+ chunk_count: chunks.length,
+ estimated_tokens: estimatedInputTokens,
+ parallelism
+ });
+
+ const analyses = await runWithConcurrency(chunks, parallelism, async (chunk, index) => {
+ const chunkTokens = estimateTokens(chunk);
+ onProgress({
+ stage: "chunk_start",
+ chunk_index: index + 1,
+ chunk_count: chunks.length,
+ chunk_tokens_estimated: chunkTokens
+ });
+ console.log(`[TOKEN] chunk_index=${index + 1}/${chunks.length} chunk_tokens_estimated=${chunkTokens}`);
+ try {
+ const chunkAnalysis = await this._analyzeTextSinglePass(chunk, existingContext);
+ onProgress({
+ stage: "chunk_done",
+ chunk_index: index + 1,
+ chunk_count: chunks.length,
+ persons: chunkAnalysis.persons.length,
+ organizations: chunkAnalysis.organizations.length,
+ events: chunkAnalysis.events.length,
+ topics: chunkAnalysis.topics.length,
+ relations: chunkAnalysis.relations.length
+ });
+ return chunkAnalysis;
+ } catch (error) {
+ onProgress({
+ stage: "chunk_failed",
+ chunk_index: index + 1,
+ chunk_count: chunks.length,
+ error: error?.message || "chunk_analyze_failed"
+ });
+ return { persons: [], events: [], topics: [], relations: [] };
+ }
+ });
+
+ const merged = mergeAnalyses(analyses);
+ onProgress({
+ stage: "merge_done",
+ persons: merged.persons.length,
+ organizations: merged.organizations.length,
+ events: merged.events.length,
+ topics: merged.topics.length,
+ relations: merged.relations.length
+ });
+
+ if (!expertReviewEnabled || chunks.length < 2) {
+ return merged;
+ }
+
+ onProgress({ stage: "expert_review_start" });
+ try {
+ const reviewed = await this._expertReviewMergedAnalysis(merged, existingContext);
+ onProgress({
+ stage: "expert_review_done",
+ persons: reviewed.persons.length,
+ organizations: reviewed.organizations.length,
+ events: reviewed.events.length,
+ topics: reviewed.topics.length,
+ relations: reviewed.relations.length
+ });
+ return reviewed;
+ } catch (error) {
+ onProgress({
+ stage: "expert_review_failed",
+ error: error?.message || "expert_review_failed"
+ });
+ return merged;
+ }
+ }
+
+ async adjudicateEventCorrections(text, recentEvents = [], options = {}) {
+ const rawText = typeof text === "string" ? text.trim() : "";
+ if (!rawText) return { decisions: [] };
+ const maxEvents = Math.min(Math.max(Number(options?.maxEvents || 12), 1), 20);
+ const minConfidence = Math.min(Math.max(Number(options?.minConfidence ?? 0.72), 0), 1);
+ const compactEvents = (Array.isArray(recentEvents) ? recentEvents : [])
+ .slice(0, maxEvents)
+ .map((e) => ({
+ id: e.id,
+ type: e.type || "general",
+ summary: String(e.summary || "").slice(0, 120),
+ occurred_at: e.occurred_at || null,
+ importance: e.importance ?? 5
+ }))
+ .filter((e) => e.id);
+ if (compactEvents.length === 0) return { decisions: [] };
+
+ const systemPrompt = `你是“事件纠错裁决器”,按低成本多角色流程工作:抽取员->校验员->裁决员。
+只返回 JSON:
+{
+ "decisions": [
+ {
+ "event_id": "事件ID",
+ "action": "keep | invalidate | update",
+ "confidence": 0.0,
+ "reason": "一句话",
+ "new_summary": "仅 action=update 时可填",
+ "new_type": "仅 action=update 时可填",
+ "new_importance": 1-10
+ }
+ ]
+}
+规则:
+1) 仅在用户明确表达“误会、说错、澄清、撤回、并非、不是”等纠错语义时,才给 invalidate/update。
+2) 不要猜测;证据不足就 keep。
+3) event_id 必须来自输入列表。
+4) confidence < ${minConfidence} 的决策不要输出。`;
+
+ const result = await this.chat([
+ { role: "system", content: systemPrompt },
+ {
+ role: "user",
+ content: `用户新输入:\n${rawText}\n\n候选历史事件:\n${JSON.stringify(compactEvents, null, 2)}`
+ }
+ ], 0.1);
+ const content = result?.choices?.[0]?.message?.content || "";
+ let parsed;
+ try {
+ parsed = parseJsonObject(content);
+ } catch {
+ parsed = { decisions: [] };
+ }
+ const validActions = new Set(["keep", "invalidate", "update"]);
+ const allowedIds = new Set(compactEvents.map((e) => e.id));
+ const decisions = (Array.isArray(parsed?.decisions) ? parsed.decisions : [])
+ .filter((d) => d && allowedIds.has(d.event_id) && validActions.has(String(d.action || "").trim()))
+ .map((d) => ({
+ event_id: d.event_id,
+ action: String(d.action).trim(),
+ confidence: Math.min(Math.max(Number(d.confidence ?? 0), 0), 1),
+ reason: String(d.reason || "").slice(0, 120),
+ new_summary: d.new_summary ? String(d.new_summary).slice(0, 160) : "",
+ new_type: d.new_type ? String(d.new_type).slice(0, 40) : "",
+ new_importance: Number.isFinite(Number(d.new_importance)) ? Math.min(10, Math.max(1, Number(d.new_importance))) : null
+ }))
+ .filter((d) => d.confidence >= minConfidence && d.action !== "keep");
+ return { decisions };
+ }
+
+ async detectImplicitCorrectionIntent(text, recentEvents = [], options = {}) {
+ const rawText = typeof text === "string" ? text.trim() : "";
+ if (!rawText) return { trigger: false, confidence: 0, reason: "empty_text" };
+ const maxEvents = Math.min(Math.max(Number(options?.maxEvents || 6), 1), 12);
+ const minConfidence = Math.min(Math.max(Number(options?.minConfidence ?? 0.78), 0), 1);
+ const compactEvents = (Array.isArray(recentEvents) ? recentEvents : [])
+ .slice(0, maxEvents)
+ .map((e) => ({
+ id: e.id,
+ type: e.type || "general",
+ summary: String(e.summary || "").slice(0, 80)
+ }))
+ .filter((e) => e.id);
+ if (compactEvents.length === 0) return { trigger: false, confidence: 0, reason: "no_events" };
+
+ const result = await this.chat([
+ {
+ role: "system",
+ content: `你是“纠错触发判定器”。判断用户新输入是否在表达“对历史事件的反转/否定/修正”。只返回 JSON:
+{"trigger":true|false,"confidence":0.0,"reason":"一句话"}
+规则:
+1) 仅当明确出现与历史事件相反的事实时 trigger=true
+2) 普通新增信息、情绪表达、泛化观点都 trigger=false
+3) 不要猜测,保守判断`
+ },
+ {
+ role: "user",
+ content: `用户新输入:\n${rawText}\n\n最近事件:\n${JSON.stringify(compactEvents, null, 2)}`
+ }
+ ], 0.1);
+ const content = result?.choices?.[0]?.message?.content || "";
+ let parsed;
+ try {
+ parsed = parseJsonObject(content);
+ } catch {
+ parsed = { trigger: false, confidence: 0, reason: "parse_failed" };
+ }
+ const confidence = Math.min(Math.max(Number(parsed?.confidence ?? 0), 0), 1);
+ const trigger = Boolean(parsed?.trigger) && confidence >= minConfidence;
+ return {
+ trigger,
+ confidence,
+ reason: String(parsed?.reason || "").slice(0, 120)
+ };
}
isEmptyAnalysis(data) {
return !data
|| (!Array.isArray(data.persons) || data.persons.length === 0)
+ && (!Array.isArray(data.organizations) || data.organizations.length === 0)
&& (!Array.isArray(data.events) || data.events.length === 0)
&& (!Array.isArray(data.topics) || data.topics.length === 0)
&& (!Array.isArray(data.relations) || data.relations.length === 0);
diff --git a/OnceLove/oncelove-graphrag/api/src/services/multiagent.service.js b/OnceLove/oncelove-graphrag/api/src/services/multiagent.service.js
new file mode 100644
index 0000000..50290d7
--- /dev/null
+++ b/OnceLove/oncelove-graphrag/api/src/services/multiagent.service.js
@@ -0,0 +1,362 @@
+const createHttpError = (statusCode, message) => {
+ const error = new Error(message);
+ error.statusCode = statusCode;
+ return error;
+};
+
+const parseJsonObject = (content) => {
+ if (!content || typeof content !== "string") {
+ throw new Error("empty content");
+ }
+ const direct = content.trim();
+ try {
+ return JSON.parse(direct);
+ } catch {}
+ const codeBlockMatch = direct.match(/```json\s*([\s\S]*?)```/i) || direct.match(/```\s*([\s\S]*?)```/i);
+ if (codeBlockMatch?.[1]) {
+ try {
+ return JSON.parse(codeBlockMatch[1].trim());
+ } catch {}
+ }
+ const start = direct.indexOf("{");
+ const end = direct.lastIndexOf("}");
+ if (start >= 0 && end > start) {
+ return JSON.parse(direct.slice(start, end + 1));
+ }
+ throw new Error("json not found");
+};
+
+export class MultiAgentService {
+ constructor({ llmService, logger }) {
+ this.llmService = llmService;
+ this.logger = logger;
+ }
+
+ _fallbackPlan(task, context, maxAgents) {
+ const n = Math.min(Math.max(Number(maxAgents || 4), 2), 8);
+ const baseAgents = [
+ {
+ id: "planner",
+ name: "任务规划Agent",
+ role: "planner",
+ goal: "澄清目标、边界与验收标准",
+ input: "原始任务、约束、上下文",
+ output: "结构化目标与执行计划"
+ },
+ {
+ id: "analyst",
+ name: "证据分析Agent",
+ role: "analyst",
+ goal: "识别事实、风险与依赖",
+ input: "规划结果与上下文信息",
+ output: "关键证据、风险与优先级"
+ },
+ {
+ id: "executor",
+ name: "执行Agent",
+ role: "executor",
+ goal: "将计划落地为可执行动作",
+ input: "分析结论与执行约束",
+ output: "执行步骤与回滚策略"
+ },
+ {
+ id: "reviewer",
+ name: "评审Agent",
+ role: "reviewer",
+ goal: "检查质量、合规与可交付性",
+ input: "执行结果与验收标准",
+ output: "评审结论与修正建议"
+ }
+ ];
+ const agents = baseAgents.slice(0, n);
+ const workflow = agents.slice(1).map((agent, index) => ({
+ from: agents[index].id,
+ to: agent.id,
+ handoff: "传递结构化结果"
+ }));
+ return {
+ ok: true,
+ mode: "fallback",
+ plan_title: "多Agent任务拆解",
+ task,
+ context: context || "",
+ agents,
+ workflow,
+ milestones: [
+ "完成目标澄清",
+ "完成风险评估",
+ "完成执行方案",
+ "完成质量评审"
+ ],
+ risks: [
+ "上下文不足导致拆解偏差",
+ "依赖未显式声明导致计划延误",
+ "执行与验收标准不一致"
+ ]
+ };
+ }
+
+ _normalizePlan(plan, maxAgents) {
+ const agents = Array.isArray(plan?.agents) ? plan.agents.slice(0, maxAgents) : [];
+ const agentIds = new Set(agents.map((a) => a?.id).filter(Boolean));
+ const workflow = (Array.isArray(plan?.workflow) ? plan.workflow : [])
+ .filter((w) => agentIds.has(w?.from) && agentIds.has(w?.to));
+ return {
+ ok: true,
+ mode: plan?.mode || "llm",
+ plan_title: plan?.plan_title || "多Agent任务拆解",
+ task: plan?.task || "",
+ context: plan?.context || "",
+ agents,
+ workflow,
+ milestones: Array.isArray(plan?.milestones) ? plan.milestones : [],
+ risks: Array.isArray(plan?.risks) ? plan.risks : []
+ };
+ }
+
+ async executeTaskWorkflow(payload = {}) {
+ const task = String(payload.task || "").trim();
+ const context = String(payload.context || "").trim();
+ const constraints = Array.isArray(payload.constraints) ? payload.constraints : [];
+ const maxAgents = Math.min(Math.max(Number(payload.max_agents || 4), 2), 8);
+ const inputPlan = payload.plan && typeof payload.plan === "object" ? payload.plan : null;
+
+ if (!task && !inputPlan) {
+ throw createHttpError(400, "task 不能为空");
+ }
+
+ this.logger?.info?.({
+ event: "multi_agent_execute_start",
+ has_input_plan: Boolean(inputPlan),
+ task_length: task.length,
+ context_length: context.length,
+ constraints_count: constraints.length,
+ max_agents: maxAgents
+ });
+
+ const plan = inputPlan
+ ? this._normalizePlan({
+ ...inputPlan,
+ task: inputPlan.task || task,
+ context: inputPlan.context || context
+ }, maxAgents)
+ : await this.decomposeTask({ task, context, constraints, max_agents: maxAgents });
+
+ if (!Array.isArray(plan.agents) || plan.agents.length < 2) {
+ throw createHttpError(400, "plan.agents 至少需要 2 个");
+ }
+
+ if (!this.llmService?.isEnabled?.()) {
+ const steps = plan.agents.map((agent, index) => ({
+ index: index + 1,
+ agent_id: agent.id,
+ agent_name: agent.name,
+ role: agent.role,
+ result: `${agent.name}已完成占位执行,请在启用LLM后获得真实执行结果`,
+ deliverables: [agent.output || "结构化输出"],
+ risks: [],
+ handoff_to_next: index < plan.agents.length - 1 ? plan.agents[index + 1].id : null
+ }));
+ this.logger?.warn?.({
+ event: "multi_agent_execute_fallback",
+ reason: "llm_not_enabled",
+ step_count: steps.length
+ });
+ return {
+ ok: true,
+ mode: "fallback",
+ task: plan.task,
+ plan,
+ steps,
+ final_summary: "已按回退模式完成流程编排,当前结果为占位执行"
+ };
+ }
+
+ const steps = [];
+ for (let i = 0; i < plan.agents.length; i += 1) {
+ const agent = plan.agents[i];
+ const previousOutputs = steps.map((s) => ({
+ agent_id: s.agent_id,
+ result: s.result,
+ deliverables: s.deliverables
+ }));
+ const handoffFromWorkflow = plan.workflow.find((w) => w.from === agent.id)?.handoff || "";
+
+ const systemPrompt = `你是${agent.name},角色是${agent.role}。你需要对任务执行本角色工作并返回JSON,不要输出任何额外文字。
+输出格式:
+{
+ "result":"本角色结论",
+ "deliverables":["交付物1","交付物2"],
+ "risks":["风险1","风险2"],
+ "next_handoff":"给下个Agent的交接信息"
+}`;
+ const userPrompt = JSON.stringify({
+ task: plan.task,
+ context: plan.context,
+ constraints,
+ agent,
+ handoff_from_workflow: handoffFromWorkflow,
+ previous_outputs: previousOutputs
+ });
+
+ let stepResult;
+ try {
+ const response = await this.llmService.chat([
+ { role: "system", content: systemPrompt },
+ { role: "user", content: userPrompt }
+ ], 0.2);
+ const content = response?.choices?.[0]?.message?.content || "";
+ const parsed = parseJsonObject(content);
+ stepResult = {
+ index: i + 1,
+ agent_id: agent.id,
+ agent_name: agent.name,
+ role: agent.role,
+ result: parsed.result || "",
+ deliverables: Array.isArray(parsed.deliverables) ? parsed.deliverables : [],
+ risks: Array.isArray(parsed.risks) ? parsed.risks : [],
+ next_handoff: parsed.next_handoff || ""
+ };
+ } catch (error) {
+ this.logger?.warn?.({
+ event: "multi_agent_execute_step_failed",
+ agent_id: agent.id,
+ message: error?.message || "unknown_error"
+ });
+ stepResult = {
+ index: i + 1,
+ agent_id: agent.id,
+ agent_name: agent.name,
+ role: agent.role,
+ result: "本步骤执行失败,已跳过",
+ deliverables: [],
+ risks: ["LLM返回异常或解析失败"],
+ next_handoff: ""
+ };
+ }
+ steps.push(stepResult);
+ }
+
+ const finalSummary = steps.map((s) => `[${s.agent_name}] ${s.result}`).join(" | ");
+ this.logger?.info?.({
+ event: "multi_agent_execute_success",
+ step_count: steps.length,
+ failed_step_count: steps.filter((s) => s.result === "本步骤执行失败,已跳过").length
+ });
+
+ return {
+ ok: true,
+ mode: "llm",
+ task: plan.task,
+ plan,
+ steps,
+ final_summary: finalSummary
+ };
+ }
+
+ async decomposeTask(payload = {}) {
+ const task = String(payload.task || payload.query || "").trim();
+ const context = String(payload.context || "").trim();
+ const constraints = Array.isArray(payload.constraints) ? payload.constraints : [];
+ const maxAgents = Math.min(Math.max(Number(payload.max_agents || 4), 2), 8);
+
+ if (!task) {
+ throw createHttpError(400, "task 不能为空");
+ }
+
+ this.logger?.info?.({
+ event: "multi_agent_decompose_start",
+ task_length: task.length,
+ context_length: context.length,
+ constraints_count: constraints.length,
+ max_agents: maxAgents
+ });
+
+ if (!this.llmService?.isEnabled?.()) {
+ const fallback = this._fallbackPlan(task, context, maxAgents);
+ this.logger?.warn?.({
+ event: "multi_agent_decompose_fallback",
+ reason: "llm_not_enabled",
+ agent_count: fallback.agents.length
+ });
+ return fallback;
+ }
+
+ const systemPrompt = `你是多Agent编排专家。请把任务拆解为多个可协作智能体,仅返回JSON。
+输出格式:
+{
+ "plan_title":"字符串",
+ "agents":[
+ {"id":"a1","name":"名称","role":"planner|analyst|executor|reviewer|researcher","goal":"目标","input":"输入","output":"输出"}
+ ],
+ "workflow":[
+ {"from":"a1","to":"a2","handoff":"交接内容"}
+ ],
+ "milestones":["里程碑1","里程碑2"],
+ "risks":["风险1","风险2"]
+}
+规则:
+1) agents 数量为 2-${maxAgents}
+2) id 唯一,workflow 中引用必须有效
+3) 结果要可执行、可验收
+4) 不要输出任何解释文字`;
+
+ const userPrompt = JSON.stringify({
+ task,
+ context,
+ constraints
+ });
+
+ let parsed;
+ try {
+ const response = await this.llmService.chat([
+ { role: "system", content: systemPrompt },
+ { role: "user", content: userPrompt }
+ ], 0.2);
+ const content = response?.choices?.[0]?.message?.content || "";
+ parsed = parseJsonObject(content);
+ } catch (error) {
+ this.logger?.warn?.({
+ event: "multi_agent_decompose_parse_failed",
+ message: error?.message || "unknown_error"
+ });
+ return this._fallbackPlan(task, context, maxAgents);
+ }
+
+ const agents = Array.isArray(parsed.agents) ? parsed.agents.slice(0, maxAgents) : [];
+ const idSet = new Set(agents.map((a) => a?.id).filter(Boolean));
+ const workflow = (Array.isArray(parsed.workflow) ? parsed.workflow : [])
+ .filter((w) => idSet.has(w?.from) && idSet.has(w?.to));
+ const result = {
+ ok: true,
+ mode: "llm",
+ plan_title: parsed.plan_title || "多Agent任务拆解",
+ task,
+ context,
+ agents,
+ workflow,
+ milestones: Array.isArray(parsed.milestones) ? parsed.milestones : [],
+ risks: Array.isArray(parsed.risks) ? parsed.risks : []
+ };
+
+ if (result.agents.length < 2) {
+ const fallback = this._fallbackPlan(task, context, maxAgents);
+ this.logger?.warn?.({
+ event: "multi_agent_decompose_invalid_output",
+ reason: "agent_count_lt_2"
+ });
+ return fallback;
+ }
+
+ this.logger?.info?.({
+ event: "multi_agent_decompose_success",
+ mode: result.mode,
+ agent_count: result.agents.length,
+ workflow_count: result.workflow.length,
+ milestone_count: result.milestones.length,
+ risk_count: result.risks.length
+ });
+
+ return result;
+ }
+}
diff --git a/OnceLove/oncelove-graphrag/api/src/services/rerank.service.js b/OnceLove/oncelove-graphrag/api/src/services/rerank.service.js
index 438cddc..f3c8491 100644
--- a/OnceLove/oncelove-graphrag/api/src/services/rerank.service.js
+++ b/OnceLove/oncelove-graphrag/api/src/services/rerank.service.js
@@ -9,10 +9,30 @@ export class RerankService {
this.baseUrl = (env.RERANK_BASE_URL ?? "").replace(/\/+$/, "");
this.apiKey = env.RERANK_API_KEY ?? "";
this.model = env.RERANK_MODEL ?? "";
+ this.disabledReason = "";
+ const candidates = [];
+ if (this.baseUrl.endsWith("/v1")) {
+ candidates.push(`${this.baseUrl}/rerank`);
+ } else if (this.baseUrl) {
+ candidates.push(`${this.baseUrl}/v1/rerank`, `${this.baseUrl}/rerank`);
+ }
+ if (this.baseUrl.includes("dashscope.aliyuncs.com")) {
+ candidates.push("https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank");
+ }
+ this.endpointCandidates = [...new Set(candidates)];
}
isEnabled() {
- return Boolean(this.baseUrl && this.apiKey && this.model);
+ return Boolean(this.baseUrl && this.apiKey && this.model) && !this.disabledReason;
+ }
+
+ getRuntimeInfo() {
+ return {
+ configured: Boolean(this.baseUrl && this.apiKey && this.model),
+ enabled: this.isEnabled(),
+ model: this.model || null,
+ disabled_reason: this.disabledReason || null
+ };
}
/**
@@ -38,47 +58,63 @@ export class RerankService {
const texts = chunks.map(c => c.text);
try {
- // 假设使用类似 OpenAI 或通用的 rerank 接口格式
- // 实际使用时需根据具体第三方模型的 API 调整参数和路径
- const response = await fetch(`${this.baseUrl}/v1/rerank`, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- Authorization: `Bearer ${this.apiKey}`
- },
- body: JSON.stringify({
- model: this.model,
- query: cleanedQuery,
- texts: texts
- })
- });
-
- if (!response.ok) {
- const errorText = await response.text();
+ let response = null;
+ let errorText = "";
+ let usedEndpoint = "";
+ for (const endpoint of this.endpointCandidates) {
+ usedEndpoint = endpoint;
+ const isDashScopeTextRerank = endpoint.includes("/services/rerank/text-rerank/text-rerank");
+ const body = isDashScopeTextRerank
+ ? {
+ model: this.model,
+ input: {
+ query: cleanedQuery,
+ documents: texts
+ },
+ parameters: {
+ return_documents: false,
+ top_n: texts.length
+ }
+ }
+ : {
+ model: this.model,
+ query: cleanedQuery,
+ texts
+ };
+ response = await fetch(endpoint, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${this.apiKey}`
+ },
+ body: JSON.stringify(body)
+ });
+ if (response.ok) break;
+ errorText = await response.text();
+ if (response.status === 404) continue;
throw createHttpError(response.status, `rerank 请求失败: ${errorText}`);
}
+ if (!response?.ok) {
+ this.disabledReason = `endpoint_not_supported:${usedEndpoint || "unknown"}`;
+ return chunks;
+ }
+
const data = await response.json();
- // data.results 格式通常为: [{ index: 0, relevance_score: 0.9 }, ...]
- const results = data?.results;
-
+ const results = Array.isArray(data?.results) ? data.results : data?.output?.results;
if (!Array.isArray(results)) {
throw createHttpError(500, "rerank 返回格式异常");
}
- // 根据重排结果重新排序 chunks
- const rerankedChunks = results
+ this.disabledReason = "";
+ return results
.sort((a, b) => b.relevance_score - a.relevance_score)
.map(r => ({
...chunks[r.index],
relevance_score: r.relevance_score
}));
-
- return rerankedChunks;
- } catch (error) {
- // 重排失败时,为了不阻断流程,可以选择直接返回原结果并记录日志,或者抛出错误
- console.error("Rerank error:", error);
- return chunks;
+ } catch {
+ return chunks;
}
}
}
diff --git a/OnceLove/oncelove-graphrag/frontend/src/App.vue b/OnceLove/oncelove-graphrag/frontend/src/App.vue
index c10d1ec..7835156 100644
--- a/OnceLove/oncelove-graphrag/frontend/src/App.vue
+++ b/OnceLove/oncelove-graphrag/frontend/src/App.vue
@@ -12,32 +12,49 @@
box-sizing: border-box;
}
+html,
+body,
#app {
- font-family: 'JetBrains Mono', 'Space Grotesk', 'Noto Sans SC', monospace;
+ width: 100%;
+ min-height: 100%;
+}
+
+#app {
+ font-family: var(--font-family, -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', 'PingFang SC', sans-serif);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
- color: #000000;
+ color: #111111;
background-color: #ffffff;
}
+body {
+ background:
+ radial-gradient(circle at 12% -10%, rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0) 40%),
+ radial-gradient(circle at 92% -20%, rgba(0, 0, 0, 0.06), rgba(0, 0, 0, 0) 45%),
+ #ffffff;
+}
+
::-webkit-scrollbar {
- width: 8px;
- height: 8px;
+ width: 9px;
+ height: 9px;
}
::-webkit-scrollbar-track {
- background: #f1f1f1;
+ background: #f4f4f4;
+ border-left: 1px solid #e6e6e6;
}
::-webkit-scrollbar-thumb {
- background: #000000;
+ background: #222222;
+ border: 2px solid #f4f4f4;
+ border-radius: 999px;
}
::-webkit-scrollbar-thumb:hover {
- background: #333333;
+ background: #000000;
}
button {
font-family: inherit;
}
-
\ No newline at end of file
+
diff --git a/OnceLove/oncelove-graphrag/frontend/src/components/GraphQaPanel.vue b/OnceLove/oncelove-graphrag/frontend/src/components/GraphQaPanel.vue
new file mode 100644
index 0000000..b5f7695
--- /dev/null
+++ b/OnceLove/oncelove-graphrag/frontend/src/components/GraphQaPanel.vue
@@ -0,0 +1,855 @@
+
+ {{ errorMsg }} {{ item.role === 'user' ? '你' : 'AI' }} {{ line.text }} {{ item.pending ? '思考中...' : item.text }} 等待提问
+ [{{ line.statusLabel }}]
+ {{ line.text }}
+ 图谱问答
+ userId: {{ userId }}
+ AI 对话
+ 深度思考 / 工具调用
+ Console
+
{{ currentNav.desc }}
查看所有用户并进入对应图谱或数据导入
+| User ID | +人物 | +组织 | +事件 | +主题 | +节点总数 | +最近活跃 | +操作 | +
|---|---|---|---|---|---|---|---|
| {{ item.userId }} | +{{ item.personCount }} | +{{ item.organizationCount }} | +{{ item.eventCount }} | +{{ item.topicCount }} | +{{ item.nodeCount }} | +{{ formatDateTime(item.lastActive) }} | ++ + + + | +
| 暂无用户数据 | +|||||||