fuli-context 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +178 -0
  3. package/compose.graphiti.yml +86 -0
  4. package/dist/web/assets/ConnectionsPage-CY6H_mJJ.js +1 -0
  5. package/dist/web/assets/KnowledgeInspector-BSpz5Sfm.js +1 -0
  6. package/dist/web/assets/KnowledgeInspector-CHbB_0rC.css +1 -0
  7. package/dist/web/assets/KnowledgeOrganizerPage-BlEvhPrv.css +1 -0
  8. package/dist/web/assets/KnowledgeOrganizerPage-CvjGPlUc.js +1 -0
  9. package/dist/web/assets/KnowledgePage-Khuo3cNs.js +1 -0
  10. package/dist/web/assets/KnowledgeWorkspace-DVZusvem.js +4 -0
  11. package/dist/web/assets/OverviewPage-DC4C4Fhn.js +1 -0
  12. package/dist/web/assets/PersonalProfilePage-CSV9Mtgz.js +1 -0
  13. package/dist/web/assets/PersonalProfilePage-CVJ3TcRi.css +1 -0
  14. package/dist/web/assets/PersonalProjectsPage-A50EaseQ.js +1 -0
  15. package/dist/web/assets/PublicProjectsPage-BAdO_bae.js +1 -0
  16. package/dist/web/assets/ReviewPage-Dztk2jDG.js +1 -0
  17. package/dist/web/assets/console-tbvTUi6p.js +1 -0
  18. package/dist/web/assets/fuli-logo-DGuyqchQ.png +0 -0
  19. package/dist/web/assets/identity-B3s5CXhv.js +1 -0
  20. package/dist/web/assets/index-Devf7glZ.css +1 -0
  21. package/dist/web/assets/index-DwUDhQKc.js +6 -0
  22. package/dist/web/assets/model-DVT8sjy9.js +1 -0
  23. package/dist/web/assets/paths-B790iniO.js +1 -0
  24. package/dist/web/assets/runtime-dom.esm-bundler-DttsK21F.js +1 -0
  25. package/dist/web/assets/vue-router-BYTAoWT3.js +1 -0
  26. package/dist/web/index.html +22 -0
  27. package/graph-provider/Dockerfile +16 -0
  28. package/graph-provider/fuli_graph/__init__.py +5 -0
  29. package/graph-provider/fuli_graph/app.py +547 -0
  30. package/graph-provider/fuli_graph/auth.py +17 -0
  31. package/graph-provider/fuli_graph/collaboration_context.py +290 -0
  32. package/graph-provider/fuli_graph/config.py +34 -0
  33. package/graph-provider/fuli_graph/graph_models.py +123 -0
  34. package/graph-provider/fuli_graph/graph_projection.py +405 -0
  35. package/graph-provider/fuli_graph/graph_query.py +561 -0
  36. package/graph-provider/fuli_graph/knowledge_audit.py +765 -0
  37. package/graph-provider/fuli_graph/knowledge_batch_confirmation.py +246 -0
  38. package/graph-provider/fuli_graph/knowledge_management.py +816 -0
  39. package/graph-provider/fuli_graph/knowledge_records.py +65 -0
  40. package/graph-provider/fuli_graph/knowledge_search.py +723 -0
  41. package/graph-provider/fuli_graph/knowledge_usage_models.py +52 -0
  42. package/graph-provider/fuli_graph/model_validation.py +58 -0
  43. package/graph-provider/fuli_graph/models.py +1179 -0
  44. package/graph-provider/fuli_graph/personal_project_access.py +29 -0
  45. package/graph-provider/fuli_graph/preference_conflicts.py +420 -0
  46. package/graph-provider/fuli_graph/project_action_models.py +142 -0
  47. package/graph-provider/fuli_graph/project_knowledge.py +707 -0
  48. package/graph-provider/fuli_graph/project_management.py +251 -0
  49. package/graph-provider/fuli_graph/provider_values.py +40 -0
  50. package/graph-provider/fuli_graph/runtime.py +350 -0
  51. package/graph-provider/fuli_graph/search_projection.py +156 -0
  52. package/graph-provider/fuli_graph/store.py +24 -0
  53. package/graph-provider/fuli_graph/store_identity.py +237 -0
  54. package/graph-provider/fuli_graph/store_knowledge.py +447 -0
  55. package/graph-provider/fuli_graph/store_projects.py +242 -0
  56. package/graph-provider/fuli_graph/store_publication.py +370 -0
  57. package/graph-provider/fuli_graph/store_records.py +134 -0
  58. package/graph-provider/pyproject.toml +35 -0
  59. package/npm-shrinkwrap.json +6045 -0
  60. package/package.json +99 -0
  61. package/skills/capturing-session-knowledge/SKILL.md +226 -0
  62. package/skills/capturing-session-knowledge/agents/openai.yaml +13 -0
  63. package/skills/grilling-project/SKILL.md +129 -0
  64. package/skills/grilling-project/agents/openai.yaml +4 -0
  65. package/skills/grilling-project/references/project-profile.md +105 -0
  66. package/src/agent/candidate-read-service.js +53 -0
  67. package/src/agent/context-pack-service.js +83 -0
  68. package/src/agent/public-read-policy.js +39 -0
  69. package/src/agent/public-read-service.js +69 -0
  70. package/src/agent/read-projection.js +107 -0
  71. package/src/agent/search-service.js +61 -0
  72. package/src/agent/space-boundary.js +33 -0
  73. package/src/agent-tools/graph-definitions.js +479 -0
  74. package/src/agent-tools/graph-handlers.js +96 -0
  75. package/src/agent-tools/schema.js +37 -0
  76. package/src/agent-tools.js +16 -0
  77. package/src/app/application-error.js +13 -0
  78. package/src/app/bootstrap.js +8 -0
  79. package/src/app/create-application.js +143 -0
  80. package/src/app/state-service.js +38 -0
  81. package/src/candidates.js +78 -0
  82. package/src/classifier.js +63 -0
  83. package/src/cli/command-arguments.js +14 -0
  84. package/src/cli/command-registry.js +78 -0
  85. package/src/cli/commands/candidates.js +17 -0
  86. package/src/cli/commands/ingestion.js +32 -0
  87. package/src/cli/commands/reads.js +50 -0
  88. package/src/cli/commands/spaces.js +20 -0
  89. package/src/cli/invocation.js +47 -0
  90. package/src/cli/local-runtime-command.js +94 -0
  91. package/src/cli/local-runtime-options.js +64 -0
  92. package/src/cli/migrate-command.js +71 -0
  93. package/src/cli/migration-destination.js +117 -0
  94. package/src/cli/setup-agent-selection.js +195 -0
  95. package/src/cli/setup-command.js +85 -0
  96. package/src/cli/uninstall-command.js +67 -0
  97. package/src/cli.js +90 -0
  98. package/src/context-pack.js +206 -0
  99. package/src/defaults.js +1 -0
  100. package/src/extractor.js +77 -0
  101. package/src/fact-history.js +39 -0
  102. package/src/facts/fact-service.js +89 -0
  103. package/src/graphiti/agent-access-policy.js +88 -0
  104. package/src/graphiti/capture-policy.js +84 -0
  105. package/src/graphiti/federated-application.js +1292 -0
  106. package/src/graphiti/personal-context.js +29 -0
  107. package/src/graphiti/project-path-context.js +159 -0
  108. package/src/graphiti/provider-client.js +288 -0
  109. package/src/graphiti/runtime-config.js +85 -0
  110. package/src/graphiti/setup.js +43 -0
  111. package/src/graphiti/source-marker.js +142 -0
  112. package/src/http/api-router.js +133 -0
  113. package/src/http/bounded-json-array.js +19 -0
  114. package/src/http/error-mapping.js +36 -0
  115. package/src/http/graph-api-router.js +257 -0
  116. package/src/http/lens-route.js +50 -0
  117. package/src/http/request-policy.js +41 -0
  118. package/src/http/response.js +29 -0
  119. package/src/http/static-handler.js +55 -0
  120. package/src/ingestion.js +161 -0
  121. package/src/lens/lens-correction-service.js +81 -0
  122. package/src/lens/lens-fact-writer.js +85 -0
  123. package/src/lens/lens-input.js +62 -0
  124. package/src/lens/lens-query-projection.js +87 -0
  125. package/src/lens/lens-query-ranking.js +62 -0
  126. package/src/lens/lens-query.js +143 -0
  127. package/src/lens/lens-resource-projection.js +93 -0
  128. package/src/lens/lens-resource-selection.js +45 -0
  129. package/src/lens/lens-resource-service.js +103 -0
  130. package/src/lens/lens-search-query.js +122 -0
  131. package/src/lens/lens-service.js +183 -0
  132. package/src/local-runtime/lifecycle.js +254 -0
  133. package/src/mcp/create-mcp-server.js +85 -0
  134. package/src/mcp/instructions.js +10 -0
  135. package/src/mcp/project-action-preview-tokens.js +72 -0
  136. package/src/mcp/runtime.js +71 -0
  137. package/src/mcp/tool-annotations.js +63 -0
  138. package/src/mcp/tool-result.js +167 -0
  139. package/src/mcp/tool-schema.js +60 -0
  140. package/src/mcp-server.js +76 -0
  141. package/src/models.js +44 -0
  142. package/src/observer.js +83 -0
  143. package/src/package-metadata.js +6 -0
  144. package/src/project-rules.js +33 -0
  145. package/src/publication/canonical-json.js +39 -0
  146. package/src/publication/outbox-service.js +46 -0
  147. package/src/publication/publication-envelope-schema.js +75 -0
  148. package/src/publication/publication-envelope.js +37 -0
  149. package/src/publication/publication-policy.js +82 -0
  150. package/src/publication/publication-service.js +48 -0
  151. package/src/router.js +58 -0
  152. package/src/runtime-options.js +139 -0
  153. package/src/security/episode-visibility.js +14 -0
  154. package/src/security/fact-visibility.js +15 -0
  155. package/src/security/sensitive-content.js +18 -0
  156. package/src/security/source-metadata-policy.js +8 -0
  157. package/src/server/application-lifecycle.js +34 -0
  158. package/src/server/listen.js +24 -0
  159. package/src/server.js +135 -0
  160. package/src/setup/agent-installation-status.js +97 -0
  161. package/src/setup/agents.js +123 -0
  162. package/src/setup/codex-bootstrap.js +118 -0
  163. package/src/setup/codex-config.js +122 -0
  164. package/src/setup/config-backup.js +22 -0
  165. package/src/setup/container-runtime.js +388 -0
  166. package/src/setup/cursor-config.js +61 -0
  167. package/src/setup/graph-runtime.js +360 -0
  168. package/src/setup/node-runtime.js +24 -0
  169. package/src/setup/options.js +91 -0
  170. package/src/setup/paths.js +47 -0
  171. package/src/setup/session-skill.js +91 -0
  172. package/src/setup/setup.js +85 -0
  173. package/src/setup/uninstall.js +90 -0
  174. package/src/storage/episode-evidence-preview.js +100 -0
  175. package/src/storage/fact-search-order.js +18 -0
  176. package/src/storage/file/episode-evidence.js +37 -0
  177. package/src/storage/file/episode-index.js +59 -0
  178. package/src/storage/file/fact-search.js +60 -0
  179. package/src/storage/file-store.js +437 -0
  180. package/src/storage/import-json.js +70 -0
  181. package/src/storage/json-file.js +41 -0
  182. package/src/storage/legacy-record-validation.js +152 -0
  183. package/src/storage/legacy-snapshot-validation.js +32 -0
  184. package/src/storage/migrate.js +128 -0
  185. package/src/storage/migrations/001-initial.sql +83 -0
  186. package/src/storage/migrations/002-storage-integrity.sql +113 -0
  187. package/src/storage/record-validation.js +46 -0
  188. package/src/storage/snapshot-defaults.js +54 -0
  189. package/src/storage/sqlite/candidates-repository.js +84 -0
  190. package/src/storage/sqlite/episode-evidence-repository.js +154 -0
  191. package/src/storage/sqlite/episode-privacy-sql.js +33 -0
  192. package/src/storage/sqlite/episodes-repository.js +52 -0
  193. package/src/storage/sqlite/fact-search-repository.js +162 -0
  194. package/src/storage/sqlite/facts-repository.js +146 -0
  195. package/src/storage/sqlite/imports-repository.js +39 -0
  196. package/src/storage/sqlite/mapper.js +84 -0
  197. package/src/storage/sqlite/open-database.js +15 -0
  198. package/src/storage/sqlite/outbox-repository.js +103 -0
  199. package/src/storage/sqlite/snapshot-repository.js +69 -0
  200. package/src/storage/sqlite/spaces-repository.js +52 -0
  201. package/src/storage/sqlite/subscriptions-repository.js +54 -0
  202. package/src/storage/sqlite-store.js +159 -0
  203. package/src/storage/stable-json.js +24 -0
  204. package/src/storage/store-port.js +20 -0
  205. package/src/store.js +2 -0
  206. package/src/workspace-protocol/array-validation.js +19 -0
  207. package/src/workspace-protocol/base64url-contract.js +27 -0
  208. package/src/workspace-protocol/constants.js +37 -0
  209. package/src/workspace-protocol/error-contract.js +24 -0
  210. package/src/workspace-protocol/fact-contract.js +110 -0
  211. package/src/workspace-protocol/governance-contract.js +47 -0
  212. package/src/workspace-protocol/index.js +53 -0
  213. package/src/workspace-protocol/json-budget.js +19 -0
  214. package/src/workspace-protocol/manifest-contract.js +186 -0
  215. package/src/workspace-protocol/publication-contract.js +212 -0
  216. package/src/workspace-protocol/query-contract.js +149 -0
  217. package/src/workspace-protocol/scalar-contract.js +192 -0
  218. package/src/workspace-protocol/sync-contract.js +184 -0
  219. package/src/workspace-protocol/text-contract.js +39 -0
  220. package/src/workspace-protocol/validation.js +66 -0
