graph-relevance.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427
  1. import { readFile, listDirectory } from "@/commands/fs"
  2. import type { FileNode } from "@/types/wiki"
  3. import { normalizePath } from "@/lib/path-utils"
  4. import { NOVEL_RELATION_LABELS } from "@/lib/novel/graph-adapter"
  5. // ---------------------------------------------------------------------------
  6. // Types
  7. // ---------------------------------------------------------------------------
  8. /** 带关系类型的出边,用于关系类型匹配信号。 */
  9. interface RelationEdge {
  10. readonly target: string
  11. readonly relation: string
  12. }
  13. interface RetrievalNode {
  14. readonly id: string
  15. readonly title: string
  16. readonly type: string
  17. readonly path: string
  18. readonly sources: readonly string[]
  19. readonly outLinks: ReadonlySet<string>
  20. readonly inLinks: ReadonlySet<string>
  21. /** 带关系类型的出边(从 wiki 实体页的 `- [[target]] — 关系标签` 提取)。 */
  22. readonly relationEdges: readonly RelationEdge[]
  23. }
  24. interface RetrievalGraph {
  25. readonly nodes: ReadonlyMap<string, RetrievalNode>
  26. readonly dataVersion: number
  27. }
  28. // ---------------------------------------------------------------------------
  29. // Constants
  30. // ---------------------------------------------------------------------------
  31. const WIKILINK_REGEX = /\[\[([^\]|]+?)(?:\|[^\]]+?)?\]\]/g
  32. const WEIGHTS = {
  33. directLink: 3.0,
  34. sourceOverlap: 4.0,
  35. commonNeighbor: 1.5,
  36. typeAffinity: 1.0,
  37. relationTypeMatch: 1.5,
  38. } as const
  39. const TYPE_AFFINITY: Record<string, Record<string, number>> = {
  40. entity: { concept: 1.2, entity: 0.8, source: 1.0, synthesis: 1.0, query: 0.8 },
  41. concept: { entity: 1.2, concept: 0.8, source: 1.0, synthesis: 1.2, query: 1.0 },
  42. source: { entity: 1.0, concept: 1.0, source: 0.5, query: 0.8, synthesis: 1.0 },
  43. query: { concept: 1.0, entity: 0.8, synthesis: 1.0, source: 0.8, query: 0.5 },
  44. synthesis: { concept: 1.2, entity: 1.0, source: 1.0, query: 1.0, synthesis: 0.8 },
  45. }
  46. /**
  47. * 关系类型对关联度的贡献权重。
  48. *
  49. * 设计原则:
  50. * - 强关系(敌对/合作/属于):人物间核心关系,高权重
  51. * - 伏笔关系(推进/回收/新增):剧情线索,较高权重
  52. * - 心理关系(怀疑/隐瞒):戏剧冲突来源,较高权重
  53. * - 因果关系(导致/揭示/影响):剧情推动,中权重
  54. * - 弱关系(出场于/发生于/位于):太常见,低权重(避免噪声)
  55. * - 认知关系(知道/不知道):单向认知,中低权重
  56. */
  57. const RELATION_TYPE_AFFINITY: Record<string, number> = {
  58. ENEMY_OF: 1.5,
  59. ALLY_OF: 1.5,
  60. BELONGS_TO: 1.3,
  61. HAS_ITEM: 1.0,
  62. KNOWS: 0.8,
  63. DOES_NOT_KNOW: 0.5,
  64. SUSPECTS: 1.2,
  65. HIDES_FROM: 1.2,
  66. CAUSES: 1.0,
  67. REVEALS: 1.0,
  68. AFFECTS: 0.8,
  69. APPEARS_IN: 0.5,
  70. HAPPENS_IN: 0.5,
  71. LOCATED_AT: 0.6,
  72. ADVANCES_FORESHADOWING: 1.2,
  73. RESOLVES_FORESHADOWING: 1.2,
  74. CREATES_FORESHADOWING: 1.2,
  75. }
  76. // ---------------------------------------------------------------------------
  77. // Module-level cache
  78. // ---------------------------------------------------------------------------
  79. const graphCache = new Map<string, Promise<RetrievalGraph>>()
  80. // ---------------------------------------------------------------------------
  81. // Helpers (pure)
  82. // ---------------------------------------------------------------------------
  83. function flattenMdFiles(nodes: readonly FileNode[]): FileNode[] {
  84. const files: FileNode[] = []
  85. for (const node of nodes) {
  86. if (node.is_dir && node.children) {
  87. files.push(...flattenMdFiles(node.children))
  88. } else if (!node.is_dir && node.name.endsWith(".md")) {
  89. files.push(node)
  90. }
  91. }
  92. return files
  93. }
  94. function fileNameToId(fileName: string): string {
  95. return fileName.replace(/\.md$/, "")
  96. }
  97. function extractFrontmatter(content: string): { title: string; type: string; sources: string[]; isHistorical: boolean } {
  98. const fmMatch = content.match(/^---\n([\s\S]*?)\n---/)
  99. const fm = fmMatch ? fmMatch[1] : ""
  100. const titleMatch = fm.match(/^title:\s*["']?(.+?)["']?\s*$/m)
  101. const typeMatch = fm.match(/^type:\s*["']?(.+?)["']?\s*$/m)
  102. const historicalMatch = fm.match(/^is_historical:\s*(true|false)\s*$/mi)
  103. // Parse sources array from YAML frontmatter
  104. const sources: string[] = []
  105. const sourcesBlockMatch = fm.match(/^sources:\s*\n((?:\s+-\s+.+\n?)*)/m)
  106. if (sourcesBlockMatch) {
  107. const lines = sourcesBlockMatch[1].split("\n")
  108. for (const line of lines) {
  109. const itemMatch = line.match(/^\s+-\s+["']?(.+?)["']?\s*$/)
  110. if (itemMatch) {
  111. sources.push(itemMatch[1])
  112. }
  113. }
  114. } else {
  115. // Single-line: sources: ["a.pdf", "b.pdf"] or sources: [a.pdf]
  116. const inlineMatch = fm.match(/^sources:\s*\[([^\]]*)\]/m)
  117. if (inlineMatch) {
  118. const items = inlineMatch[1].split(",")
  119. for (const item of items) {
  120. const trimmed = item.trim().replace(/^["']|["']$/g, "")
  121. if (trimmed) sources.push(trimmed)
  122. }
  123. }
  124. }
  125. let title = titleMatch ? titleMatch[1].trim() : ""
  126. if (!title) {
  127. const headingMatch = content.match(/^#\s+(.+)$/m)
  128. title = headingMatch ? headingMatch[1].trim() : ""
  129. }
  130. return {
  131. title,
  132. type: typeMatch ? typeMatch[1].trim().toLowerCase() : "other",
  133. sources,
  134. isHistorical: historicalMatch?.[1]?.toLowerCase() === "true",
  135. }
  136. }
  137. function extractWikilinks(content: string): string[] {
  138. const links: string[] = []
  139. const regex = new RegExp(WIKILINK_REGEX.source, "g")
  140. let match: RegExpExecArray | null
  141. while ((match = regex.exec(content)) !== null) {
  142. links.push(match[1].trim())
  143. }
  144. return links
  145. }
  146. /** 把关系标签(中文或英文类型名)转为关系类型枚举。 */
  147. function relationNameToType(name: string): string | undefined {
  148. const normalized = name.trim()
  149. for (const [type, label] of Object.entries(NOVEL_RELATION_LABELS)) {
  150. if (normalized === type || normalized === label) return type
  151. }
  152. return undefined
  153. }
  154. /**
  155. * 从 wiki 实体页内容提取带关系类型的链接。
  156. *
  157. * 匹配格式(与 wiki-graph.ts 的 extractRelationLinks 保持一致):
  158. * - [[target]] — 关系标签
  159. * - [[target]] - 关系标签
  160. * - [[target]] : 关系标签
  161. * - [[target]]:关系标签
  162. *
  163. * 只保留能识别为 NOVEL_RELATION_LABELS 的关系,避免噪声。
  164. */
  165. function extractRelationLinks(content: string): Array<{ target: string; relation: string }> {
  166. const links: Array<{ target: string; relation: string }> = []
  167. const regex = /^\s*-\s*\[\[([^\]|]+?)(?:\|[^\]]+?)?\]\]\s*(?:—|-|:|:)\s*([^\n]+?)\s*$/gm
  168. let match: RegExpExecArray | null
  169. while ((match = regex.exec(content)) !== null) {
  170. const relation = relationNameToType(match[2])
  171. if (relation) links.push({ target: match[1].trim(), relation })
  172. }
  173. return links
  174. }
  175. function resolveTarget(
  176. raw: string,
  177. nodeIds: ReadonlySet<string>,
  178. ): string | null {
  179. if (nodeIds.has(raw)) return raw
  180. const normalized = raw.toLowerCase().replace(/\s+/g, "-")
  181. for (const id of nodeIds) {
  182. const idLower = id.toLowerCase()
  183. if (idLower === normalized) return id
  184. if (idLower === raw.toLowerCase()) return id
  185. if (idLower.replace(/\s+/g, "-") === normalized) return id
  186. }
  187. return null
  188. }
  189. function getNeighbors(node: RetrievalNode): ReadonlySet<string> {
  190. const neighbors = new Set<string>()
  191. for (const id of node.outLinks) neighbors.add(id)
  192. for (const id of node.inLinks) neighbors.add(id)
  193. return neighbors
  194. }
  195. function getNodeDegree(node: RetrievalNode): number {
  196. return node.outLinks.size + node.inLinks.size
  197. }
  198. // ---------------------------------------------------------------------------
  199. // Core API
  200. // ---------------------------------------------------------------------------
  201. export async function buildRetrievalGraph(
  202. projectPath: string,
  203. dataVersion: number = 0,
  204. ): Promise<RetrievalGraph> {
  205. const normalizedProjectPath = normalizePath(projectPath)
  206. const cacheKey = `${normalizedProjectPath}:${dataVersion}`
  207. const cached = graphCache.get(cacheKey)
  208. if (cached) {
  209. return cached
  210. }
  211. const graphPromise = buildRetrievalGraphForProject(normalizedProjectPath, dataVersion).catch((error) => {
  212. graphCache.delete(cacheKey)
  213. throw error
  214. })
  215. graphCache.set(cacheKey, graphPromise)
  216. return graphPromise
  217. }
  218. async function buildRetrievalGraphForProject(
  219. projectPath: string,
  220. dataVersion: number,
  221. ): Promise<RetrievalGraph> {
  222. const wikiRoot = `${projectPath}/wiki`
  223. let tree: FileNode[]
  224. try {
  225. tree = await listDirectory(wikiRoot)
  226. } catch {
  227. return { nodes: new Map(), dataVersion }
  228. }
  229. const mdFiles = flattenMdFiles(tree)
  230. // First pass: read all files and build raw node data
  231. const rawNodes: Array<{
  232. id: string
  233. title: string
  234. type: string
  235. path: string
  236. sources: string[]
  237. rawLinks: string[]
  238. rawRelationLinks: Array<{ target: string; relation: string }>
  239. fileName: string
  240. }> = []
  241. for (const file of mdFiles) {
  242. const id = fileNameToId(file.name)
  243. let content = ""
  244. try {
  245. content = await readFile(file.path)
  246. } catch {
  247. continue
  248. }
  249. const fm = extractFrontmatter(content)
  250. if (fm.isHistorical) {
  251. continue
  252. }
  253. rawNodes.push({
  254. id,
  255. title: fm.title || file.name.replace(/\.md$/, "").replace(/-/g, " "),
  256. type: fm.type,
  257. path: file.path,
  258. sources: fm.sources,
  259. rawLinks: extractWikilinks(content),
  260. rawRelationLinks: extractRelationLinks(content),
  261. fileName: file.name,
  262. })
  263. }
  264. const nodeIds = new Set(rawNodes.map((n) => n.id))
  265. // Second pass: resolve links and build graph nodes
  266. const outLinksMap = new Map<string, Set<string>>()
  267. const inLinksMap = new Map<string, Set<string>>()
  268. const relationEdgesMap = new Map<string, RelationEdge[]>()
  269. for (const id of nodeIds) {
  270. outLinksMap.set(id, new Set())
  271. inLinksMap.set(id, new Set())
  272. relationEdgesMap.set(id, [])
  273. }
  274. for (const raw of rawNodes) {
  275. for (const linkTarget of raw.rawLinks) {
  276. const resolvedId = resolveTarget(linkTarget, nodeIds)
  277. if (resolvedId === null || resolvedId === raw.id) continue
  278. outLinksMap.get(raw.id)!.add(resolvedId)
  279. inLinksMap.get(resolvedId)!.add(raw.id)
  280. }
  281. // 解析关系链接,只保留能解析到目标节点的边
  282. for (const rel of raw.rawRelationLinks) {
  283. const resolvedId = resolveTarget(rel.target, nodeIds)
  284. if (resolvedId === null || resolvedId === raw.id) continue
  285. relationEdgesMap.get(raw.id)!.push({ target: resolvedId, relation: rel.relation })
  286. }
  287. }
  288. // Build immutable nodes map
  289. const nodes = new Map<string, RetrievalNode>()
  290. for (const raw of rawNodes) {
  291. nodes.set(raw.id, {
  292. id: raw.id,
  293. title: raw.title,
  294. type: raw.type,
  295. path: raw.path,
  296. sources: Object.freeze([...raw.sources]),
  297. outLinks: Object.freeze(outLinksMap.get(raw.id) ?? new Set<string>()),
  298. inLinks: Object.freeze(inLinksMap.get(raw.id) ?? new Set<string>()),
  299. relationEdges: Object.freeze(relationEdgesMap.get(raw.id) ?? []),
  300. })
  301. }
  302. const graph: RetrievalGraph = { nodes, dataVersion }
  303. return graph
  304. }
  305. export function calculateRelevance(
  306. nodeA: RetrievalNode,
  307. nodeB: RetrievalNode,
  308. graph: RetrievalGraph,
  309. ): number {
  310. if (nodeA.id === nodeB.id) return 0
  311. // Signal 1: Direct links (weight 3.0)
  312. const forwardLinks = nodeA.outLinks.has(nodeB.id) ? 1 : 0
  313. const backwardLinks = nodeB.outLinks.has(nodeA.id) ? 1 : 0
  314. const directLinkScore = (forwardLinks + backwardLinks) * WEIGHTS.directLink
  315. // Signal 2: Source overlap (weight 4.0)
  316. const sourcesA = new Set(nodeA.sources)
  317. let sharedSourceCount = 0
  318. for (const src of nodeB.sources) {
  319. if (sourcesA.has(src)) sharedSourceCount += 1
  320. }
  321. const sourceOverlapScore = sharedSourceCount * WEIGHTS.sourceOverlap
  322. // Signal 3: Common neighbors - Adamic-Adar (weight 1.5)
  323. const neighborsA = getNeighbors(nodeA)
  324. const neighborsB = getNeighbors(nodeB)
  325. let adamicAdar = 0
  326. for (const neighborId of neighborsA) {
  327. if (neighborsB.has(neighborId)) {
  328. const neighbor = graph.nodes.get(neighborId)
  329. if (neighbor) {
  330. const degree = getNodeDegree(neighbor)
  331. adamicAdar += 1 / Math.log(Math.max(degree, 2))
  332. }
  333. }
  334. }
  335. const commonNeighborScore = adamicAdar * WEIGHTS.commonNeighbor
  336. // Signal 4: Type affinity (weight 1.0)
  337. const affinityMap = TYPE_AFFINITY[nodeA.type]
  338. const typeAffinityScore = (affinityMap?.[nodeB.type] ?? 0.5) * WEIGHTS.typeAffinity
  339. // Signal 5: Relation type match (weight 1.5)
  340. // 遍历 A→B 和 B→A 的带关系类型边,按关系类型权重累加。
  341. // 强关系(敌对/合作/属于)贡献高,弱关系(出场于/位于)贡献低。
  342. let relationTypeWeight = 0
  343. for (const edge of nodeA.relationEdges) {
  344. if (edge.target === nodeB.id) {
  345. relationTypeWeight += RELATION_TYPE_AFFINITY[edge.relation] ?? 0.5
  346. }
  347. }
  348. for (const edge of nodeB.relationEdges) {
  349. if (edge.target === nodeA.id) {
  350. relationTypeWeight += RELATION_TYPE_AFFINITY[edge.relation] ?? 0.5
  351. }
  352. }
  353. const relationTypeMatchScore = relationTypeWeight * WEIGHTS.relationTypeMatch
  354. return directLinkScore + sourceOverlapScore + commonNeighborScore + typeAffinityScore + relationTypeMatchScore
  355. }
  356. export function getRelatedNodes(
  357. nodeId: string,
  358. graph: RetrievalGraph,
  359. limit: number = 5,
  360. ): ReadonlyArray<{ node: RetrievalNode; relevance: number }> {
  361. const sourceNode = graph.nodes.get(nodeId)
  362. if (!sourceNode) return []
  363. const scored: Array<{ node: RetrievalNode; relevance: number }> = []
  364. for (const [id, node] of graph.nodes) {
  365. if (id === nodeId) continue
  366. const relevance = calculateRelevance(sourceNode, node, graph)
  367. if (relevance > 0) {
  368. scored.push({ node, relevance })
  369. }
  370. }
  371. scored.sort((a, b) => b.relevance - a.relevance)
  372. return scored.slice(0, limit)
  373. }
  374. export function clearGraphCache(): void {
  375. graphCache.clear()
  376. }