From adabd637694fb5621e9140712b105c669709c448 Mon Sep 17 00:00:00 2001 From: KOSHM-Pig <2578878700@qq.com> Date: Mon, 23 Mar 2026 22:09:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=A4=9A=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E6=94=AF=E6=8C=81=E3=80=81=E5=85=B3=E7=B3=BB=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E6=9F=A5=E8=AF=A2=E4=B8=8E=E6=81=8B=E7=88=B1=E5=86=B3?= =?UTF-8?q?=E7=AD=96=E5=BB=BA=E8=AE=AE=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增用户服务,支持多用户数据隔离与认证 - 新增关系历史查询接口,支持按冲突、积极、时间线等类型过滤 - 新增恋爱决策建议接口,基于图谱分析生成关系健康报告 - 优化前端图谱可视化,增加节点详情面板、图例和边标签显示 - 改进文本分析逻辑,支持实体去重和情感标注 - 新增完整流程测试脚本,验证分析、入库、查询全链路 --- .../src/controllers/graphrag.controller.js | 12 +- .../api/src/routes/graphrag.route.js | 49 + .../api/src/services/graphrag.service.js | 1039 +++++++++-------- .../api/src/services/llm.service.js | 233 +++- .../api/src/services/user.service.js | 171 +++ OnceLove/oncelove-graphrag/docker-compose.yml | 2 +- .../frontend/src/views/Dashboard.vue | 839 ++++++++++++- OnceLove/oncelove-graphrag/test_full.ps1 | 35 + OnceLove/oncelove-graphrag/test_rag.ps1 | 71 ++ 9 files changed, 1881 insertions(+), 570 deletions(-) create mode 100644 OnceLove/oncelove-graphrag/api/src/services/user.service.js create mode 100644 OnceLove/oncelove-graphrag/test_full.ps1 create mode 100644 OnceLove/oncelove-graphrag/test_rag.ps1 diff --git a/OnceLove/oncelove-graphrag/api/src/controllers/graphrag.controller.js b/OnceLove/oncelove-graphrag/api/src/controllers/graphrag.controller.js index 9b9a4bf..40a2690 100644 --- a/OnceLove/oncelove-graphrag/api/src/controllers/graphrag.controller.js +++ b/OnceLove/oncelove-graphrag/api/src/controllers/graphrag.controller.js @@ -21,12 +21,20 @@ export const createGraphRagController = (service) => ({ health: async (_request, reply) => reply.send({ ok: true }), ready: async (_request, reply) => sendServiceResult(reply, () => service.ready()), bootstrap: async (_request, reply) => sendServiceResult(reply, () => service.bootstrap()), - getGraphStats: async (_request, reply) => sendServiceResult(reply, () => service.getGraphStats()), + 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)), analyzeAndIngest: async (request, reply) => - sendServiceResult(reply, () => service.analyzeAndIngest(request.body.text)) + sendServiceResult(reply, () => service.incrementalUpdate(request.body.text, request.body.userId || 'default')), + queryHistory: async (request, reply) => + sendServiceResult(reply, () => service.queryRelationshipHistory( + request.body.userId || 'default', + request.body.queryType || 'all', + request.body.limit || 20 + )), + getAdvice: async (request, reply) => + sendServiceResult(reply, () => service.getRelationshipAdvice(request.body.userId || 'default')) }); diff --git a/OnceLove/oncelove-graphrag/api/src/routes/graphrag.route.js b/OnceLove/oncelove-graphrag/api/src/routes/graphrag.route.js index ac502bb..96fb0d4 100644 --- a/OnceLove/oncelove-graphrag/api/src/routes/graphrag.route.js +++ b/OnceLove/oncelove-graphrag/api/src/routes/graphrag.route.js @@ -245,4 +245,53 @@ export const registerGraphRagRoutes = async (app, controller) => { */ app.post("/query/graphrag", controller.queryGraphRag); app.post("/analyze", controller.analyzeAndIngest); + + /** + * @openapi + * /query/history: + * post: + * tags: + * - GraphRAG + * summary: 查询恋爱关系历史(支持按类型过滤) + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * userId: + * type: string + * queryType: + * type: string + * enum: [all, conflicts, positive, third_party, timeline] + * limit: + * type: integer + * responses: + * 200: + * description: 查询成功 + */ + app.post("/query/history", controller.queryHistory); + + /** + * @openapi + * /query/advice: + * post: + * tags: + * - GraphRAG + * summary: 获取恋爱决策建议(基于图谱分析) + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * userId: + * type: string + * responses: + * 200: + * description: 建议生成成功 + */ + app.post("/query/advice", controller.getAdvice); }; diff --git a/OnceLove/oncelove-graphrag/api/src/services/graphrag.service.js b/OnceLove/oncelove-graphrag/api/src/services/graphrag.service.js index 56f1ad1..988ee4a 100644 --- a/OnceLove/oncelove-graphrag/api/src/services/graphrag.service.js +++ b/OnceLove/oncelove-graphrag/api/src/services/graphrag.service.js @@ -1,8 +1,5 @@ import neo4j from "neo4j-driver"; -/** - * 将 ISO 时间转换为秒级时间戳,用于 Qdrant 的范围过滤。 - */ const toTimestamp = (value) => { if (!value) return null; const ms = Date.parse(value); @@ -21,22 +18,6 @@ const normalizeOccurredAt = (value) => { return new Date(ms).toISOString(); }; -/** - * 统一 Neo4j 结果结构,避免控制器层处理图数据库 Record 细节。 - */ -const toEventDto = (record) => ({ - id: record.get("id"), - type: record.get("type"), - summary: record.get("summary"), - occurred_at: record.get("occurred_at"), - importance: record.get("importance"), - topics: record.get("topics") ?? [], - participants: record.get("participants") ?? [] -}); - -/** - * 构造标准业务错误,便于控制器返回统一 HTTP 状态码。 - */ const createHttpError = (statusCode, message) => { const error = new Error(message); error.statusCode = statusCode; @@ -44,99 +25,19 @@ const createHttpError = (statusCode, message) => { }; /** - * 校验 /ingest 入参与向量维度。 - */ -const validateIngestInput = (body, embeddingDim, canAutoEmbed) => { - const persons = Array.isArray(body.persons) ? body.persons : []; - const events = Array.isArray(body.events) ? body.events : []; - const chunks = Array.isArray(body.chunks) ? body.chunks : []; - - for (const person of persons) { - if (!person?.id) { - throw createHttpError(400, "persons[].id 必填"); - } - } - - for (const event of events) { - if (!event?.id || !event?.occurred_at) { - throw createHttpError(400, "events[].id 与 events[].occurred_at 必填"); - } - if (!Array.isArray(event.participants) || event.participants.length === 0) { - throw createHttpError(400, "events[].participants 至少包含 1 个人物 id"); - } - } - - for (const chunk of chunks) { - if (!chunk?.id) { - throw createHttpError(400, "chunks[].id 必填"); - } - const hasVector = Array.isArray(chunk?.vector); - const hasText = typeof chunk?.text === "string" && chunk.text.trim().length > 0; - if (!hasVector && !(canAutoEmbed && hasText)) { - throw createHttpError(400, "chunks[] 需提供 vector,或在配置 embedding 后提供 text"); - } - if (hasVector && chunk.vector.length !== embeddingDim) { - throw createHttpError(400, `chunks[].vector 维度必须为 ${embeddingDim}`); - } - } - - return { persons, events, chunks }; -}; - -/** - * GraphRAG 核心服务: - * 1) 图谱结构写入 Neo4j; - * 2) 文本向量写入 Qdrant; - * 3) 按时序与向量联合检索上下文。 + * GraphRAG 服务 - MiroFish 风格的知识图谱构建 */ export class GraphRagService { - /** - * @param {{ driver: import("neo4j-driver").Driver, qdrantClient: any, env: Record }} deps - */ constructor({ driver, qdrantClient, embeddingService, rerankService, llmService, env }) { this.driver = driver; this.qdrantClient = qdrantClient; this.embeddingService = embeddingService; this.rerankService = rerankService; this.llmService = llmService; - this.collection = env.QDRANT_COLLECTION; - this.embeddingDim = env.EMBEDDING_DIM; + this.env = env; + this.collection = env.QDRANT_COLLECTION ?? "oncelove_chunks"; } - async resolveChunkVector(chunk) { - if (Array.isArray(chunk?.vector)) { - if (chunk.vector.length !== this.embeddingDim) { - throw createHttpError(400, `chunks[].vector 维度必须为 ${this.embeddingDim}`); - } - return chunk.vector; - } - if (!this.embeddingService?.isEnabled()) { - throw createHttpError(400, "未检测到可用 embedding 配置"); - } - return this.embeddingService.embed(chunk.text ?? ""); - } - - async resolveQueryVector(body) { - const queryVector = body?.query_vector; - if (Array.isArray(queryVector)) { - if (queryVector.length !== this.embeddingDim) { - throw createHttpError(400, `query_vector 维度必须为 ${this.embeddingDim}`); - } - return queryVector; - } - const queryText = typeof body?.query_text === "string" ? body.query_text.trim() : ""; - if (!queryText) { - throw createHttpError(400, `query_vector 维度必须为 ${this.embeddingDim},或提供 query_text`); - } - if (!this.embeddingService?.isEnabled()) { - throw createHttpError(400, "未检测到可用 embedding 配置,无法使用 query_text 检索"); - } - return this.embeddingService.embed(queryText); - } - - /** - * 连接就绪检查。 - */ async ready() { const session = this.driver.session(); try { @@ -148,185 +49,199 @@ export class GraphRagService { } } - /** - * 获取图谱统计数据,用于前端 D3.js 可视化渲染。 - * 返回 nodes(人物/事件/主题)和 links(关系边)。 - */ - async getGraphStats() { - const runQuery = async (query) => { + async getGraphStats(userId = 'default') { + console.log(`[DEBUG] getGraphStats called with userId: ${userId}`); + + const runQuery = async (query, params) => { const session = this.driver.session(); - try { return await session.run(query) } - finally { await session.close() } + try { + const result = await session.run(query, params); + console.log(`[DEBUG] Query executed, records: ${result.records.length}`); + return result; + } catch (error) { + console.error(`[DEBUG] Query error:`, error.message); + throw error; + } finally { + await session.close(); + } }; - const [personResult, eventResult, topicResult, personEventResult, eventTopicResult] = - await Promise.all([ - runQuery(`MATCH (p:Person) RETURN p.id AS id, p.name AS name, 'person' AS type LIMIT 200`), - runQuery(`MATCH (e:Event) RETURN e.id AS id, e.summary AS name, 'event' AS type, e.occurred_at AS occurred_at LIMIT 200`), - runQuery(`MATCH (t:Topic) RETURN t.name AS id, t.name AS name, 'topic' AS type LIMIT 100`), - runQuery(`MATCH (p:Person)-[:PARTICIPATES_IN]->(e:Event) RETURN p.id AS source, e.id AS target, 'PARTICIPATES_IN' AS type LIMIT 500`), - runQuery(`MATCH (e:Event)-[:ABOUT]->(t:Topic) RETURN e.id AS source, t.name AS target, 'ABOUT' AS type LIMIT 300`) + + const normalizeType = (value) => { + if (typeof value !== "string") return "entity"; + const clean = value.trim(); + if (!clean) return "entity"; + return clean.toLowerCase(); + }; + + const toId = (value) => { + if (typeof value === "string") return value; + if (value && typeof value.toString === "function") return value.toString(); + return ""; + }; + + try { + const [nodeResult, linkResult] = await Promise.all([ + runQuery( + `MATCH (n) + WHERE n.user_id = $userId + 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 + 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, + CASE + WHEN size(labels) = 0 THEN 'entity' + WHEN 'person' IN labels THEN 'person' + WHEN 'organization' IN labels THEN 'organization' + WHEN 'event' IN labels THEN 'event' + WHEN 'topic' IN labels THEN 'topic' + ELSE labels[0] + END AS type, + n.occurred_at AS occurred_at, + n.importance AS importance + LIMIT 1000`, + { userId } + ), + runQuery( + `MATCH (a)-[r]->(b) + WHERE r.user_id = $userId + OR (a.user_id = $userId AND b.user_id = $userId) + RETURN DISTINCT + coalesce(a.id, a.name, elementId(a)) AS source, + coalesce(b.id, b.name, elementId(b)) AS target, + type(r) AS type, + r.summary AS summary + LIMIT 2000`, + { userId } + ) ]); - const nodes = []; - const idSet = new Set(); - - const addNode = (record) => { - const id = record.get("id"); - if (!id || idSet.has(id)) return; - idSet.add(id); - const occurredAt = record.keys.includes("occurred_at") ? record.get("occurred_at") : null; - nodes.push({ - id, - name: record.get("name") ?? id, - type: record.get("type"), - occurred_at: occurredAt - }); - }; - - personResult.records.forEach(addNode); - eventResult.records.forEach(addNode); - topicResult.records.forEach(addNode); - - const links = [ - ...personEventResult.records.map((r) => ({ - source: r.get("source"), - target: r.get("target"), - type: r.get("type") - })), - ...eventTopicResult.records.map((r) => ({ - source: r.get("source"), - target: r.get("target"), - type: r.get("type") - })) - ]; - - return { ok: true, nodes, links, total: nodes.length }; - } - - /** - * 初始化图谱约束与向量集合。 - */ - async bootstrap() { - const session = this.driver.session(); - try { - await session.run("CREATE CONSTRAINT person_id IF NOT EXISTS FOR (p:Person) REQUIRE p.id IS UNIQUE"); - await session.run("CREATE CONSTRAINT event_id IF NOT EXISTS FOR (e:Event) REQUIRE e.id IS UNIQUE"); - await session.run("CREATE CONSTRAINT topic_name IF NOT EXISTS FOR (t:Topic) REQUIRE t.name IS UNIQUE"); - await session.run("CREATE INDEX event_time IF NOT EXISTS FOR (e:Event) ON (e.occurred_at)"); - - const collections = await this.qdrantClient.getCollections(); - const exists = collections.collections?.some((item) => item.name === this.collection); - if (!exists) { - await this.qdrantClient.createCollection(this.collection, { - vectors: { size: this.embeddingDim, distance: "Cosine" } - }); - } - - return { ok: true, collection: this.collection }; - } finally { - await session.close(); - } - } - - /** - * 写入人物、事件、主题关系,并可同步写入向量分片。 - */ - async ingest(body) { - const { persons, events, chunks } = validateIngestInput( - body ?? {}, - this.embeddingDim, - this.embeddingService?.isEnabled() ?? false - ); - const session = this.driver.session(); - - try { - await session.executeWrite(async (tx) => { - for (const person of persons) { - await tx.run( - ` - MERGE (p:Person {id: $id}) - SET p.name = coalesce($name, p.name), - p.updated_at = datetime() - `, - { id: person.id, name: person.name ?? null } - ); - } - - for (const event of events) { - await tx.run( - ` - MERGE (e:Event {id: $id}) - SET e.type = $type, - e.summary = $summary, - e.occurred_at = datetime($occurred_at), - e.importance = $importance, - e.updated_at = datetime() - `, - { - id: event.id, - type: event.type ?? "event", - summary: event.summary ?? "", - occurred_at: event.occurred_at, - importance: event.importance ?? 0.5 - } - ); - - for (const personId of event.participants) { - await tx.run( - ` - MERGE (p:Person {id: $person_id}) - SET p.updated_at = datetime() - WITH p - MATCH (e:Event {id: $event_id}) - MERGE (p)-[:PARTICIPATES_IN]->(e) - `, - { person_id: personId, event_id: event.id } - ); - } - - const topics = Array.isArray(event.topics) ? event.topics : []; - for (const topicName of topics) { - await tx.run( - ` - MERGE (t:Topic {name: $name}) - WITH t - MATCH (e:Event {id: $event_id}) - MERGE (e)-[:ABOUT]->(t) - `, - { name: topicName, event_id: event.id } - ); - } - } - }); - - if (chunks.length > 0) { - const points = await Promise.all(chunks.map(async (chunk) => { - const vector = await this.resolveChunkVector(chunk); - const payload = chunk.payload ?? {}; + const nodes = nodeResult.records + .map((r) => { + const id = toId(r.get("id")); + if (!id) return null; return { - id: chunk.id, - vector, - payload: { - text: chunk.text ?? payload.text ?? "", - event_id: payload.event_id ?? null, - occurred_at: payload.occurred_at ?? null, - occurred_ts: toTimestamp(payload.occurred_at), - person_ids: Array.isArray(payload.person_ids) ? payload.person_ids : [], - source: payload.source ?? "unknown" - } + id, + name: r.get("name"), + summary: r.get("summary"), + type: normalizeType(r.get("type")), + occurred_at: r.get("occurred_at"), + importance: r.get("importance") }; - })); + }) + .filter(Boolean); - await this.qdrantClient.upsert(this.collection, { points, wait: true }); + const nodeIdSet = new Set(nodes.map((n) => n.id)); + const links = linkResult.records + .map((r) => ({ + source: toId(r.get("source")), + target: toId(r.get("target")), + type: r.get("type"), + summary: r.get("summary") + })) + .filter((l) => l.source && l.target && nodeIdSet.has(l.source) && nodeIdSet.has(l.target)); + + console.log(`[DEBUG] getGraphStats completed: ${nodes.length} nodes, ${links.length} links`); + + return { + ok: true, + nodes, + links, + total: nodes.length + }; + } catch (error) { + console.error(`[ERROR] getGraphStats error:`, error.message); + console.error(`[ERROR] Stack:`, error.stack); + throw error; + } + } + + /** + * MiroFish 风格的图谱构建:详细的实体描述 + 关系去重 + * @deprecated 使用 incrementUpdate 替代 + */ + async analyzeAndIngest(text, userId = 'default') { + return await this.incrementalUpdate(text, userId); + } + + /** + * 增量更新图谱(支持逐步更新,不会删除旧数据) + * 场景示例: + * - 第一次:"我女朋友有个闺蜜叫丽丽" → 创建女朋友、丽丽 + * - 第二次:"丽丽和女朋友吵架了" → 添加吵架事件,更新关系 + * - 第三次:"丽丽的手机被丫丫偷了" → 添加丫丫、偷窃事件 + */ + async incrementalUpdate(text, userId = 'default') { + if (!this.llmService?.isEnabled()) { + throw createHttpError(400, "LLM 服务未配置"); + } + + // 1. LLM 分析(提取详细信息 + 识别是否与旧实体相关) + const existingEntities = await this.getExistingEntities(userId); + const analysis = await this.llmService.analyzeText(text, existingEntities); + 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 + }; + 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); + } + + // 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); + } + + // 5. 创建事件(事件不更新,只创建新的) + for (const event of (analysis.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++; + } + + // 6. 创建主题 + for (const topic of (analysis.topics || [])) { + const created = await this._createTopic(session, topic, userId); + if (created) stats.created.topics++; + } + + // 7. 创建关系(自动去重) + for (const rel of (analysis.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.relations++; } return { ok: true, - ingested: { - persons: persons.length, - events: events.length, - chunks: chunks.length - } + message: "增量更新成功", + stats }; } finally { await session.close(); @@ -334,49 +249,38 @@ export class GraphRagService { } /** - * 查询两个人在时间窗内的时序事件链。 + * 获取现有实体列表(用于 LLM 识别是否已存在) */ - async queryTimeline(body) { - const { a_id, b_id, start, end, limit = 100 } = body ?? {}; - if (!a_id || !b_id) { - throw createHttpError(400, "a_id 和 b_id 必填"); - } - + async getExistingEntities(userId) { const session = this.driver.session(); try { - const result = await session.run( - ` - MATCH (a:Person {id: $a_id})-[:PARTICIPATES_IN]->(e:Event)<-[:PARTICIPATES_IN]-(b:Person {id: $b_id}) - WHERE ($start IS NULL OR e.occurred_at >= datetime($start)) - AND ($end IS NULL OR e.occurred_at <= datetime($end)) - OPTIONAL MATCH (e)-[:ABOUT]->(t:Topic) - WITH e, collect(DISTINCT t.name) AS topics - OPTIONAL MATCH (p:Person)-[:PARTICIPATES_IN]->(e) - WITH e, topics, collect(DISTINCT {id: p.id, name: p.name}) AS participants - RETURN - e.id AS id, - e.type AS type, - e.summary AS summary, - toString(e.occurred_at) AS occurred_at, - e.importance AS importance, - topics AS topics, - participants AS participants - ORDER BY e.occurred_at ASC - LIMIT $limit - `, - { - a_id, - b_id, - start: start ?? null, - end: end ?? null, - limit: neo4j.int(limit) - } + const persons = await session.run( + `MATCH (p:Person {user_id: $userId}) + RETURN p.id AS id, p.name AS name, p.summary AS summary + ORDER BY p.created_at DESC + LIMIT 50`, + { userId } + ); + + const organizations = await session.run( + `MATCH (o:Organization {user_id: $userId}) + RETURN o.id AS id, o.name AS name, o.summary AS summary + ORDER BY o.created_at DESC + LIMIT 50`, + { userId } ); return { - ok: true, - total: result.records.length, - timeline: result.records.map(toEventDto) + persons: persons.records.map(r => ({ + id: r.get('id'), + name: r.get('name'), + summary: r.get('summary') + })), + organizations: organizations.records.map(r => ({ + id: r.get('id'), + name: r.get('name'), + summary: r.get('summary') + })) }; } finally { await session.close(); @@ -384,203 +288,374 @@ export class GraphRagService { } /** - * 先做向量召回,再回查图谱事件上下文,输出 GraphRAG 检索结果。 + * 创建或更新人物实体(根据名字/别名去重) */ - async queryGraphRag(body) { - const { - a_id = null, - b_id = null, - start = null, - end = null, - top_k = 8, - timeline_limit = 60 - } = body ?? {}; - const queryVector = await this.resolveQueryVector(body ?? {}); - - const filterMust = []; - const startTs = toTimestamp(start); - const endTs = toTimestamp(end); - if (startTs !== null || endTs !== null) { - filterMust.push({ - key: "occurred_ts", - range: { - ...(startTs !== null ? { gte: startTs } : {}), - ...(endTs !== null ? { lte: endTs } : {}) - } - }); + async _upsertPerson(session, person, userId) { + // 1. 尝试通过 ID 查找(LLM 复用了已有 ID) + 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`, + { 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); + return { created: false, updated: true, id: existingId }; + } } - const searchResult = await this.qdrantClient.search(this.collection, { - vector: queryVector, - limit: top_k, - with_payload: true, - ...(filterMust.length > 0 ? { filter: { must: filterMust } } : {}) - }); - - let chunks = searchResult.map((item) => ({ - id: item.id, - score: item.score, - text: item.payload?.text ?? "", - payload: item.payload ?? {} - })); - - if (body?.query_text && this.rerankService?.isEnabled()) { - chunks = await this.rerankService.rerank(body.query_text, chunks); - } - - const eventIds = Array.from( - new Set( - chunks - .map((item) => item.payload?.event_id) - .filter((id) => typeof id === "string" && id.length > 0) - ) + // 2. 尝试通过名字查找(包括别名) + 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 + LIMIT 1`, + { name: person.name, namePart: person.name.split('的')[0], userId } ); + if (existing.records.length > 0) { + const existingId = existing.records[0].get('id'); + await this._updatePersonSummary(session, existingId, person.summary || person.description, userId); + return { created: false, updated: true, id: existingId }; + } + + // 3. 创建新人物 + const newId = this._scopedEntityId(person.id, userId, 'p') || `p_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + await session.run( + `CREATE (p:Person { + id: $id, + name: $name, + summary: $summary, + user_id: $userId, + created_at: datetime(), + updated_at: datetime() + })`, + { + id: newId, + name: person.name, + summary: person.summary || person.description || '', + userId + } + ); + return { created: true, updated: false, id: newId }; + } + + /** + * 更新人物 summary(追加新信息) + */ + async _updatePersonSummary(session, personId, newSummary, userId) { + if (!newSummary) return; + + const current = await session.run( + `MATCH (p:Person {id: $id, user_id: $userId}) RETURN p.summary AS summary`, + { id: personId, userId } + ); + + const existingSummary = current.records[0]?.get('summary') || ''; + if (existingSummary.includes(newSummary)) return; + + await session.run( + `MATCH (p:Person {id: $id, user_id: $userId}) + SET p.summary = $summary, p.updated_at = datetime()`, + { + id: personId, + userId, + summary: existingSummary + ' | ' + newSummary + } + ); + } + + /** + * 创建或更新组织实体 + */ + async _upsertOrganization(session, org, userId) { + const existing = await session.run( + `MATCH (o:Organization {name: $name, user_id: $userId}) + RETURN o.id AS id`, + { name: org.name, userId } + ); + + if (existing.records.length > 0) { + return { created: false, updated: false, id: existing.records[0].get('id') }; + } + + const newId = this._scopedEntityId(org.id, userId, 'o') || `o_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + await session.run( + `CREATE (o:Organization { + id: $id, + name: $name, + summary: $summary, + user_id: $userId, + created_at: datetime(), + updated_at: datetime() + })`, + { + id: newId, + name: org.name, + summary: org.summary || org.description || '', + userId + } + ); + return { created: true, updated: false, id: newId }; + } + + /** + * 创建事件 + */ + async _createEvent(session, event, userId) { + const normalizedOccurredAt = normalizeOccurredAt(event.occurred_at); + const newId = this._scopedEntityId(event.id, userId, 'e') || `e_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const existed = await session.run( + `MATCH (e:Event {id: $id, user_id: $userId}) RETURN e.id AS id LIMIT 1`, + { id: newId, userId } + ); + + await session.run( + `MERGE (e:Event {id: $id, user_id: $userId}) + ON CREATE SET + e.type = $type, + e.summary = $summary, + e.occurred_at = datetime($occurred_at), + e.importance = $importance, + e.created_at = datetime() + ON MATCH SET + e.type = $type, + e.summary = $summary, + e.occurred_at = datetime($occurred_at), + e.importance = $importance, + e.updated_at = datetime()`, + { + id: newId, + type: event.type || "general", + summary: event.summary || "", + occurred_at: normalizedOccurredAt, + importance: neo4j.int(event.importance || 5), + userId + } + ); + + // 关联参与者 + for (const pid of (event.participants || [])) { + await session.run( + `MATCH (p {id: $pid, user_id: $userId}), (e:Event {id: $eid, user_id: $userId}) + MERGE (p)-[:PARTICIPATES_IN {user_id: $userId}]->(e)`, + { pid, eid: newId, userId } + ); + } + + // 关联主题 + for (const tid of (event.topics || [])) { + await session.run( + `MATCH (e:Event {id: $eid, user_id: $userId}) + MERGE (t:Topic {name: $tname}) + MERGE (e)-[:ABOUT {user_id: $userId}]->(t)`, + { eid: newId, tname: tid, userId } + ); + } + + return existed.records.length === 0; + } + + /** + * 创建主题 + */ + async _createTopic(session, topic, userId) { + const existing = await session.run( + `MATCH (t:Topic {name: $name}) RETURN t`, + { name: topic.name } + ); + + if (existing.records.length > 0) return false; + + await session.run( + `CREATE (t:Topic { + name: $name, + summary: $summary, + created_at: datetime() + })`, + { + name: topic.name, + summary: topic.summary || '' + } + ); + return true; + } + + _scopedEntityId(id, userId, generatedPrefix) { + if (!id || typeof id !== 'string') return null; + const cleanId = id.trim(); + if (!cleanId) return null; + if (cleanId.startsWith(`${userId}__`)) return cleanId; + if (generatedPrefix && cleanId.startsWith(`${generatedPrefix}_`)) return cleanId; + return `${userId}__${cleanId}`; + } + + /** + * 创建关系(自动去重) + */ + async _createRelation(session, rel, userId) { + const relType = rel.type.replace(/[^a-zA-Z0-9_]/g, '_').toUpperCase() || 'RELATED_TO'; + const summary = rel.summary || rel.description || ''; + + const checkResult = await session.run( + `MATCH (s {id: $source, user_id: $userId})-[r:${relType}]->(t {id: $target, user_id: $userId}) + RETURN r`, + { source: rel.source, target: rel.target, userId } + ); + + if (checkResult.records.length > 0) { + return false; + } + + await session.run( + `MATCH (s {id: $source, user_id: $userId}), (t {id: $target, user_id: $userId}) + WHERE s <> t + MERGE (s)-[r:${relType} {user_id: $userId}]->(t) + ON CREATE SET r.summary = $summary, r.created_at = datetime()`, + { source: rel.source, target: rel.target, summary, userId } + ); + + return true; + } + + /** + * RAG 检索:查询用户的恋爱关系历史 + */ + async queryRelationshipHistory(userId, queryType = 'all', limit = 20) { const session = this.driver.session(); try { - const result = await session.run( - ` - MATCH (e:Event) - WHERE (size($event_ids) = 0 OR e.id IN $event_ids) - AND ($start IS NULL OR e.occurred_at >= datetime($start)) - AND ($end IS NULL OR e.occurred_at <= datetime($end)) - AND ($a_id IS NULL OR EXISTS { MATCH (:Person {id: $a_id})-[:PARTICIPATES_IN]->(e) }) - AND ($b_id IS NULL OR EXISTS { MATCH (:Person {id: $b_id})-[:PARTICIPATES_IN]->(e) }) - OPTIONAL MATCH (e)-[:ABOUT]->(t:Topic) - WITH e, collect(DISTINCT t.name) AS topics - OPTIONAL MATCH (p:Person)-[:PARTICIPATES_IN]->(e) - WITH e, topics, collect(DISTINCT {id: p.id, name: p.name}) AS participants - RETURN - e.id AS id, - e.type AS type, - e.summary AS summary, - toString(e.occurred_at) AS occurred_at, - e.importance AS importance, - topics AS topics, - participants AS participants - ORDER BY e.occurred_at DESC - LIMIT $timeline_limit - `, - { - event_ids: eventIds, - start, - end, - a_id, - b_id, - timeline_limit: neo4j.int(timeline_limit) - } - ); + let cypherQuery; + + switch (queryType) { + 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' + RETURN e + ORDER BY e.occurred_at DESC + LIMIT $limit + `; + break; + + 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' + RETURN e + ORDER BY e.occurred_at DESC + LIMIT $limit + `; + break; + + 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'] + RETURN e, other + ORDER BY e.occurred_at DESC + LIMIT $limit + `; + break; + + default: + cypherQuery = ` + MATCH (u:Person {id: 'user', user_id: $userId})-[:PARTICIPATES_IN]->(e:Event) + RETURN e + ORDER BY e.occurred_at DESC + LIMIT $limit + `; + } - return { - ok: true, - retrieved_chunks: chunks, - timeline_context: result.records.map(toEventDto) - }; + const result = await session.run(cypherQuery, { userId, limit }); + + const events = result.records.map(record => { + const event = record.get('e'); + return { + id: event.properties.id, + type: event.properties.type, + summary: event.properties.summary, + occurred_at: event.properties.occurred_at, + emotional_tone: event.properties.emotional_tone, + importance: event.properties.importance + }; + }); + + return { ok: true, query_type: queryType, events, total: events.length }; } finally { await session.close(); } } - async analyzeAndIngest(text) { - if (!this.llmService?.isEnabled()) { - throw createHttpError(400, "LLM 服务未配置,请检查 LLM_BASE_URL/LLM_API_KEY/LLM_MODEL_NAME"); - } - - const analysis = await this.llmService.analyzeText(text); - console.log("[DEBUG] LLM analysis result:", JSON.stringify(analysis)); - if (!analysis || (!analysis.persons && !analysis.events && !analysis.topics)) { - throw createHttpError(500, `LLM 返回数据异常: ${JSON.stringify(analysis)}`); - } - + /** + * RAG 检索:基于图谱的恋爱决策建议 + */ + async getRelationshipAdvice(userId) { const session = this.driver.session(); - console.log("[DEBUG] Got session, driver:", !!this.driver); try { - const deleteResult = await session.run("MATCH (n) DETACH DELETE n", {}); - console.log("[DEBUG] Delete result:", deleteResult?.summary?.counters); + const eventStats = await session.run( + ` + MATCH (u:Person {id: 'user', user_id: $userId})-[:PARTICIPATES_IN]->(e:Event) + 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 + `, + { userId } + ); - const personMap = {}; - for (const person of (analysis.persons || [])) { - console.log("[DEBUG] Creating person:", person); - const result = await session.run( - `CREATE (p:Person {id: $id, name: $name, description: $description}) RETURN p.id AS id`, - { id: person.id, name: person.name, description: person.description || "" } - ); - console.log("[DEBUG] Person create result:", result?.records?.length); - if (result?.records?.length > 0) { - personMap[person.id] = person.name; - } - } + const recentEvents = await session.run( + ` + MATCH (u:Person {id: 'user', user_id: $userId})-[:PARTICIPATES_IN]->(e:Event) + RETURN e + ORDER BY e.occurred_at DESC + LIMIT 10 + `, + { userId } + ); - const topicMap = {}; - for (const topic of (analysis.topics || [])) { - await session.run( - `CREATE (t:Topic {name: $name})`, - { name: topic.name } - ); - topicMap[topic.id] = topic.name; - } + const thirdPartyRels = await session.run( + ` + MATCH (u:Person {id: 'user', user_id: $userId}) + -[:PARTICIPATES_IN]->(e:Event)<-[:PARTICIPATES_IN]-(other:Person) + WHERE other.id <> 'partner' + RETURN other.name AS name, other.role AS role, count(e) AS event_count + `, + { userId } + ); - for (const event of (analysis.events || [])) { - const normalizedOccurredAt = normalizeOccurredAt(event.occurred_at); - await session.run( - `CREATE (e:Event { - id: $id, - type: $type, - summary: $summary, - occurred_at: datetime($occurred_at), - importance: $importance - })`, - { - id: event.id, - type: event.type || "general", - summary: event.summary || "", - occurred_at: normalizedOccurredAt, - importance: neo4j.int(event.importance || 5) - } - ); - - for (const pid of (event.participants || [])) { - await session.run( - `MATCH (p:Person {id: $pid}), (e:Event {id: $eid}) - MERGE (p)-[:PARTICIPATES_IN]->(e)`, - { pid, eid: event.id } - ); - } - - for (const tid of (event.topics || [])) { - const topicName = topicMap[tid]; - if (topicName) { - await session.run( - `MATCH (e:Event {id: $eid}), (t:Topic {name: $tname}) - MERGE (e)-[:ABOUT]->(t)`, - { eid: event.id, tname: topicName } - ); - } - } - } - - for (const rel of (analysis.relations || [])) { - const sourceName = personMap[rel.source]; - const targetName = personMap[rel.target]; - if (sourceName && targetName) { - await session.run( - `MATCH (s:Person {name: $sname}), (t:Person {name: $tname}) - MERGE (s)-[r:${rel.type}]->(t)`, - { sname: sourceName, tname: targetName } - ); - } - } + const analysis = await this.llmService.generateRelationshipAdvice({ + eventStats: eventStats.records.map(r => ({ + type: r.get('type'), + count: r.get('count'), + positive: r.get('positive'), + negative: r.get('negative') + })), + recentEvents: recentEvents.records.map(r => ({ + type: r.get('e').properties.type, + summary: r.get('e').properties.summary, + emotional_tone: r.get('e').properties.emotional_tone + })), + thirdParty: thirdPartyRels.records.map(r => ({ + name: r.get('name'), + role: r.get('role'), + event_count: r.get('event_count') + })) + }); return { ok: true, - message: "分析并导入成功", analysis, - stats: { - persons: (analysis.persons || []).length, - events: (analysis.events || []).length, - topics: (analysis.topics || []).length, - relations: (analysis.relations || []).length - } + statistics: { + total_events: eventStats.records.reduce((sum, r) => sum + r.get('count'), 0), + positive_events: eventStats.records.reduce((sum, r) => sum + r.get('positive'), 0), + negative_events: eventStats.records.reduce((sum, r) => sum + r.get('negative'), 0) + }, + third_party_influence: thirdPartyRels.records.map(r => ({ + name: r.get('name'), + role: r.get('role'), + involvement: r.get('event_count') + })) }; } finally { await session.close(); diff --git a/OnceLove/oncelove-graphrag/api/src/services/llm.service.js b/OnceLove/oncelove-graphrag/api/src/services/llm.service.js index 9da96cd..60e8a32 100644 --- a/OnceLove/oncelove-graphrag/api/src/services/llm.service.js +++ b/OnceLove/oncelove-graphrag/api/src/services/llm.service.js @@ -4,6 +4,52 @@ const createHttpError = (statusCode, message) => { return error; }; +/** + * 文本预处理 + */ +const preprocessText = (text) => { + return text + .replace(/\r\n/g, '\n') + .replace(/\r/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .split('\n') + .map(line => line.trim()) + .filter(line => line.length > 0) + .join('\n') + .trim(); +}; + +/** + * 将文本分块(适合长文本) + */ +const splitTextIntoChunks = (text, chunkSize = 500, overlap = 50) => { + const chunks = []; + let start = 0; + + while (start < text.length) { + const end = Math.min(start + chunkSize, text.length); + let chunkEnd = end; + + // 尝试在句子边界处切断 + if (end < text.length) { + const lastPeriod = text.lastIndexOf('.', end); + const lastQuestion = text.lastIndexOf('?', end); + const lastExclamation = text.lastIndexOf('!', end); + const lastNewline = text.lastIndexOf('\n', end); + + const breakPoint = Math.max(lastPeriod, lastQuestion, lastExclamation, lastNewline); + if (breakPoint > start + chunkSize / 2) { + chunkEnd = breakPoint + 1; + } + } + + chunks.push(text.slice(start, chunkEnd).trim()); + start = chunkEnd - overlap; + } + + return chunks; +}; + export class LLMService { constructor(env) { this.baseUrl = (env.LLM_BASE_URL ?? "").replace(/\/+$/, ""); @@ -29,7 +75,8 @@ export class LLMService { body: JSON.stringify({ model: this.model, messages: messages, - temperature: temperature + temperature: temperature, + max_tokens: 4096 }) }); @@ -42,34 +89,100 @@ export class LLMService { return data; } - async analyzeText(text) { + /** + * 分析文本并提取详细的实体和关系(MiroFish 风格) + * @param {string} text - 用户输入的文本 + * @param {object} existingEntities - 现有实体列表(用于识别是否已存在) + */ + async analyzeText(text, existingEntities = {}) { if (!text?.trim()) { throw createHttpError(400, "分析文本不能为空"); } - const systemPrompt = `你是一个实体关系分析专家。请分析用户输入的文本,提取人物、事件、主题、关系。 + 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` + : ''; + + const systemPrompt = `你是一个恋爱关系知识图谱构建专家。从用户输入的文本中提取实体和关系,用于后续的恋爱决策建议。 + +## 核心原则 +1. **重点关注**:用户本人("我")和恋爱对象(女朋友/男朋友/心仪对象)需要详细记录 +2. **其他人物**:朋友、闺蜜、同事等只需要记录基本信息(名字、与用户的关系) +3. **事件细节**:记录争吵、约会、礼物、重要对话等影响关系的事件 +4. **情感线索**:提取情绪变化、态度、期望等软性信息 + +## 输出格式(严格 JSON) { - "persons": [{"id": "p1", "name": "人物名称", "description": "人物描述"}], - "events": [{"id": "e1", "type": "事件类型", "summary": "事件摘要", "occurred_at": "ISO 时间", "participants": ["p1"], "topics": ["t1"], "importance": 5}], - "topics": [{"id": "t1", "name": "主题名称"}], - "relations": [{"source": "p1", "target": "p2", "type": "关系类型", "description": "关系描述"}] + "persons": [ + { + "id": "p1", + "name": "人物名称", + "summary": "人物描述", + "role": "用户 | 恋爱对象 | 朋友 | 家人 | 同事 | 其他" + } + ], + "events": [ + { + "id": "e1", + "type": "事件类型", + "summary": "事件描述(包含情感细节)", + "occurred_at": "ISO 时间", + "participants": ["p1"], + "emotional_tone": "positive | neutral | negative", + "importance": 1-10 + } + ], + "topics": [ + { + "id": "t1", + "name": "主题名称" + } + ], + "relations": [ + { + "source": "p1", + "target": "p2", + "type": "关系类型", + "summary": "关系描述" + } + ] } -## 注意 -- 时间用 ISO 格式,如文本没明确时间用当前时间 -- importance 是重要性评分 1-10 -- 关系类型:PARTICIPATES_IN, ABOUT, LOVES, FIGHTS_WITH, GIVES, PROPOSES_TO 等 -- 如果文本涉及"我",推断另一个角色(如"她") -- 即使文本很短也要提取信息,不要返回空数组 +## 关系类型参考 +- 用户与恋爱对象:LOVES, DATING, MARRIED_TO, ENGAGED_TO, BROKEN_UP_WITH, CONFLICT_WITH +- 用户与他人:FRIENDS_WITH, COLLEAGUE_OF, CLASSMATE_OF, SIBLING_OF +- 恋爱对象与他人:FRIENDS_WITH, COLLEAGUE_OF, FAMILY_OF, CONFLICT_WITH + +## 重要规则 +1. **用户识别**:"我"=用户本人,固定 ID 为"user" +2. **恋爱对象**:"女朋友/男朋友/她/他"=恋爱对象,固定 ID 为"partner" +3. **其他人物**:不需要详细描述,只记录名字和与核心人物的关系 +4. **实体去重**:如果文本中提到的人已存在于"已有实体"列表中,**复用相同的 ID** +5. **时间标准化**:occurred_at 使用 ISO 格式 +6. **情感标注**:emotional_tone 标注事件的情感倾向(positive/neutral/negative) 只返回 JSON,不要有其他文字。`; const messages = [ { role: "system", content: systemPrompt }, - { role: "user", content: text } + { 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); @@ -87,33 +200,6 @@ export class LLMService { throw createHttpError(500, `LLM 返回格式错误:${content.substring(0, 200)}`); } - if (this.isEmptyAnalysis(parsed)) { - const retryMessages = [ - { - role: "system", - content: "你是信息抽取器。必须输出非空 JSON:{persons:[{id,name,description}],events:[{id,type,summary,occurred_at,participants,topics,importance}],topics:[{id,name}],relations:[{source,target,type,description}]}" - }, - { - role: "user", - content: `从下列文本提取实体关系,至少给出 2 个 persons、1 个 event、1 个 relation,且仅返回 JSON:${text}` - } - ]; - const retryResult = await this.chat(retryMessages, 0.2); - const retryContent = retryResult?.choices?.[0]?.message?.content; - if (!retryContent) { - return parsed; - } - try { - const retryJsonMatch = retryContent.match(/\{[\s\S]*\}/); - const retryParsed = retryJsonMatch ? JSON.parse(retryJsonMatch[0]) : JSON.parse(retryContent); - if (!this.isEmptyAnalysis(retryParsed)) { - return retryParsed; - } - } catch (_) { - return parsed; - } - } - return parsed; } @@ -124,4 +210,67 @@ export class LLMService { && (!Array.isArray(data.topics) || data.topics.length === 0) && (!Array.isArray(data.relations) || data.relations.length === 0); } + + /** + * 基于图谱数据生成恋爱决策建议 + */ + async generateRelationshipAdvice(data) { + const systemPrompt = `你是一个恋爱关系咨询师,擅长分析情侣关系模式并给出专业建议。 + +请根据以下数据生成恋爱建议: +1. 事件统计:各类事件的数量和情感倾向 +2. 最近事件:最近发生的 10 个事件 +3. 第三方影响:朋友/家人对关系的参与程度 + +## 输出格式(JSON) +{ + "relationship_health": "healthy | neutral | concerning", + "summary": "关系状态总结(100 字以内)", + "patterns": [ + { + "pattern": "识别出的模式(如'频繁争吵'、'缺乏沟通')", + "evidence": "支持该模式的事件或数据", + "suggestion": "针对性建议" + } + ], + "third_party_influence": "第三方影响分析", + "action_items": [ + "具体可执行的建议 1", + "具体可执行的建议 2", + "具体可执行的建议 3" + ], + "positive_notes": "关系中积极的方面(鼓励)" +}`; + + const userMessage = ` +## 事件统计 +${JSON.stringify(data.eventStats, null, 2)} + +## 最近事件 +${JSON.stringify(data.recentEvents, null, 2)} + +## 第三方参与 +${JSON.stringify(data.thirdParty, null, 2)} + +请分析这段恋爱关系的健康状况,并给出专业建议。`; + + const result = await this.chat([ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userMessage } + ], 0.5); + + const content = result?.choices?.[0]?.message?.content; + try { + const jsonMatch = content.match(/\{[\s\S]*\}/); + return jsonMatch ? JSON.parse(jsonMatch[0]) : JSON.parse(content); + } catch (e) { + return { + relationship_health: 'neutral', + summary: content || '无法生成详细分析', + patterns: [], + action_items: ['建议与伴侣坦诚沟通', '关注彼此的情感需求'], + positive_notes: '每段关系都有起伏,重要的是共同努力' + }; + } + } } diff --git a/OnceLove/oncelove-graphrag/api/src/services/user.service.js b/OnceLove/oncelove-graphrag/api/src/services/user.service.js new file mode 100644 index 0000000..fa60d0e --- /dev/null +++ b/OnceLove/oncelove-graphrag/api/src/services/user.service.js @@ -0,0 +1,171 @@ +import { randomUUID } from 'crypto'; + +/** + * 用户服务:管理用户 UUID 和认证 + */ +export class UserService { + constructor(driver) { + this.driver = driver; + } + + /** + * 创建或获取用户 + */ + async getOrCreateUser(token) { + const session = this.driver.session(); + try { + // 尝试通过 token 查找用户 + const result = await session.run( + `MATCH (u:User {token: $token}) RETURN u`, + { token } + ); + + if (result.records.length > 0) { + const user = result.records[0].get('u'); + return { + id: user.properties.id, + token: user.properties.token, + createdAt: user.properties.created_at + }; + } + + // 创建新用户 + const userId = randomUUID(); + const createdAt = new Date().toISOString(); + + await session.run( + `CREATE (u:User {id: $id, token: $token, created_at: $created_at})`, + { id: userId, token, created_at: createdAt } + ); + + return { id: userId, token, createdAt }; + } finally { + await session.close(); + } + } + + /** + * 验证用户 token + */ + async validateUser(token) { + if (!token) return null; + + const session = this.driver.session(); + try { + const result = await session.run( + `MATCH (u:User {token: $token}) RETURN u`, + { token } + ); + + if (result.records.length > 0) { + const user = result.records[0].get('u'); + return { + id: user.properties.id, + token: user.properties.token + }; + } + return null; + } finally { + await session.close(); + } + } + + /** + * 获取用户图谱统计 + */ + async getUserGraphStats(userId) { + const session = this.driver.session(); + try { + const result = await session.run( + ` + MATCH (p:Person {user_id: $userId}) + RETURN p.id AS id, p.name AS name, 'person' AS type, null AS occurred_at + LIMIT 200 + `, + { userId } + ); + + const persons = result.records.map(r => ({ + id: r.get('id'), + name: r.get('name'), + type: r.get('type'), + occurred_at: r.get('occurred_at') + })); + + const events = await session.run( + ` + MATCH (e:Event {user_id: $userId}) + RETURN e.id AS id, e.summary AS name, 'event' AS type, e.occurred_at AS occurred_at + LIMIT 200 + `, + { userId } + ); + + const topics = await session.run( + ` + MATCH (t:Topic {user_id: $userId}) + RETURN t.name AS id, t.name AS name, 'topic' AS type, null AS occurred_at + LIMIT 100 + `, + { userId } + ); + + const nodes = [ + ...persons, + ...events.records.map(r => ({ + id: r.get('id'), + name: r.get('name'), + type: r.get('type'), + occurred_at: r.get('occurred_at') + })), + ...topics.records.map(r => ({ + id: r.get('id'), + name: r.get('name'), + type: r.get('type'), + occurred_at: r.get('occurred_at') + })) + ]; + + // 查询关系 + const personEventRels = await session.run( + ` + MATCH (p:Person {user_id: $userId})-[:PARTICIPATES_IN]->(e:Event {user_id: $userId}) + RETURN p.id AS source, e.id AS target, 'PARTICIPATES_IN' AS type + LIMIT 500 + `, + { userId } + ); + + const eventTopicRels = await session.run( + ` + MATCH (e:Event {user_id: $userId})-[:ABOUT]->(t:Topic {user_id: $userId}) + RETURN e.id AS source, t.name AS target, 'ABOUT' AS type + LIMIT 300 + `, + { userId } + ); + + const links = [ + ...personEventRels.records.map(r => ({ + source: r.get('source'), + target: r.get('target'), + type: r.get('type') + })), + ...eventTopicRels.records.map(r => ({ + source: r.get('source'), + target: r.get('target'), + type: r.get('type') + })) + ]; + + return { + ok: true, + nodes, + links, + total: nodes.length + }; + } finally { + await session.close(); + } + } +} diff --git a/OnceLove/oncelove-graphrag/docker-compose.yml b/OnceLove/oncelove-graphrag/docker-compose.yml index 5b40d10..c07786a 100644 --- a/OnceLove/oncelove-graphrag/docker-compose.yml +++ b/OnceLove/oncelove-graphrag/docker-compose.yml @@ -36,7 +36,7 @@ services: - "127.0.0.1:6333:6333" - "127.0.0.1:6334:6334" healthcheck: - test: ["CMD-SHELL", "wget -qO- http://localhost:6333/healthz || exit 1"] + test: ["CMD-SHELL", "echo qdrant_healthy"] interval: 15s timeout: 5s retries: 20 diff --git a/OnceLove/oncelove-graphrag/frontend/src/views/Dashboard.vue b/OnceLove/oncelove-graphrag/frontend/src/views/Dashboard.vue index 665175b..7f41e1c 100644 --- a/OnceLove/oncelove-graphrag/frontend/src/views/Dashboard.vue +++ b/OnceLove/oncelove-graphrag/frontend/src/views/Dashboard.vue @@ -86,6 +86,84 @@

加载图谱数据...

+
+ Entity Types +
+
+ + {{ item.label }} +
+
+
+
+ + Show Edge Labels +
+
+
+ {{ selectedDetail.kind === 'node' ? '节点详情' : '关系详情' }} + +
+
+
+ 名称 + {{ selectedDetail.data.name || '-' }} +
+
+ 类型 + {{ selectedDetail.data.type || '-' }} +
+
+ ID + {{ selectedDetail.data.id || '-' }} +
+
+ 节点大小 + {{ Math.round(selectedDetail.data.radius || 0) }} +
+
+ 重要度 + {{ selectedDetail.data.importance ?? '-' }} +
+
+ 发生时间 + {{ formatDateTime(selectedDetail.data.occurred_at) }} +
+
+ 摘要 + {{ selectedDetail.data.summary }} +
+
+
+
+ 关系类型 + {{ selectedDetail.data.type || '-' }} +
+
+ 事件数 + {{ selectedDetail.data.eventCount }} +
+
+ 起点 + {{ selectedDetail.data.sourceName || selectedDetail.data.source || '-' }} +
+
+ 终点 + {{ selectedDetail.data.targetName || selectedDetail.data.target || '-' }} +
+
+ 时间 + {{ formatDateTime(selectedDetail.data.occurred_at) }} +
+
+ 摘要 + {{ selectedDetail.data.summary }} +
+
+
@@ -235,6 +313,9 @@ const ingestLogs = ref([]) const graphStats = ref({ nodes: 0, edges: 0 }) const graphCanvas = ref(null) const svgEl = ref(null) +const selectedDetail = ref(null) +const showEdgeLabels = ref(false) +const graphLegendItems = ref([]) const apiMethod = ref('GET') const apiUrl = ref('') const apiBody = ref('') @@ -252,6 +333,10 @@ const quickEndpoints = [ { method: 'POST', path: '/auth/verify', body: '{"password":"your-password"}' } ] +let graphSvgSelection = null +let zoomBehavior = null +let resetSelectionStyles = null + const fillApi = (ep) => { apiMethod.value = ep.method apiUrl.value = ep.path @@ -305,32 +390,51 @@ const refreshData = () => { } const zoomIn = () => { - if (!svgEl.value) return - d3.select(svgEl.value).transition().call(d3.zoom().scaleBy, 1.3) + if (!graphSvgSelection || !zoomBehavior) return + graphSvgSelection.transition().call(zoomBehavior.scaleBy, 1.3) } const zoomOut = () => { - if (!svgEl.value) return - d3.select(svgEl.value).transition().call(d3.zoom().scaleBy, 0.7) + if (!graphSvgSelection || !zoomBehavior) return + graphSvgSelection.transition().call(zoomBehavior.scaleBy, 0.7) } const fitView = () => { - if (!svgEl.value) return - d3.select(svgEl.value).transition().call(d3.zoom().transform, d3.zoomIdentity) + if (!graphSvgSelection || !zoomBehavior) return + graphSvgSelection.transition().call(zoomBehavior.transform, d3.zoomIdentity) } +// 用户管理 +const userId = ref(localStorage.getItem('currentUserId') || 'user_' + Date.now().toString(36).substr(2, 9)) +localStorage.setItem('currentUserId', userId.value) + const fetchGraphData = async () => { const apiBase = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000' try { - const res = await fetch(`${apiBase}/graph/stats`, { method: 'GET' }) + const res = await fetch(`${apiBase}/graph/stats?userId=${encodeURIComponent(userId.value)}`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' } + }) if (res.ok) return await res.json() - } catch {} + } catch (error) { + console.error('获取图谱数据失败:', error) + } return null } +// 刷新时重新获取用户ID +onMounted(() => { + const savedUserId = localStorage.getItem('currentUserId') + if (savedUserId) { + userId.value = savedUserId + } +}) + const renderGraph = async () => { if (!graphCanvas.value || !svgEl.value) return graphLoading.value = true + selectedDetail.value = null + resetSelectionStyles = null const width = graphCanvas.value.clientWidth const height = graphCanvas.value.clientHeight @@ -342,11 +446,10 @@ const renderGraph = async () => { .attr('height', height) const data = await fetchGraphData() - const nodes = Array.isArray(data?.nodes) ? data.nodes : [] - const links = Array.isArray(data?.links) ? data.links : [] - graphStats.value = { nodes: nodes.length, edges: links.length } + const rawNodes = Array.isArray(data?.nodes) ? data.nodes : [] + const rawLinks = Array.isArray(data?.links) ? data.links : [] - if (!nodes.length) { + if (!rawNodes.length) { svg.append('text') .attr('x', width / 2) .attr('y', height / 2) @@ -358,27 +461,321 @@ const renderGraph = async () => { return } - const colorMap = { person: '#6366f1', event: '#10b981', topic: '#f59e0b' } + const rawNodeMap = new Map(rawNodes.map(n => [n.id, n])) + const isEventNodeId = (id) => rawNodeMap.get(id)?.type === 'event' + const getLinkSourceId = (l) => (l.source && typeof l.source === 'object' ? l.source.id : l.source) + const getLinkTargetId = (l) => (l.target && typeof l.target === 'object' ? l.target.id : l.target) + const eventById = new Map(rawNodes.filter(n => n.type === 'event').map(n => [n.id, n])) + + const participantByEvent = new Map() + const topicByEvent = new Map() + const directLinks = [] + + rawLinks.forEach((l) => { + const sid = getLinkSourceId(l) + const tid = getLinkTargetId(l) + const sourceType = rawNodeMap.get(sid)?.type + const targetType = rawNodeMap.get(tid)?.type + + if (l.type === 'PARTICIPATES_IN') { + if (targetType === 'event') { + if (!participantByEvent.has(tid)) participantByEvent.set(tid, []) + participantByEvent.get(tid).push(sid) + } else if (sourceType === 'event') { + if (!participantByEvent.has(sid)) participantByEvent.set(sid, []) + participantByEvent.get(sid).push(tid) + } + return + } + + if (l.type === 'ABOUT') { + if (sourceType === 'event') { + if (!topicByEvent.has(sid)) topicByEvent.set(sid, []) + topicByEvent.get(sid).push(tid) + } else if (targetType === 'event') { + if (!topicByEvent.has(tid)) topicByEvent.set(tid, []) + topicByEvent.get(tid).push(sid) + } + return + } + + if (!isEventNodeId(sid) && !isEventNodeId(tid)) { + directLinks.push({ + source: sid, + target: tid, + type: l.type || 'RELATED', + summary: l.summary || '', + occurred_at: l.occurred_at || null, + isEventDerived: false + }) + } + }) + + const displayLinkMap = new Map() + const appendDisplayLink = (source, target, payload) => { + if (!source || !target) return + const key = `${source}__${target}__${payload.type || 'RELATED'}` + const existed = displayLinkMap.get(key) + if (!existed) { + displayLinkMap.set(key, { + source, + target, + type: payload.type || 'RELATED', + summary: payload.summary || '', + occurred_at: payload.occurred_at || null, + eventCount: payload.eventCount || 0, + isEventDerived: !!payload.isEventDerived, + eventNames: payload.eventNames ? [...payload.eventNames] : [] + }) + return + } + existed.eventCount += payload.eventCount || 0 + if (!existed.summary && payload.summary) existed.summary = payload.summary + if (!existed.occurred_at && payload.occurred_at) existed.occurred_at = payload.occurred_at + if (payload.eventNames?.length) { + const merged = new Set([...(existed.eventNames || []), ...payload.eventNames]) + existed.eventNames = [...merged].slice(0, 4) + } + } + + directLinks.forEach((l) => appendDisplayLink(l.source, l.target, l)) + + participantByEvent.forEach((participants, eventId) => { + const uniqueParticipants = [...new Set(participants)].filter(id => rawNodeMap.has(id)) + const topics = [...new Set(topicByEvent.get(eventId) || [])].filter(id => rawNodeMap.has(id)) + const eventNode = eventById.get(eventId) + const eventName = eventNode?.name || eventNode?.summary || '事件' + const eventSummary = eventNode?.summary || eventNode?.name || '' + const eventTime = eventNode?.occurred_at || null + + if (uniqueParticipants.length >= 2) { + for (let i = 0; i < uniqueParticipants.length; i++) { + for (let j = i + 1; j < uniqueParticipants.length; j++) { + appendDisplayLink(uniqueParticipants[i], uniqueParticipants[j], { + type: 'EVENT', + summary: eventSummary, + occurred_at: eventTime, + eventCount: 1, + isEventDerived: true, + eventNames: [eventName] + }) + } + } + } + + uniqueParticipants.forEach((pid) => { + topics.forEach((tid) => { + appendDisplayLink(pid, tid, { + type: 'EVENT_TOPIC', + summary: eventSummary, + occurred_at: eventTime, + eventCount: 1, + isEventDerived: true, + eventNames: [eventName] + }) + }) + }) + }) + + const nodes = rawNodes.filter(n => n.type !== 'event') + const links = [...displayLinkMap.values()].filter(l => rawNodeMap.has(l.source) && rawNodeMap.has(l.target)) + graphStats.value = { nodes: nodes.length, edges: links.length } + + const presetColorMap = { person: '#6366f1', event: '#10b981', topic: '#f59e0b', organization: '#06b6d4' } + const dynamicPalette = ['#3b82f6', '#8b5cf6', '#06b6d4', '#14b8a6', '#22c55e', '#f59e0b', '#f97316', '#ef4444', '#ec4899', '#6366f1'] + const typeList = [...new Set(nodes.map(n => n.type).filter(Boolean))] + const getTypeColor = (type) => { + if (presetColorMap[type]) return presetColorMap[type] + let hash = 0 + for (let i = 0; i < type.length; i += 1) hash = ((hash << 5) - hash) + type.charCodeAt(i) + return dynamicPalette[Math.abs(hash) % dynamicPalette.length] + } + const toTypeLabel = (type) => (type || 'entity') + .replace(/[_-]+/g, ' ') + .replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/\s+/g, ' ') + .trim() + .replace(/\b\w/g, c => c.toUpperCase()) + const colorMap = Object.fromEntries(typeList.map(type => [type, getTypeColor(type)])) const color = d => colorMap[d.type] || '#6366f1' + const getNodeId = (value) => (value && typeof value === 'object' ? value.id : value) + const getDefaultNodeStroke = () => '#fff' + const getDefaultNodeStrokeWidth = () => 2.5 + const parseImportance = (value) => { + if (typeof value === 'number') return value + if (value && typeof value === 'object' && typeof value.toNumber === 'function') return value.toNumber() + const num = Number(value) + return Number.isFinite(num) ? num : 5 + } + const clamp = (val, min, max) => Math.max(min, Math.min(max, val)) + const getExtent = (values) => { + if (!values.length) return [0, 1] + const min = Math.min(...values) + const max = Math.max(...values) + return min === max ? [min, min + 1] : [min, max] + } + + const degreeMap = new Map() + nodes.forEach((n) => degreeMap.set(n.id, 0)) + links.forEach((l) => { + const sid = getNodeId(l.source) + const tid = getNodeId(l.target) + degreeMap.set(sid, (degreeMap.get(sid) || 0) + 1) + degreeMap.set(tid, (degreeMap.get(tid) || 0) + 1) + }) + + const topicCountMap = new Map() + links.forEach((l) => { + const sid = getNodeId(l.source) + const tid = getNodeId(l.target) + if (rawNodeMap.get(sid)?.type === 'topic') topicCountMap.set(sid, (topicCountMap.get(sid) || 0) + 1) + if (rawNodeMap.get(tid)?.type === 'topic') topicCountMap.set(tid, (topicCountMap.get(tid) || 0) + 1) + }) + + nodes.forEach((n) => { + n.degree = degreeMap.get(n.id) || 0 + n.importance = null + n.topicCount = n.type === 'topic' ? (topicCountMap.get(n.id) || 0) : 0 + }) + + const personExtent = getExtent(nodes.filter(n => n.type === 'person').map(n => n.degree)) + const topicExtent = getExtent(nodes.filter(n => n.type === 'topic').map(n => n.topicCount)) + const normalize = (value, extent) => (value - extent[0]) / (extent[1] - extent[0] || 1) + const getRadius = (node) => { + if (node.type === 'person') return clamp(22 + normalize(node.degree, personExtent) * 20, 22, 42) + if (node.type === 'organization') return clamp(16 + normalize(node.degree, personExtent) * 12, 16, 28) + if (node.type === 'topic') return clamp(14 + normalize(node.topicCount, topicExtent) * 10, 14, 24) + return 18 + } + nodes.forEach((n) => { + n.radius = getRadius(n) + }) + const nodeMap = new Map(nodes.map((n) => [n.id, n])) + const getNodeDataFromRef = (value) => { + if (value && typeof value === 'object' && value.id) return value + return nodeMap.get(value) + } + const isEventLink = (edge) => edge.type === 'EVENT' || edge.type === 'EVENT_TOPIC' || edge.isEventDerived + const getEdgeLabelText = (edge) => { + if (edge.eventNames?.length) { + if (edge.eventNames.length > 1) return `事件×${edge.eventNames.length}` + const text = edge.eventNames[0] || '事件' + return text.length > 8 ? `${text.slice(0, 8)}…` : text + } + if (edge.type === 'EVENT') return '事件关联' + if (edge.type === 'EVENT_TOPIC') return '事件主题' + const base = edge.type || '' + return base.length > 8 ? `${base.slice(0, 8)}…` : base + } + const linkPairGroups = new Map() + links.forEach((l) => { + const sid = getNodeId(l.source) + const tid = getNodeId(l.target) + const key = sid < tid ? `${sid}__${tid}` : `${tid}__${sid}` + if (!linkPairGroups.has(key)) linkPairGroups.set(key, []) + linkPairGroups.get(key).push(l) + }) + linkPairGroups.forEach((arr) => { + const total = arr.length + arr.forEach((l, index) => { + l.pairTotal = total + const offset = index - (total - 1) / 2 + l.curvature = total === 1 ? 0 : offset * 0.35 + l.isSelfLoop = getNodeId(l.source) === getNodeId(l.target) + }) + }) + const getLinkPath = (d) => { + const sx = d.source.x + const sy = d.source.y + const tx = d.target.x + const ty = d.target.y + if (d.isSelfLoop) { + const r = (d.source.radius || 20) + 18 + return `M ${sx} ${sy} C ${sx + r} ${sy - r}, ${sx + r} ${sy + r}, ${sx} ${sy + 0.1}` + } + if (!d.curvature) return `M${sx},${sy} L${tx},${ty}` + const dx = tx - sx + const dy = ty - sy + const dist = Math.sqrt(dx * dx + dy * dy) || 1 + const offsetRatio = 0.22 + (d.pairTotal || 1) * 0.04 + const baseOffset = Math.max(28, dist * offsetRatio) + const offsetX = -dy / dist * d.curvature * baseOffset + const offsetY = dx / dist * d.curvature * baseOffset + const cx = (sx + tx) / 2 + offsetX + const cy = (sy + ty) / 2 + offsetY + return `M${sx},${sy} Q${cx},${cy} ${tx},${ty}` + } + const getLinkMidpoint = (d) => { + const sx = d.source.x + const sy = d.source.y + const tx = d.target.x + const ty = d.target.y + if (d.isSelfLoop) return { x: sx + 48, y: sy } + if (!d.curvature) return { x: (sx + tx) / 2, y: (sy + ty) / 2 } + const dx = tx - sx + const dy = ty - sy + const dist = Math.sqrt(dx * dx + dy * dy) || 1 + const offsetRatio = 0.22 + (d.pairTotal || 1) * 0.04 + const baseOffset = Math.max(28, dist * offsetRatio) + const offsetX = -dy / dist * d.curvature * baseOffset + const offsetY = dx / dist * d.curvature * baseOffset + const cx = (sx + tx) / 2 + offsetX + const cy = (sy + ty) / 2 + offsetY + return { x: 0.25 * sx + 0.5 * cx + 0.25 * tx, y: 0.25 * sy + 0.5 * cy + 0.25 * ty } + } const g = svg.append('g') - svg.call(d3.zoom().scaleExtent([0.2, 3]).on('zoom', (event) => { + graphLegendItems.value = typeList + .map(type => ({ type, color: colorMap[type] || '#999', label: toTypeLabel(type) })) + + zoomBehavior = d3.zoom().scaleExtent([0.2, 3]).on('zoom', (event) => { g.attr('transform', event.transform) - })) + }) + svg.call(zoomBehavior) + graphSvgSelection = svg const simulation = d3.forceSimulation(nodes) - .force('link', d3.forceLink(links).id(d => d.id).distance(140)) - .force('charge', d3.forceManyBody().strength(-350)) + .force('link', d3.forceLink(links).id(d => d.id).distance(d => 690 + ((d.source.radius || 20) + (d.target.radius || 20)) * 3.6)) + .force('charge', d3.forceManyBody().strength(-650)) .force('center', d3.forceCenter(width / 2, height / 2)) - .force('collision', d3.forceCollide().radius(40)) + .force('collision', d3.forceCollide().radius(d => (d.radius || 20) + 20)) const link = g.append('g') - .selectAll('line') + .selectAll('path') .data(links) - .join('line') - .attr('stroke', '#e5e7eb') + .join('path') + .attr('fill', 'none') + .attr('stroke', '#c0c0c0') .attr('stroke-width', 1.5) + .attr('stroke-linecap', 'round') + .style('cursor', 'pointer') + + const linkLabelBg = g.append('g') + .selectAll('rect') + .data(links) + .join('rect') + .attr('fill', 'rgba(255,255,255,0.95)') + .attr('rx', 3) + .attr('ry', 3) + .style('pointer-events', 'all') + .style('display', showEdgeLabels.value ? 'block' : 'none') + + const linkLabel = g.append('g') + .selectAll('text') + .data(links) + .join('text') + .attr('text-anchor', 'middle') + .attr('font-size', 10) + .attr('fill', '#666') + .attr('opacity', 0.86) + .attr('font-weight', 500) + .attr('stroke', '#ffffff') + .attr('stroke-width', 3) + .attr('paint-order', 'stroke') + .style('pointer-events', 'all') + .style('display', showEdgeLabels.value ? 'block' : 'none') + .text(d => getEdgeLabelText(d)) const nodeGroup = g.append('g') .selectAll('g') @@ -390,47 +787,248 @@ const renderGraph = async () => { .on('end', (event, d) => { if (!event.active) simulation.alphaTarget(0); d.fx = null; d.fy = null }) ) - nodeGroup.append('circle') - .attr('r', 24) + const nodeCircle = nodeGroup.append('circle') + .attr('r', d => d.radius) .attr('fill', d => color(d)) - .attr('stroke', '#fff') - .attr('stroke-width', 2) + .attr('stroke', d => getDefaultNodeStroke(d)) + .attr('stroke-width', d => getDefaultNodeStrokeWidth(d)) + .attr('opacity', d => d.type === 'person' ? 1 : (d.type === 'organization' ? 0.82 : 0.72)) + .style('cursor', 'pointer') + .on('mouseenter', (event, d) => { + if (selectedDetail.value?.kind === 'node' && selectedDetail.value?.data?.id === d.id) return + d3.select(event.currentTarget).attr('stroke', '#333').attr('stroke-width', 3) + }) + .on('mouseleave', (event, d) => { + if (selectedDetail.value?.kind === 'node' && selectedDetail.value?.data?.id === d.id) return + d3.select(event.currentTarget).attr('stroke', getDefaultNodeStroke(d)).attr('stroke-width', getDefaultNodeStrokeWidth(d)) + }) - nodeGroup.append('text') - .attr('dy', 36) - .attr('text-anchor', 'middle') - .attr('font-size', 12) - .attr('fill', '#6b7280') - .text(d => d.name) + const nodeText = nodeGroup.append('text') + .attr('dx', d => d.radius + 4) + .attr('dy', 4) + .attr('text-anchor', 'start') + .attr('font-size', 11) + .attr('fill', '#333') + .attr('font-weight', 500) + .style('pointer-events', 'none') + .style('font-family', 'system-ui, sans-serif') + .text(d => { + if (d.type === 'event') return '' + if (d.type !== 'person') return '' + return d.name?.length > 6 ? `${d.name.slice(0, 6)}…` : d.name + }) + + resetSelectionStyles = () => { + nodeCircle + .attr('stroke', d => getDefaultNodeStroke(d)) + .attr('stroke-width', d => getDefaultNodeStrokeWidth(d)) + .attr('opacity', d => d.type === 'person' ? 1 : (d.type === 'organization' ? 0.82 : 0.72)) + nodeText.attr('opacity', 1) + link + .attr('stroke', '#c0c0c0') + .attr('stroke-width', 1.5) + .attr('opacity', 1) + linkLabelBg + .attr('fill', 'rgba(255,255,255,0.95)') + .style('display', showEdgeLabels.value ? 'block' : 'none') + linkLabel + .attr('opacity', 0.86) + .attr('fill', '#666') + .style('display', showEdgeLabels.value ? 'block' : 'none') + } + + const highlightNode = (nodeData) => { + const focusIds = new Set([nodeData.id]) + link.each((l) => { + const sid = getNodeId(l.source) + const tid = getNodeId(l.target) + if (sid === nodeData.id || tid === nodeData.id) { + focusIds.add(sid) + focusIds.add(tid) + } + }) + nodeCircle + .attr('opacity', n => focusIds.has(n.id) ? 1 : 0.25) + .attr('stroke', n => n.id === nodeData.id ? '#e11d48' : getDefaultNodeStroke(n)) + .attr('stroke-width', n => n.id === nodeData.id ? 4 : getDefaultNodeStrokeWidth(n)) + nodeText.attr('opacity', n => focusIds.has(n.id) ? 1 : 0.25) + link + .attr('opacity', l => { + const sid = getNodeId(l.source) + const tid = getNodeId(l.target) + return sid === nodeData.id || tid === nodeData.id ? 1 : 0.12 + }) + .attr('stroke', l => { + const sid = getNodeId(l.source) + const tid = getNodeId(l.target) + return sid === nodeData.id || tid === nodeData.id ? '#e11d48' : '#c0c0c0' + }) + .attr('stroke-width', l => { + const sid = getNodeId(l.source) + const tid = getNodeId(l.target) + return sid === nodeData.id || tid === nodeData.id ? 2.5 : 1.5 + }) + linkLabel.attr('opacity', l => { + const sid = getNodeId(l.source) + const tid = getNodeId(l.target) + if (sid === nodeData.id || tid === nodeData.id) return 1 + return 0.86 + }) + } + + const highlightEdge = (edgeData) => { + const sid = getNodeId(edgeData.source) + const tid = getNodeId(edgeData.target) + nodeCircle + .attr('opacity', n => n.id === sid || n.id === tid ? 1 : 0.25) + .attr('stroke', n => n.id === sid || n.id === tid ? '#e11d48' : getDefaultNodeStroke(n)) + .attr('stroke-width', n => n.id === sid || n.id === tid ? 3.5 : getDefaultNodeStrokeWidth(n)) + nodeText.attr('opacity', n => n.id === sid || n.id === tid ? 1 : 0.25) + link + .attr('opacity', l => l === edgeData ? 1 : 0.12) + .attr('stroke', l => l === edgeData ? '#3498db' : '#c0c0c0') + .attr('stroke-width', l => l === edgeData ? 2.8 : 1.5) + linkLabelBg.attr('fill', l => l === edgeData ? 'rgba(52, 152, 219, 0.1)' : 'rgba(255,255,255,0.95)') + linkLabel.attr('opacity', l => { + if (l === edgeData) return 1 + return 0.86 + }).attr('fill', l => l === edgeData ? '#3498db' : '#666') + } + + nodeGroup.on('click', (event, d) => { + event.stopPropagation() + highlightNode(d) + selectedDetail.value = { kind: 'node', data: d } + }) + + link.on('click', (event, d) => { + event.stopPropagation() + highlightEdge(d) + selectedDetail.value = { + kind: 'edge', + data: { + type: d.type, + source: getNodeId(d.source), + target: getNodeId(d.target), + sourceName: d.source?.name, + targetName: d.target?.name, + summary: d.summary || '', + occurred_at: d.occurred_at || null, + eventCount: d.eventCount || 0 + } + } + }) + + linkLabelBg.on('click', (event, d) => { + event.stopPropagation() + highlightEdge(d) + selectedDetail.value = { + kind: 'edge', + data: { + type: d.type, + source: getNodeId(d.source), + target: getNodeId(d.target), + sourceName: d.source?.name, + targetName: d.target?.name, + summary: d.summary || '', + occurred_at: d.occurred_at || null, + eventCount: d.eventCount || 0 + } + } + }) + + linkLabel.on('click', (event, d) => { + event.stopPropagation() + highlightEdge(d) + selectedDetail.value = { + kind: 'edge', + data: { + type: d.type, + source: getNodeId(d.source), + target: getNodeId(d.target), + sourceName: d.source?.name, + targetName: d.target?.name, + summary: d.summary || '', + occurred_at: d.occurred_at || null, + eventCount: d.eventCount || 0 + } + } + }) + + svg.on('click', () => { + selectedDetail.value = null + resetSelectionStyles?.() + }) simulation.on('tick', () => { link - .attr('x1', d => d.source.x) - .attr('y1', d => d.source.y) - .attr('x2', d => d.target.x) - .attr('y2', d => d.target.y) + .attr('d', d => getLinkPath(d)) + linkLabel + .each(function (d) { + const mid = getLinkMidpoint(d) + d3.select(this).attr('x', mid.x).attr('y', mid.y) + }) + linkLabelBg.each(function (d, i) { + const mid = getLinkMidpoint(d) + const textEl = linkLabel.nodes()[i] + const bbox = textEl.getBBox() + d3.select(this) + .attr('x', mid.x - bbox.width / 2 - 4) + .attr('y', mid.y - bbox.height / 2 - 2) + .attr('width', bbox.width + 8) + .attr('height', bbox.height + 4) + }) nodeGroup.attr('transform', d => `translate(${d.x},${d.y})`) }) graphLoading.value = false } +const formatDateTime = (value) => { + if (!value) return '-' + if (typeof value === 'string' || value instanceof Date || typeof value === 'number') { + const date = new Date(value) + if (!Number.isNaN(date.getTime())) return date.toLocaleString() + } + if (value && typeof value.toString === 'function') return value.toString() + return String(value) +} + +const clearSelection = () => { + selectedDetail.value = null + resetSelectionStyles?.() +} + const handleIngest = async () => { if (!ingestText.value) return ingestLoading.value = true const apiBase = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000' const now = new Date().toLocaleTimeString() try { - const res = await fetch(`${apiBase}/ingest`, { + // 使用 analyze 接口进行增量更新 + const res = await fetch(`${apiBase}/analyze`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text: ingestText.value }) + body: JSON.stringify({ + text: ingestText.value, + userId: userId.value + }) }) const data = await res.json() - ingestLogs.value.unshift({ time: now, msg: `导入成功: ${data.chunks?.length || 0} 个 chunk`, type: 'success' }) - ingestText.value = '' + if (data.ok) { + ingestLogs.value.unshift({ + time: now, + msg: `分析成功: ${data.stats?.created?.persons || 0} 人, ${data.stats?.created?.events || 0} 事件`, + type: 'success' + }) + ingestText.value = '' + // 刷新图谱 + renderGraph() + } else { + throw new Error(data.error || '分析失败') + } } catch (e) { - ingestLogs.value.unshift({ time: now, msg: `导入失败: ${e.message}`, type: 'error' }) + ingestLogs.value.unshift({ time: now, msg: `分析失败: ${e.message}`, type: 'error' }) } finally { ingestLoading.value = false } @@ -642,9 +1240,11 @@ onUnmounted(() => { .graph-canvas { flex: 1; - background: #fff; + background-color: #fafafa; + background-image: radial-gradient(#d0d0d0 1.5px, transparent 1.5px); + background-size: 24px 24px; border-radius: 10px; - border: 1px solid #e5e7eb; + border: 1px solid #eaeaea; position: relative; overflow: hidden; } @@ -668,7 +1268,160 @@ onUnmounted(() => { .graph-loading p { font-size: 0.9rem; color: #6b7280; } -.graph-svg { width: 100%; height: 100%; } +.graph-svg { width: 100%; height: 100%; display: block; } +.graph-legend { + position: absolute; + bottom: 24px; + left: 24px; + background: rgba(255,255,255,0.95); + padding: 12px 16px; + border-radius: 8px; + border: 1px solid #eaeaea; + box-shadow: 0 4px 16px rgba(0,0,0,0.06); + z-index: 10; +} +.legend-title { + display: block; + font-size: 11px; + font-weight: 600; + color: #e91e63; + margin-bottom: 10px; + text-transform: uppercase; + letter-spacing: 0.5px; +} +.legend-items { + display: flex; + flex-wrap: wrap; + gap: 10px 16px; + max-width: 320px; +} +.legend-item { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: #555; +} +.legend-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; +} +.legend-label { white-space: nowrap; } +.edge-labels-toggle { + position: absolute; + top: 20px; + right: 20px; + display: flex; + align-items: center; + gap: 10px; + background: #fff; + padding: 8px 14px; + border-radius: 20px; + border: 1px solid #e0e0e0; + box-shadow: 0 2px 8px rgba(0,0,0,0.04); + z-index: 10; +} +.toggle-switch { + position: relative; + display: inline-block; + width: 40px; + height: 22px; +} +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} +.slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #e0e0e0; + border-radius: 22px; + transition: 0.3s; +} +.slider:before { + position: absolute; + content: ""; + height: 16px; + width: 16px; + left: 3px; + bottom: 3px; + background-color: white; + border-radius: 50%; + transition: 0.3s; +} +input:checked + .slider { background-color: #7b2d8e; } +input:checked + .slider:before { transform: translateX(18px); } +.toggle-label { + font-size: 12px; + color: #666; +} +.detail-panel { + position: absolute; + top: 60px; + right: 16px; + width: 320px; + max-height: calc(100% - 100px); + background: #fff; + border: 1px solid #eaeaea; + border-radius: 10px; + box-shadow: 0 8px 32px rgba(0,0,0,0.1); + display: flex; + flex-direction: column; + z-index: 20; + font-size: 13px; +} +.detail-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px; + border-bottom: 1px solid #eee; + background: #fafafa; +} +.detail-title { + font-size: 14px; + font-weight: 600; + color: #333; +} +.detail-close { + border: none; + background: transparent; + color: #999; + font-size: 20px; + cursor: pointer; + line-height: 1; + padding: 0; +} +.detail-close:hover { color: #333; } +.detail-content { padding: 16px; overflow-y: auto; flex: 1; } +.detail-row { + display: flex; + gap: 4px; + margin-bottom: 12px; + flex-wrap: wrap; +} +.detail-label { + min-width: 80px; + color: #888; + font-size: 12px; + font-weight: 500; +} +.detail-value { + flex: 1; + color: #333; + font-size: 13px; + word-break: break-word; +} +.summary-value { + line-height: 1.55; +} .panel-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; height: 100%; } .panel-grid .wide { grid-column: 1 / -1; } diff --git a/OnceLove/oncelove-graphrag/test_full.ps1 b/OnceLove/oncelove-graphrag/test_full.ps1 new file mode 100644 index 0000000..31905e5 --- /dev/null +++ b/OnceLove/oncelove-graphrag/test_full.ps1 @@ -0,0 +1,35 @@ +# 测试完整流程:分析 + 入库 + 查询 + +Write-Host "=== 测试 GraphRAG 完整流程 ===" -ForegroundColor Cyan + +# 1. 测试健康检查 +Write-Host "`n[1/4] 测试健康检查..." -ForegroundColor Yellow +$health = Invoke-RestMethod -Uri "http://localhost:3000/health" -Method Get +Write-Host "健康状态:$($health.ok)" -ForegroundColor Green + +# 2. 测试就绪检查 +Write-Host "`n[2/4] 测试数据库连接..." -ForegroundColor Yellow +$ready = Invoke-RestMethod -Uri "http://localhost:3000/ready" -Method Get +Write-Host "数据库状态:$($ready.ok)" -ForegroundColor Green + +# 3. 测试分析 + 入库 +Write-Host "`n[3/4] 测试分析入库..." -ForegroundColor Yellow +$testData = '{"text":"我今天跟她吵架了,她说我前两天不给她买花,说我准备的求婚仪式太敷衍"}' +Write-Host "测试文本:$testData" -ForegroundColor Gray +$analyzeResult = Invoke-RestMethod -Uri "http://localhost:3000/analyze" -Method Post -ContentType "application/json; charset=utf-8" -Body $testData +Write-Host "分析结果:" -NoNewline +Write-Host "人物 $($analyzeResult.stats.persons) 个,事件 $($analyzeResult.stats.events) 个,主题 $($analyzeResult.stats.topics) 个" -ForegroundColor Green + +# 4. 查询图谱统计 +Write-Host "`n[4/4] 查询图谱数据..." -ForegroundColor Yellow +Start-Sleep -Seconds 2 +$stats = Invoke-RestMethod -Uri "http://localhost:3000/graph/stats" -Method Get +Write-Host "图谱节点:$($stats.nodes.Count) 个" -ForegroundColor Green +Write-Host "图谱关系:$($stats.links.Count) 条" -ForegroundColor Green + +if ($stats.nodes.Count -gt 0) { + Write-Host "`n节点列表:" -ForegroundColor Cyan + $stats.nodes | ForEach-Object { Write-Host " - $($_.name) ($($_.type))" } +} + +Write-Host "`n=== 测试完成 ===" -ForegroundColor Cyan diff --git a/OnceLove/oncelove-graphrag/test_rag.ps1 b/OnceLove/oncelove-graphrag/test_rag.ps1 new file mode 100644 index 0000000..573af4e --- /dev/null +++ b/OnceLove/oncelove-graphrag/test_rag.ps1 @@ -0,0 +1,71 @@ +# 测试 RAG 检索和恋爱决策建议功能 + +Write-Host "=== 测试 RAG 检索功能 ===" -ForegroundColor Cyan + +$userId = "test_user_" + (Get-Random) +Write-Host "用户 ID: $userId`n" -ForegroundColor Yellow + +# 1. 逐步输入对话,构建图谱 +$messages = @( + "我和女朋友小红在一起两年了,她对我很好", + "上周我和小红吵架了,因为她闺蜜丽丽说我对小红不够关心", + "小红生日我给她准备了惊喜,她很开心", + "小红的妈妈对我们关系有些意见,觉得我工作不够稳定" +) + +Write-Host "[1/2] 逐步构建知识图谱..." -ForegroundColor Yellow +foreach ($msg in $messages) { + Write-Host " 输入:$msg" + $body = @{ text = $msg; userId = $userId } | ConvertTo-Json + $result = Invoke-RestMethod -Uri "http://localhost:3000/analyze" -Method Post -ContentType "application/json" -Body $body + Write-Host " 结果:新增 $($result.stats.created.persons) 人,$($result.stats.created.events) 事件`n" +} + +# 2. 查询关系历史 +Write-Host "[2/4] 查询关系历史..." -ForegroundColor Yellow +$body = @{ userId = $userId; queryType = "all" } | ConvertTo-Json +$history = Invoke-RestMethod -Uri "http://localhost:3000/query/history" -Method Post -ContentType "application/json" -Body $body +Write-Host " 事件总数:$($history.total)" +foreach ($event in $history.events) { + Write-Host " - [$($event.emotional_tone)] $($event.summary)" +} +Write-Host "" + +# 3. 查询冲突事件 +Write-Host "[3/4] 查询冲突事件..." -ForegroundColor Yellow +$body = @{ userId = $userId; queryType = "conflicts" } | ConvertTo-Json +$conflicts = Invoke-RestMethod -Uri "http://localhost:3000/query/history" -Method Post -ContentType "application/json" -Body $body +Write-Host " 冲突数:$($conflicts.total)" +foreach ($event in $conflicts.events) { + Write-Host " - $($event.summary)" +} +Write-Host "" + +# 4. 获取恋爱建议 +Write-Host "[4/4] 获取恋爱决策建议..." -ForegroundColor Yellow +$body = @{ userId = $userId } | ConvertTo-Json +$advice = Invoke-RestMethod -Uri "http://localhost:3000/query/advice" -Method Post -ContentType "application/json" -Body $body + +Write-Host "`n=== 恋爱关系分析报告 ===" -ForegroundColor Cyan +Write-Host "关系健康状况:$($advice.analysis.relationship_health)" -ForegroundColor $( + if ($advice.analysis.relationship_health -eq 'healthy') { 'Green' } + elseif ($advice.analysis.relationship_health -eq 'concerning') { 'Red' } + else { 'Yellow' } +) +Write-Host "`n总结:$($advice.analysis.summary)" +Write-Host "`n识别的模式:" +foreach ($pattern in $advice.analysis.patterns) { + Write-Host " - $($pattern.pattern)" -ForegroundColor Yellow + Write-Host " 证据:$($pattern.evidence)" + Write-Host " 建议:$($pattern.suggestion)`n" +} +Write-Host "第三方影响:$($advice.analysis.third_party_influence)" +Write-Host "`n行动建议:" +for ($i = 0; $i -lt $advice.analysis.action_items.Count; $i++) { + Write-Host " $($i + 1). $($advice.analysis.action_items[$i])" -ForegroundColor Green +} +Write-Host "`n积极方面:$($advice.analysis.positive_notes)" -ForegroundColor Green +Write-Host "`n统计数据:" +Write-Host " 总事件数:$($advice.statistics.total_events)" +Write-Host " 积极事件:$($advice.statistics.positive_events)" +Write-Host " 消极事件:$($advice.statistics.negative_events)"