@@ -0,0 +1,405 @@
1
+ from collections.abc import Iterable
2
+
3
+ from .graph_models import GraphEdge, GraphNode
4
+ from .models import ProjectProfile
5
+
6
+
7
+ def management_projection(
8
+ space: dict,
9
+ personal_projects: Iterable[dict] = (),
10
+ project_relations: Iterable[dict] = (),
11
+ ) -> tuple[list[GraphNode], list[GraphEdge]]:
12
+ """Expose Fuli management nodes beside Graphiti knowledge entities."""
13
+ root_id = f"space:{space['id']}"
14
+ root_type = 'ProjectSpace' if space['kind'] == 'project' else 'PersonalSpace'
15
+ fallback = '公共项目空间' if space['kind'] == 'project' else '本机个人知识空间'
16
+ nodes = [GraphNode(
17
+ id=root_id,
18
+ name=space['name'],
19
+ type=root_type,
20
+ group_id=space['group_id'],
21
+ summary=_profile_summary(space.get('profile_json')) or space.get('description') or fallback,
22
+ )]
23
+ edges: list[GraphEdge] = []
24
+
25
+ project_node_ids = {}
26
+ for project in personal_projects:
27
+ project_node_id = f"personal-project:{project['id']}"
28
+ project_node_ids[project.get('project_id', project['id'])] = project_node_id
29
+ nodes.append(GraphNode(
30
+ id=project_node_id,
31
+ name=project['name'],
32
+ type='PersonalProject',
33
+ group_id=space['group_id'],
34
+ summary=_profile_summary(project.get('profile_json')) or '本机个人项目档案',
35
+ attributes={
36
+ 'projectId': project.get('project_id', project['id']),
37
+ 'publicationKey': project.get('publication_key'),
38
+ },
39
+ ))
40
+ edges.append(GraphEdge(
41
+ id=f"contains-project:{project['id']}",
42
+ source=root_id,
43
+ target=project_node_id,
44
+ type='CONTAINS_PROJECT',
45
+ fact=f"个人项目“{project['name']}”保存在该个人空间中。",
46
+ valid_at=None,
47
+ invalid_at=None,
48
+ ))
49
+
50
+ for relation in project_relations:
51
+ source_id = project_node_ids.get(relation['source_project_id'])
52
+ target_id = project_node_ids.get(relation['target_project_id'])
53
+ if not source_id or not target_id:
54
+ continue
55
+ edges.append(GraphEdge(
56
+ id=f"personal-project-relation:{relation['id']}",
57
+ source=source_id,
58
+ target=target_id,
59
+ type=relation['relation_type'],
60
+ fact=_relation_fact(relation),
61
+ valid_at=None,
62
+ invalid_at=None,
63
+ ))
64
+ return nodes, edges
65
+
66
+
67
+ def personal_project_projection(
68
+ space: dict,
69
+ project: dict,
70
+ project_relations: Iterable[dict] = (),
71
+ ) -> tuple[list[GraphNode], list[GraphEdge]]:
72
+ """Expand one local project profile without leaking the rest of the personal graph."""
73
+ profile = ProjectProfile.model_validate_json(project['profile_json'])
74
+ project_id = project['id']
75
+ root_id = f'personal-project:{project_id}'
76
+ group_id = space['group_id']
77
+ nodes = [GraphNode(
78
+ id=root_id,
79
+ name=profile.name,
80
+ type='PersonalProject',
81
+ group_id=group_id,
82
+ summary=profile.purpose or profile.technical_summary or profile.scope or '本机个人项目档案',
83
+ attributes={
84
+ 'projectId': project['project_id'],
85
+ 'lifecycle': profile.lifecycle,
86
+ 'publicationKey': project.get('publication_key'),
87
+ },
88
+ )]
89
+ edges: list[GraphEdge] = []
90
+
91
+ related_node_ids = set()
92
+ for relation in project_relations:
93
+ current_project_id = project['project_id']
94
+ if current_project_id not in {
95
+ relation['source_project_id'], relation['target_project_id']
96
+ }:
97
+ continue
98
+ current_is_source = relation['source_project_id'] == current_project_id
99
+ related_project_id = (
100
+ relation['target_project_id'] if current_is_source
101
+ else relation['source_project_id']
102
+ )
103
+ related_name = relation['target_name'] if current_is_source else relation['source_name']
104
+ related_id = f'personal-project-related:{related_project_id}'
105
+ if related_id not in related_node_ids:
106
+ related_node_ids.add(related_id)
107
+ nodes.append(GraphNode(
108
+ id=related_id,
109
+ name=related_name,
110
+ type='RelatedPersonalProject',
111
+ group_id=group_id,
112
+ summary='与当前个人项目显式关联的项目。',
113
+ attributes={'projectId': related_project_id},
114
+ ))
115
+ edges.append(GraphEdge(
116
+ id=f"personal-project-relation:{relation['id']}",
117
+ source=root_id if current_is_source else related_id,
118
+ target=related_id if current_is_source else root_id,
119
+ type=relation['relation_type'],
120
+ fact=_relation_fact(relation),
121
+ valid_at=None,
122
+ invalid_at=None,
123
+ ))
124
+
125
+ fields = (
126
+ ('purpose', '项目目标', 'ProjectPurpose', profile.purpose, 'HAS_PURPOSE'),
127
+ ('scope', '项目范围', 'ProjectScope', profile.scope, 'HAS_SCOPE'),
128
+ (
129
+ 'technical-summary', '技术说明', 'TechnicalSummary',
130
+ profile.technical_summary, 'HAS_TECHNICAL_SUMMARY',
131
+ ),
132
+ )
133
+ for key, name, node_type, summary, relation in fields:
134
+ if summary:
135
+ _append_profile_node(
136
+ nodes, edges, root_id, group_id, project_id, key,
137
+ name, node_type, summary, relation,
138
+ )
139
+
140
+ for source in profile.sources:
141
+ _append_profile_node(
142
+ nodes, edges, root_id, group_id, project_id, f'source:{source.key}',
143
+ source.title, 'ProjectSource', source.summary or source.uri or '已登记项目资料',
144
+ 'HAS_SOURCE', attributes={
145
+ 'kind': source.kind,
146
+ 'uri': source.uri,
147
+ 'sensitivity': source.sensitivity,
148
+ },
149
+ )
150
+
151
+ for index, boundary in enumerate(profile.boundaries):
152
+ _append_profile_node(
153
+ nodes, edges, root_id, group_id, project_id, f'boundary:{index}',
154
+ _compact_name(boundary), 'ProjectBoundary', boundary, 'GOVERNS',
155
+ )
156
+
157
+ if profile.assessment:
158
+ assessment = profile.assessment
159
+ assessment_id = _append_profile_node(
160
+ nodes, edges, root_id, group_id, project_id, 'assessment',
161
+ f'资料覆盖 {assessment.score}', 'ProjectAssessment',
162
+ _assessment_summary(assessment), 'ASSESSED_AS', attributes={
163
+ 'score': assessment.score,
164
+ 'label': assessment.label,
165
+ 'analyzedAt': assessment.analyzed_at.isoformat(),
166
+ 'confirmed': assessment.confirmed,
167
+ 'inferred': assessment.inferred,
168
+ },
169
+ )
170
+ for dimension in assessment.dimensions:
171
+ _append_profile_node(
172
+ nodes, edges, assessment_id, group_id, project_id,
173
+ f'assessment:{dimension.key}', dimension.label,
174
+ 'AssessmentDimension',
175
+ ';'.join(dimension.evidence[:3]) or dimension.label,
176
+ 'MEASURES', attributes={
177
+ 'score': dimension.score,
178
+ 'state': dimension.state,
179
+ 'evidence': dimension.evidence,
180
+ },
181
+ )
182
+
183
+ return nodes, edges
184
+
185
+
186
+ def coalesce_personal_project_identity(
187
+ project: dict,
188
+ management_nodes: list[GraphNode],
189
+ management_edges: list[GraphEdge],
190
+ knowledge_nodes: list[GraphNode],
191
+ knowledge_edges: list[GraphEdge],
192
+ ) -> tuple[list[GraphNode], list[GraphEdge]]:
193
+ """Render one project root while preserving its Graphiti evidence and relations."""
194
+ return coalesce_personal_project_identities(
195
+ [project],
196
+ management_nodes,
197
+ management_edges,
198
+ knowledge_nodes,
199
+ knowledge_edges,
200
+ )
201
+
202
+
203
+ def coalesce_personal_project_identities(
204
+ projects: Iterable[dict],
205
+ management_nodes: list[GraphNode],
206
+ management_edges: list[GraphEdge],
207
+ knowledge_nodes: list[GraphNode],
208
+ knowledge_edges: list[GraphEdge],
209
+ ) -> tuple[list[GraphNode], list[GraphEdge]]:
210
+ """Use one canonical node per personal project in an aggregate personal graph."""
211
+ identities = [_project_identity(project) for project in projects]
212
+ aliases_by_root: dict[str, list[GraphNode]] = {}
213
+ alias_targets: dict[str, str] = {}
214
+
215
+ for node in knowledge_nodes:
216
+ target = _matching_project_root(node, identities)
217
+ if target is None:
218
+ continue
219
+ alias_targets[node.id] = target
220
+ aliases_by_root.setdefault(target, []).append(node)
221
+
222
+ if not alias_targets:
223
+ return management_nodes + knowledge_nodes, management_edges + knowledge_edges
224
+
225
+ nodes = [
226
+ _merge_project_root(node, aliases_by_root[node.id])
227
+ if node.id in aliases_by_root else node
228
+ for node in management_nodes
229
+ ]
230
+ nodes.extend(node for node in knowledge_nodes if node.id not in alias_targets)
231
+
232
+ edges = list(management_edges)
233
+ for edge in knowledge_edges:
234
+ source = alias_targets.get(edge.source, edge.source)
235
+ target = alias_targets.get(edge.target, edge.target)
236
+ if source == target:
237
+ continue
238
+ edges.append(edge.model_copy(update={'source': source, 'target': target}))
239
+ return nodes, edges
240
+
241
+
242
+ def _project_identity(project: dict) -> dict[str, str]:
243
+ profile = ProjectProfile.model_validate_json(project['profile_json'])
244
+ return {
245
+ 'project_id': project['project_id'],
246
+ 'name': profile.name,
247
+ 'root_id': f"personal-project:{project['id']}",
248
+ }
249
+
250
+
251
+ def _matching_project_root(node: GraphNode, identities: list[dict[str, str]]) -> str | None:
252
+ if node.type != 'Project':
253
+ return None
254
+ explicit_project_id = node.attributes.get(
255
+ 'projectId', node.attributes.get('project_id')
256
+ )
257
+ if explicit_project_id is not None:
258
+ matches = [
259
+ identity for identity in identities
260
+ if identity['project_id'] == str(explicit_project_id)
261
+ ]
262
+ else:
263
+ normalized_name = _normalized_project_name(node.name)
264
+ matches = [
265
+ identity for identity in identities
266
+ if _normalized_project_name(identity['name']) == normalized_name
267
+ ]
268
+ return matches[0]['root_id'] if len(matches) == 1 else None
269
+
270
+
271
+ def _append_profile_node(
272
+ nodes: list[GraphNode],
273
+ edges: list[GraphEdge],
274
+ parent_id: str,
275
+ group_id: str,
276
+ project_id: str,
277
+ key: str,
278
+ name: str,
279
+ node_type: str,
280
+ summary: str,
281
+ relation: str,
282
+ *,
283
+ attributes: dict | None = None,
284
+ ) -> str:
285
+ node_id = f'project-profile:{project_id}:{key}'
286
+ nodes.append(GraphNode(
287
+ id=node_id,
288
+ name=name,
289
+ type=node_type,
290
+ group_id=group_id,
291
+ summary=summary,
292
+ attributes={key: value for key, value in (attributes or {}).items() if value is not None},
293
+ ))
294
+ edges.append(GraphEdge(
295
+ id=f'project-profile-edge:{project_id}:{key}',
296
+ source=parent_id,
297
+ target=node_id,
298
+ type=relation,
299
+ fact=summary,
300
+ valid_at=None,
301
+ invalid_at=None,
302
+ ))
303
+ return node_id
304
+
305
+
306
+ def _merge_project_root(root: GraphNode, aliases: list[GraphNode]) -> GraphNode:
307
+ attributes = {}
308
+ for alias in aliases:
309
+ attributes.update(alias.attributes)
310
+ project_definitions = list(dict.fromkeys(
311
+ alias.summary for alias in aliases if alias.summary
312
+ ))
313
+ if project_definitions:
314
+ attributes['projectDefinition'] = ';'.join(project_definitions)
315
+ attributes.update(root.attributes)
316
+ created_values = [
317
+ node.created_at for node in [root, *aliases] if node.created_at is not None
318
+ ]
319
+ summary = root.summary
320
+ if summary == '本机个人项目档案':
321
+ summary = next((node.summary for node in aliases if node.summary), summary)
322
+ human_alias = max(
323
+ (node for node in aliases if node.human_edited),
324
+ key=lambda node: str(node.last_human_changed_at or node.created_at or ''),
325
+ default=None,
326
+ )
327
+ return root.model_copy(update={
328
+ 'summary': summary,
329
+ 'attributes': attributes,
330
+ 'evidence': _unique_records(root.evidence, aliases, 'evidence'),
331
+ 'revisions': _unique_records(root.revisions, aliases, 'revisions'),
332
+ 'assignments': _unique_records(root.assignments, aliases, 'assignments'),
333
+ 'project_references': _unique_records(
334
+ root.project_references, aliases, 'project_references'
335
+ ),
336
+ 'conflicts': _unique_records(root.conflicts, aliases, 'conflicts'),
337
+ 'audit_events': _unique_records(root.audit_events, aliases, 'audit_events'),
338
+ 'human_edited': human_alias is not None,
339
+ 'human_change_status': (
340
+ human_alias.human_change_status if human_alias else 'none'
341
+ ),
342
+ 'human_change_version': (
343
+ human_alias.human_change_version if human_alias else 0
344
+ ),
345
+ 'last_human_changed_at': (
346
+ human_alias.last_human_changed_at if human_alias else None
347
+ ),
348
+ 'last_agent_viewed_at': (
349
+ human_alias.last_agent_viewed_at if human_alias else None
350
+ ),
351
+ 'last_agent_reviewed_at': (
352
+ human_alias.last_agent_reviewed_at if human_alias else None
353
+ ),
354
+ 'created_at': min(created_values) if created_values else None,
355
+ 'reasoning_summary': root.reasoning_summary or next(
356
+ (node.reasoning_summary for node in aliases if node.reasoning_summary),
357
+ None,
358
+ ),
359
+ })
360
+
361
+
362
+ def _unique_records(existing: list, aliases: list[GraphNode], field: str) -> list:
363
+ records = [*existing]
364
+ for alias in aliases:
365
+ records.extend(getattr(alias, field))
366
+ result = []
367
+ seen = set()
368
+ for record in records:
369
+ record_id = getattr(record, 'id', None)
370
+ if record_id in seen:
371
+ continue
372
+ seen.add(record_id)
373
+ result.append(record)
374
+ return result
375
+
376
+
377
+ def _normalized_project_name(value: str) -> str:
378
+ return ''.join(value.casefold().split())
379
+
380
+
381
+ def _compact_name(value: str) -> str:
382
+ return value if len(value) <= 24 else f'{value[:23]}…'
383
+
384
+
385
+ def _assessment_summary(assessment) -> str:
386
+ recorded = len(assessment.confirmed) + len(assessment.inferred)
387
+ dimensions = sum(1 for dimension in assessment.dimensions if dimension.evidence)
388
+ return f'已记录 {recorded} 项已有信息,覆盖 {dimensions} 个资料维度。'
389
+
390
+
391
+ def _profile_summary(raw_profile: str | None) -> str:
392
+ if not raw_profile:
393
+ return ''
394
+ try:
395
+ profile = ProjectProfile.model_validate_json(raw_profile)
396
+ except ValueError:
397
+ return ''
398
+ return profile.purpose or profile.technical_summary or profile.scope or ''
399
+
400
+
401
+ def _relation_fact(relation: dict) -> str:
402
+ return (
403
+ f"个人项目“{relation['source_name']}”通过 {relation['relation_type']} "
404
+ f"关联“{relation['target_name']}”。"
405
+ )