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 @@ + + + + + diff --git a/OnceLove/oncelove-graphrag/frontend/src/router/index.js b/OnceLove/oncelove-graphrag/frontend/src/router/index.js index d2c6368..81019d6 100644 --- a/OnceLove/oncelove-graphrag/frontend/src/router/index.js +++ b/OnceLove/oncelove-graphrag/frontend/src/router/index.js @@ -8,6 +8,12 @@ const routes = [ }, { path: '/', + name: 'UserList', + component: () => import('../views/UserList.vue'), + meta: { requiresAuth: true } + }, + { + path: '/dashboard', name: 'Dashboard', component: () => import('../views/Dashboard.vue'), meta: { requiresAuth: true } @@ -32,4 +38,4 @@ router.beforeEach((to, from, next) => { } }) -export default router \ No newline at end of file +export default router diff --git a/OnceLove/oncelove-graphrag/frontend/src/views/Dashboard.vue b/OnceLove/oncelove-graphrag/frontend/src/views/Dashboard.vue index 7f41e1c..99dc22f 100644 --- a/OnceLove/oncelove-graphrag/frontend/src/views/Dashboard.vue +++ b/OnceLove/oncelove-graphrag/frontend/src/views/Dashboard.vue @@ -57,6 +57,8 @@

{{ currentNav.desc }}

+ 当前用户:{{ userId }} +
+
+ 性别 + {{ selectedDetail.data.gender || 'unknown' }} +
ID {{ selectedDetail.data.id || '-' }} @@ -183,7 +189,7 @@ >
@@ -247,6 +253,10 @@ +
+ +
+
@@ -297,11 +307,13 @@ diff --git a/OnceLove/oncelove-graphrag/frontend/src/views/Login.vue b/OnceLove/oncelove-graphrag/frontend/src/views/Login.vue index 9f48359..56df10f 100644 --- a/OnceLove/oncelove-graphrag/frontend/src/views/Login.vue +++ b/OnceLove/oncelove-graphrag/frontend/src/views/Login.vue @@ -129,59 +129,78 @@ const handleLogin = async () => { .login-page { display: flex; min-height: 100vh; - font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-family: var(--font-family, -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', 'PingFang SC', sans-serif); + background: #ffffff; } .login-left { flex: 1; - background: linear-gradient(135deg, #0f0f0f 0%, #1a1a2e 100%); - color: white; - padding: 60px; + background: + radial-gradient(circle at 14% 22%, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0) 35%), + linear-gradient(145deg, #0f0f0f 0%, #1a1a1a 100%); + color: #ffffff; + padding: 56px; position: relative; overflow: hidden; display: flex; align-items: center; + border-right: 1px solid #2b2b2b; } .brand-content { position: relative; z-index: 2; + max-width: 540px; } .brand-logo { - margin-bottom: 32px; + margin-bottom: 26px; + width: 64px; + height: 64px; + display: grid; + place-items: center; + border: 1px solid #4a4a4a; + border-radius: 10px; + background: rgba(255, 255, 255, 0.03); } .brand-content h1 { - font-size: 2.5rem; + font-size: 46px; font-weight: 700; - margin-bottom: 12px; - letter-spacing: -0.02em; + margin-bottom: 10px; + line-height: 1.12; + letter-spacing: 1px; } .brand-desc { - font-size: 1.1rem; - color: rgba(255,255,255,0.6); - margin-bottom: 48px; + font-size: 16px; + color: #bbbbbb; + margin-bottom: 34px; + line-height: 1.7; } .feature-list { display: flex; flex-direction: column; - gap: 16px; + gap: 12px; } .feature-item { display: flex; align-items: center; - gap: 12px; - font-size: 1rem; - color: rgba(255,255,255,0.85); + gap: 10px; + font-size: 14px; + color: #e6e6e6; + letter-spacing: 0.5px; + border: 1px solid #333333; + border-radius: 8px; + padding: 10px 12px; + background: rgba(255, 255, 255, 0.03); } .feature-icon { - color: #6366f1; - font-size: 1.2rem; + color: #ffffff; + font-size: 14px; } .bg-decoration { @@ -193,78 +212,111 @@ const handleLogin = async () => { .circle { position: absolute; border-radius: 50%; - background: linear-gradient(135deg, rgba(99,102,241,0.3) 0%, rgba(99,102,241,0) 100%); + background: linear-gradient(135deg, rgba(255, 255, 255, 0.16) 0%, rgba(255, 255, 255, 0) 100%); } -.c1 { width: 400px; height: 400px; top: -100px; right: -100px; } -.c2 { width: 300px; height: 300px; bottom: -50px; left: -50px; } -.c3 { width: 200px; height: 200px; bottom: 20%; right: 10%; opacity: 0.5; } +.c1 { width: 380px; height: 380px; top: -120px; right: -120px; } +.c2 { width: 280px; height: 280px; bottom: -90px; left: -80px; } +.c3 { width: 200px; height: 200px; bottom: 16%; right: 8%; opacity: 0.4; } .login-right { width: 520px; display: flex; align-items: center; justify-content: center; - padding: 60px; - background: #fafafa; + padding: 48px; + background: + radial-gradient(circle at 90% -5%, rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0) 38%), + #ffffff; } .login-card { width: 100%; max-width: 380px; + border: 1px solid #dcdcdc; + border-radius: 12px; + padding: 26px 24px 20px; + background: #ffffff; + box-shadow: 0 18px 44px rgba(0, 0, 0, 0.08); + position: relative; +} + +.login-card::before, +.login-card::after { + content: ''; + position: absolute; + width: 12px; + height: 12px; + pointer-events: none; +} + +.login-card::before { + top: -1px; + left: -1px; + border-top: 1px solid #111111; + border-left: 1px solid #111111; +} + +.login-card::after { + right: -1px; + bottom: -1px; + border-right: 1px solid #111111; + border-bottom: 1px solid #111111; } .card-header { - margin-bottom: 40px; + margin-bottom: 28px; } .card-header h2 { - font-size: 1.75rem; + font-size: 28px; font-weight: 700; - color: #0f0f0f; - margin-bottom: 8px; + color: #111111; + margin-bottom: 6px; + letter-spacing: 0.5px; } .card-header p { - color: #666; - font-size: 0.95rem; + color: #666666; + font-size: 13px; } .login-form { display: flex; flex-direction: column; - gap: 24px; + gap: 18px; } .form-item label { display: block; - font-size: 0.875rem; - font-weight: 500; - color: #374151; - margin-bottom: 8px; + font-size: 13px; + font-weight: 600; + color: #222222; + margin-bottom: 7px; + letter-spacing: 0.5px; } .input-wrapper { display: flex; align-items: center; - background: white; - border: 1.5px solid #e5e7eb; - border-radius: 10px; - padding: 0 16px; + background: #ffffff; + border: 1px solid #cccccc; + border-radius: 8px; + padding: 0 12px; transition: all 0.2s ease; } .input-wrapper.focus { - border-color: #6366f1; - box-shadow: 0 0 0 3px rgba(99,102,241,0.1); + border-color: #111111; + box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.08); } .input-wrapper.error { - border-color: #ef4444; + border-color: #7a7a7a; } .input-icon { - color: #9ca3af; + color: #888888; flex-shrink: 0; } @@ -272,14 +324,14 @@ const handleLogin = async () => { flex: 1; border: none; outline: none; - padding: 14px 12px; - font-size: 1rem; + padding: 12px 10px; + font-size: 14px; background: transparent; - color: #0f0f0f; + color: #111111; } .input-wrapper input::placeholder { - color: #9ca3af; + color: #9a9a9a; } .input-wrapper input:disabled { @@ -290,34 +342,42 @@ const handleLogin = async () => { display: flex; align-items: center; gap: 6px; - color: #ef4444; - font-size: 0.875rem; - margin-top: -8px; + color: #444444; + font-size: 12px; + margin-top: -4px; + border: 1px solid #d2d2d2; + border-radius: 8px; + padding: 7px 9px; + background: #f8f8f8; } .login-btn { width: 100%; - padding: 14px 24px; - background: #0f0f0f; - color: white; - border: none; - border-radius: 10px; - font-size: 1rem; + padding: 12px 22px; + background: #111111; + color: #ffffff; + border: 1px solid #111111; + border-radius: 8px; + font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.2s ease; - margin-top: 8px; + margin-top: 6px; + letter-spacing: 0.8px; + box-shadow: 0 15px 35px rgba(0, 0, 0, 0.15); } .login-btn:hover:not(:disabled) { - background: #1a1a1a; + background: #000000; transform: translateY(-1px); } .login-btn:disabled { - background: #d1d5db; + background: #666666; + border-color: #666666; cursor: not-allowed; transform: none; + box-shadow: none; } .login-btn.loading { @@ -333,7 +393,7 @@ const handleLogin = async () => { .loading-dots span { width: 6px; height: 6px; - background: white; + background: #ffffff; border-radius: 50%; animation: bounce 1.4s infinite ease-in-out; } @@ -347,17 +407,27 @@ const handleLogin = async () => { } .card-footer { - margin-top: 48px; + margin-top: 28px; text-align: center; + padding-top: 14px; + border-top: 1px solid #e9e9e9; } .card-footer p { - font-size: 0.8rem; - color: #9ca3af; + font-size: 12px; + color: #8d8d8d; + letter-spacing: 0.4px; } @media (max-width: 900px) { .login-left { display: none; } - .login-right { width: 100%; } + .login-right { + width: 100%; + padding: 20px; + } + + .login-card { + max-width: 460px; + } } diff --git a/OnceLove/oncelove-graphrag/frontend/src/views/UserList.vue b/OnceLove/oncelove-graphrag/frontend/src/views/UserList.vue new file mode 100644 index 0000000..1f0886d --- /dev/null +++ b/OnceLove/oncelove-graphrag/frontend/src/views/UserList.vue @@ -0,0 +1,392 @@ + + + + +