fuli-context 0.4.0 → 0.7.2
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.
- package/README.en.md +5 -0
- package/README.md +411 -173
- package/README.zh-CN.md +448 -0
- package/compose.graphiti.yml +6 -6
- package/dist/web/assets/AboutPage-DXh7Fwxt.css +1 -0
- package/dist/web/assets/AboutPage-DfnZo7Lo.js +1 -0
- package/dist/web/assets/ConnectionsPage-BloaSKmt.js +1 -0
- package/dist/web/assets/ConnectionsPage-CBHAdZXT.css +1 -0
- package/dist/web/assets/KnowledgeInspector-Bjhdu2mf.css +1 -0
- package/dist/web/assets/KnowledgeInspector-QvNpv0_k.js +1 -0
- package/dist/web/assets/KnowledgeOrganizerPage-Cx1w1jb2.css +1 -0
- package/dist/web/assets/KnowledgeOrganizerPage-DKq1jVD5.js +1 -0
- package/dist/web/assets/KnowledgePage-BWL0jT01.js +1 -0
- package/dist/web/assets/KnowledgeWorkspace-Czq6aZ7b.js +4 -0
- package/dist/web/assets/OverviewPage-5sJoO1Gh.js +1 -0
- package/dist/web/assets/{PersonalProfilePage-S8ZcG2LU.css → PersonalProfilePage-CdmEzyvZ.css} +1 -1
- package/dist/web/assets/PersonalProfilePage-tVChM9ik.js +1 -0
- package/dist/web/assets/PersonalProjectsPage-BPFrup4V.js +1 -0
- package/dist/web/assets/PublicProjectsPage-BPJSeREN.js +1 -0
- package/dist/web/assets/ReviewPage-B7EN817i.js +1 -0
- package/dist/web/assets/SearchableSelect-ClLN0rIb.js +1 -0
- package/dist/web/assets/SettingsPage-C6ouU0sR.css +1 -0
- package/dist/web/assets/SettingsPage-Chd_B3eH.js +1 -0
- package/dist/web/assets/_plugin-vue_export-helper-BDNMzG2s.js +1 -0
- package/dist/web/assets/console-CbDsKrsO.js +1 -0
- package/dist/web/assets/fuli-logo-Bu9WBuQe.js +1 -0
- package/dist/web/assets/fuli-logo-ER1wQ8Kk.png +0 -0
- package/dist/web/assets/i18n-BQcns1PH.js +5 -0
- package/dist/web/assets/identity-B8K4VYg6.js +1 -0
- package/dist/web/assets/index-CB8Xmsc2.js +2 -0
- package/dist/web/assets/index-CZeuPh-U.css +1 -0
- package/dist/web/assets/model-eF52GgPF.js +1 -0
- package/dist/web/assets/paths-hwz-Ac1g.js +1 -0
- package/dist/web/assets/useMinimumLoadingDisplay-BpCyBeko.css +1 -0
- package/dist/web/assets/useMinimumLoadingDisplay-wCgGDmHg.js +1 -0
- package/dist/web/assets/vue-router-BsEp_XwI.js +1 -0
- package/dist/web/index.html +9 -6
- package/docs/external-knowledge-architecture.md +279 -0
- package/examples/external-knowledge/markdown-folder.mjs +235 -0
- package/graph-provider/fuli_graph/app.py +57 -1
- package/graph-provider/fuli_graph/graph_models.py +3 -0
- package/graph-provider/fuli_graph/graph_query.py +62 -16
- package/graph-provider/fuli_graph/knowledge_review.py +612 -0
- package/graph-provider/fuli_graph/knowledge_review_models.py +127 -0
- package/graph-provider/fuli_graph/knowledge_search.py +51 -4
- package/graph-provider/fuli_graph/models.py +19 -0
- package/graph-provider/fuli_graph/runtime.py +15 -0
- package/graph-provider/fuli_graph/store_knowledge.py +2 -1
- package/graph-provider/pyproject.toml +1 -1
- package/npm-shrinkwrap.json +5 -407
- package/package.json +7 -4
- package/skills/capturing-session-knowledge/SKILL.md +24 -4
- package/skills/flreview/SKILL.md +73 -0
- package/skills/flreview/agents/openai.yaml +6 -0
- package/src/agent-tools/graph-definitions.js +79 -4
- package/src/agent-tools/graph-handlers.js +12 -0
- package/src/cli/command-registry.js +3 -3
- package/src/cli/local-runtime-command.js +60 -23
- package/src/cli/local-runtime-options.js +16 -6
- package/src/cli/setup-agent-selection.js +12 -12
- package/src/cli/setup-command.js +41 -18
- package/src/cli/shell-argument.js +10 -0
- package/src/cli/uninstall-command.js +17 -15
- package/src/cli/update-command.js +38 -30
- package/src/external-knowledge/catalog.js +61 -0
- package/src/external-knowledge/conflict-policy.js +58 -0
- package/src/external-knowledge/connected-search.js +166 -0
- package/src/external-knowledge/connectors/custom.js +133 -0
- package/src/external-knowledge/connectors/feishu.js +461 -0
- package/src/external-knowledge/connectors/mcp.js +438 -0
- package/src/external-knowledge/connectors/notion.js +307 -0
- package/src/external-knowledge/connectors/retrieval-api.js +222 -0
- package/src/external-knowledge/document-mapping.js +151 -0
- package/src/external-knowledge/graph-projection.js +17 -0
- package/src/external-knowledge/index.js +8 -0
- package/src/external-knowledge/registry.js +91 -0
- package/src/external-knowledge/runtime.js +68 -0
- package/src/external-knowledge/safe-diagnostic.js +14 -0
- package/src/external-knowledge/service.js +811 -0
- package/src/graphiti/agent-knowledge-workflows.js +7 -2
- package/src/graphiti/federated-application.js +66 -41
- package/src/graphiti/knowledge-item-role.js +21 -0
- package/src/graphiti/knowledge-review.js +43 -0
- package/src/graphiti/project-profile-mapping.js +32 -0
- package/src/graphiti/provider-client.js +22 -1
- package/src/graphiti/source-marker.js +54 -3
- package/src/graphiti/task-knowledge-recall.js +250 -0
- package/src/http/api-router.js +23 -108
- package/src/http/external-knowledge-api-router.js +104 -0
- package/src/http/graph-api-router.js +12 -1
- package/src/http/request-policy.js +38 -3
- package/src/local-runtime/lifecycle.js +37 -16
- package/src/mcp/create-mcp-server.js +1 -0
- package/src/mcp/instructions.js +7 -7
- package/src/mcp/tool-annotations.js +6 -0
- package/src/mcp/tool-result.js +1 -0
- package/src/server/application-lifecycle.js +2 -11
- package/src/server/blocked-ports.js +11 -0
- package/src/server/lan-access.js +52 -0
- package/src/server.js +103 -25
- package/src/setup/agent-installation-status.js +2 -1
- package/src/setup/agents.js +2 -1
- package/src/setup/claude-code-config.js +2 -1
- package/src/setup/codex-bootstrap.js +12 -7
- package/src/setup/container-runtime.js +25 -21
- package/src/setup/graph-runtime.js +219 -58
- package/src/setup/node-runtime.js +3 -3
- package/src/setup/options.js +2 -4
- package/src/setup/paths.js +18 -3
- package/src/setup/review-skill-adapters.js +49 -0
- package/src/setup/setup.js +26 -2
- package/src/setup/uninstall.js +4 -2
- package/src/system/resource-monitor.js +317 -0
- package/src/system/runtime-settings.js +131 -0
- package/src/system/system-service.js +119 -0
- package/dist/web/assets/ConnectionsPage-CCKWm-6p.js +0 -1
- package/dist/web/assets/KnowledgeInspector-BZkB2wJn.js +0 -1
- package/dist/web/assets/KnowledgeInspector-CrGYxEob.css +0 -1
- package/dist/web/assets/KnowledgeOrganizerPage-CJCNI2Wd.css +0 -1
- package/dist/web/assets/KnowledgeOrganizerPage-PXH-TlGt.js +0 -1
- package/dist/web/assets/KnowledgePage-CNQXd15y.js +0 -1
- package/dist/web/assets/KnowledgeWorkspace-CXPE4GCD.js +0 -4
- package/dist/web/assets/OverviewPage-ul7g7HXh.js +0 -1
- package/dist/web/assets/PersonalProfilePage-CC40Pw2L.js +0 -1
- package/dist/web/assets/PersonalProjectsPage-CPIEcShD.js +0 -1
- package/dist/web/assets/PublicProjectsPage-C700sR7z.js +0 -1
- package/dist/web/assets/ReviewPage-7MDDUDgA.js +0 -1
- package/dist/web/assets/console-B_Ena2DS.js +0 -5
- package/dist/web/assets/fuli-logo-DGuyqchQ.png +0 -0
- package/dist/web/assets/identity-CjBwYSMX.js +0 -1
- package/dist/web/assets/index-BQRkOwPx.js +0 -2
- package/dist/web/assets/index-CpLDx3of.css +0 -1
- package/dist/web/assets/model-C-5RVIJ2.js +0 -1
- package/dist/web/assets/paths-BkULqmGI.js +0 -1
- package/dist/web/assets/vue-router-DnD3hUm7.js +0 -1
- package/src/agent/candidate-read-service.js +0 -53
- package/src/agent/context-pack-service.js +0 -83
- package/src/agent/public-read-policy.js +0 -39
- package/src/agent/public-read-service.js +0 -69
- package/src/agent/read-projection.js +0 -107
- package/src/agent/search-service.js +0 -61
- package/src/agent/space-boundary.js +0 -33
- package/src/app/bootstrap.js +0 -8
- package/src/app/create-application.js +0 -143
- package/src/app/state-service.js +0 -38
- package/src/candidates.js +0 -78
- package/src/classifier.js +0 -63
- package/src/cli/command-arguments.js +0 -14
- package/src/context-pack.js +0 -206
- package/src/extractor.js +0 -77
- package/src/fact-history.js +0 -39
- package/src/facts/fact-service.js +0 -89
- package/src/http/bounded-json-array.js +0 -19
- package/src/http/lens-route.js +0 -50
- package/src/ingestion.js +0 -161
- package/src/lens/lens-correction-service.js +0 -81
- package/src/lens/lens-fact-writer.js +0 -85
- package/src/lens/lens-input.js +0 -62
- package/src/lens/lens-query-projection.js +0 -87
- package/src/lens/lens-query-ranking.js +0 -62
- package/src/lens/lens-query.js +0 -143
- package/src/lens/lens-resource-projection.js +0 -93
- package/src/lens/lens-resource-selection.js +0 -45
- package/src/lens/lens-resource-service.js +0 -103
- package/src/lens/lens-search-query.js +0 -122
- package/src/lens/lens-service.js +0 -183
- package/src/models.js +0 -44
- package/src/observer.js +0 -83
- package/src/project-rules.js +0 -33
- package/src/publication/outbox-service.js +0 -46
- package/src/publication/publication-envelope-schema.js +0 -75
- package/src/publication/publication-envelope.js +0 -37
- package/src/publication/publication-policy.js +0 -82
- package/src/publication/publication-service.js +0 -48
- package/src/router.js +0 -58
- package/src/runtime-options.js +0 -138
- package/src/security/episode-visibility.js +0 -14
- package/src/security/fact-visibility.js +0 -15
- package/src/security/source-metadata-policy.js +0 -8
- package/src/storage/episode-evidence-preview.js +0 -100
- package/src/storage/fact-search-order.js +0 -18
- package/src/storage/file/episode-evidence.js +0 -37
- package/src/storage/file/episode-index.js +0 -59
- package/src/storage/file/fact-search.js +0 -60
- package/src/storage/file-store.js +0 -437
- package/src/storage/migrate.js +0 -128
- package/src/storage/migrations/001-initial.sql +0 -83
- package/src/storage/migrations/002-storage-integrity.sql +0 -113
- package/src/storage/record-validation.js +0 -46
- package/src/storage/snapshot-defaults.js +0 -54
- package/src/storage/sqlite/candidates-repository.js +0 -84
- package/src/storage/sqlite/episode-evidence-repository.js +0 -154
- package/src/storage/sqlite/episode-privacy-sql.js +0 -33
- package/src/storage/sqlite/episodes-repository.js +0 -52
- package/src/storage/sqlite/fact-search-repository.js +0 -162
- package/src/storage/sqlite/facts-repository.js +0 -146
- package/src/storage/sqlite/imports-repository.js +0 -39
- package/src/storage/sqlite/mapper.js +0 -84
- package/src/storage/sqlite/open-database.js +0 -15
- package/src/storage/sqlite/outbox-repository.js +0 -103
- package/src/storage/sqlite/snapshot-repository.js +0 -69
- package/src/storage/sqlite/spaces-repository.js +0 -52
- package/src/storage/sqlite/subscriptions-repository.js +0 -54
- package/src/storage/sqlite-store.js +0 -159
- package/src/storage/stable-json.js +0 -24
- package/src/storage/store-port.js +0 -20
- package/src/store.js +0 -2
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from fastapi import HTTPException
|
|
5
|
+
|
|
6
|
+
from .knowledge_review_models import (
|
|
7
|
+
KnowledgeReviewCandidate,
|
|
8
|
+
KnowledgeReviewCandidatePage,
|
|
9
|
+
KnowledgeReviewCandidateRequest,
|
|
10
|
+
KnowledgeReviewDecision,
|
|
11
|
+
KnowledgeReviewFinish,
|
|
12
|
+
KnowledgeReviewProgress,
|
|
13
|
+
KnowledgeReviewRun,
|
|
14
|
+
KnowledgeReviewStart,
|
|
15
|
+
)
|
|
16
|
+
from .personal_project_access import authorize_personal_project
|
|
17
|
+
from .provider_values import native_datetime, stable_uuid
|
|
18
|
+
|
|
19
|
+
# These are Provider ranking conventions, not product requirements. Keeping them
|
|
20
|
+
# here gives every Agent the same deterministic policy and makes tuning explicit.
|
|
21
|
+
LOW_UTILITY_SCORE = 0.25
|
|
22
|
+
LOW_CONFIDENCE_SCORE = 0.55
|
|
23
|
+
REPEATED_SESSION_COUNT = 3
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def start_knowledge_review(
|
|
27
|
+
store,
|
|
28
|
+
actor: dict,
|
|
29
|
+
request: KnowledgeReviewStart,
|
|
30
|
+
*,
|
|
31
|
+
started_at: datetime | None = None,
|
|
32
|
+
) -> KnowledgeReviewRun:
|
|
33
|
+
store._require_personal()
|
|
34
|
+
space = await _personal_space(store, actor, request.personal_space_id, 'maintainer')
|
|
35
|
+
if request.personal_project_id:
|
|
36
|
+
await authorize_personal_project(
|
|
37
|
+
store, actor, space, request.personal_project_id
|
|
38
|
+
)
|
|
39
|
+
scope_key = _scope_key(request.scope, request.personal_project_id)
|
|
40
|
+
changed_at = started_at or datetime.now(timezone.utc)
|
|
41
|
+
active_records, _, _ = await store.runtime.driver.execute_query(
|
|
42
|
+
'''
|
|
43
|
+
MATCH (:FuliSpace {id: $space_id, kind: 'personal'})-
|
|
44
|
+
[:HAS_KNOWLEDGE_REVIEW]->(run:FuliKnowledgeReviewRun {
|
|
45
|
+
scope_key: $scope_key
|
|
46
|
+
})
|
|
47
|
+
WHERE run.status IN ['active', 'paused']
|
|
48
|
+
RETURN run ORDER BY run.updated_at DESC LIMIT 1
|
|
49
|
+
''',
|
|
50
|
+
space_id=space['id'],
|
|
51
|
+
scope_key=scope_key,
|
|
52
|
+
routing_='r',
|
|
53
|
+
)
|
|
54
|
+
if active_records:
|
|
55
|
+
run_value = dict(active_records[0]['run'])
|
|
56
|
+
resumed = run_value['status'] == 'paused'
|
|
57
|
+
if run_value['status'] == 'paused':
|
|
58
|
+
resumed_records, _, _ = await store.runtime.driver.execute_query(
|
|
59
|
+
'''
|
|
60
|
+
MATCH (run:FuliKnowledgeReviewRun {id: $review_id})
|
|
61
|
+
SET run.status = 'active', run.updated_at = $updated_at
|
|
62
|
+
RETURN run
|
|
63
|
+
''',
|
|
64
|
+
review_id=run_value['id'],
|
|
65
|
+
updated_at=changed_at,
|
|
66
|
+
)
|
|
67
|
+
run_value = dict(resumed_records[0]['run'])
|
|
68
|
+
return _review_run(run_value, resumed=resumed)
|
|
69
|
+
|
|
70
|
+
completed_records, _, _ = await store.runtime.driver.execute_query(
|
|
71
|
+
'''
|
|
72
|
+
MATCH (:FuliSpace {id: $space_id, kind: 'personal'})-
|
|
73
|
+
[:HAS_KNOWLEDGE_REVIEW]->(run:FuliKnowledgeReviewRun {
|
|
74
|
+
scope_key: $scope_key, status: 'completed'
|
|
75
|
+
})
|
|
76
|
+
RETURN max(coalesce(run.review_cutoff_at, run.started_at))
|
|
77
|
+
AS previous_completed_at
|
|
78
|
+
''',
|
|
79
|
+
space_id=space['id'],
|
|
80
|
+
scope_key=scope_key,
|
|
81
|
+
routing_='r',
|
|
82
|
+
)
|
|
83
|
+
previous_completed_at = (
|
|
84
|
+
completed_records[0].get('previous_completed_at')
|
|
85
|
+
if completed_records else None
|
|
86
|
+
)
|
|
87
|
+
review_id = stable_uuid(
|
|
88
|
+
space['id'], 'knowledge-review', scope_key, changed_at.isoformat()
|
|
89
|
+
)
|
|
90
|
+
created_records, _, _ = await store.runtime.driver.execute_query(
|
|
91
|
+
'''
|
|
92
|
+
MATCH (space:FuliSpace {id: $space_id, kind: 'personal'})
|
|
93
|
+
MERGE (run:FuliKnowledgeReviewRun {active_key: $active_key})
|
|
94
|
+
ON CREATE SET run.id = $review_id,
|
|
95
|
+
run.personal_space_id = $space_id,
|
|
96
|
+
run.scope = $scope,
|
|
97
|
+
run.personal_project_id = $personal_project_id,
|
|
98
|
+
run.scope_key = $scope_key,
|
|
99
|
+
run.status = 'active',
|
|
100
|
+
run.previous_completed_at = $previous_completed_at,
|
|
101
|
+
run.review_cutoff_at = $started_at,
|
|
102
|
+
run.started_at = $started_at,
|
|
103
|
+
run.updated_at = $started_at,
|
|
104
|
+
run.completed_at = null
|
|
105
|
+
ON MATCH SET run.status = 'active', run.updated_at = $started_at
|
|
106
|
+
MERGE (space)-[:HAS_KNOWLEDGE_REVIEW]->(run)
|
|
107
|
+
RETURN run
|
|
108
|
+
''',
|
|
109
|
+
space_id=space['id'],
|
|
110
|
+
review_id=review_id,
|
|
111
|
+
active_key=f"{space['id']}:{scope_key}",
|
|
112
|
+
scope=request.scope,
|
|
113
|
+
personal_project_id=request.personal_project_id,
|
|
114
|
+
scope_key=scope_key,
|
|
115
|
+
previous_completed_at=previous_completed_at,
|
|
116
|
+
started_at=changed_at,
|
|
117
|
+
)
|
|
118
|
+
return _review_run(dict(created_records[0]['run']))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def list_knowledge_review_candidates(
|
|
122
|
+
store,
|
|
123
|
+
actor: dict,
|
|
124
|
+
request: KnowledgeReviewCandidateRequest,
|
|
125
|
+
) -> KnowledgeReviewCandidatePage:
|
|
126
|
+
space = await _personal_space(store, actor, request.personal_space_id, 'reader')
|
|
127
|
+
run = await _read_review_run(store, space['id'], request.review_id)
|
|
128
|
+
if run.status == 'completed':
|
|
129
|
+
return KnowledgeReviewCandidatePage(
|
|
130
|
+
review=run,
|
|
131
|
+
candidates=[],
|
|
132
|
+
total_candidate_count=0,
|
|
133
|
+
remaining_candidate_count=0,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
entity_rows, _, _ = await store.runtime.driver.execute_query(
|
|
137
|
+
_ENTITY_CANDIDATE_QUERY,
|
|
138
|
+
space_id=space['id'],
|
|
139
|
+
group_id=space['group_id'],
|
|
140
|
+
routing_='r',
|
|
141
|
+
)
|
|
142
|
+
relationship_rows, _, _ = await store.runtime.driver.execute_query(
|
|
143
|
+
_RELATIONSHIP_CANDIDATE_QUERY,
|
|
144
|
+
space_id=space['id'],
|
|
145
|
+
group_id=space['group_id'],
|
|
146
|
+
routing_='r',
|
|
147
|
+
)
|
|
148
|
+
conflict_rows, _, _ = await store.runtime.driver.execute_query(
|
|
149
|
+
_CONFLICT_ITEM_QUERY,
|
|
150
|
+
space_id=space['id'],
|
|
151
|
+
routing_='r',
|
|
152
|
+
)
|
|
153
|
+
decision_rows, _, _ = await store.runtime.driver.execute_query(
|
|
154
|
+
'''
|
|
155
|
+
MATCH (:FuliKnowledgeReviewRun {id: $review_id})-
|
|
156
|
+
[:HAS_REVIEW_DECISION]->(decision:FuliKnowledgeReviewDecision)
|
|
157
|
+
RETURN decision.candidate_key AS candidate_key
|
|
158
|
+
''',
|
|
159
|
+
review_id=run.review_id,
|
|
160
|
+
routing_='r',
|
|
161
|
+
)
|
|
162
|
+
candidates = build_review_candidates(
|
|
163
|
+
[dict(row) for row in [*entity_rows, *relationship_rows]],
|
|
164
|
+
scope=run.scope,
|
|
165
|
+
personal_project_id=run.personal_project_id,
|
|
166
|
+
previous_completed_at=run.previous_completed_at,
|
|
167
|
+
review_cutoff_at=run.review_cutoff_at,
|
|
168
|
+
conflict_item_keys={
|
|
169
|
+
key
|
|
170
|
+
for row in conflict_rows
|
|
171
|
+
for key in row.get('candidate_keys', [])
|
|
172
|
+
if key
|
|
173
|
+
},
|
|
174
|
+
decided_candidate_keys={
|
|
175
|
+
row['candidate_key'] for row in decision_rows
|
|
176
|
+
},
|
|
177
|
+
)
|
|
178
|
+
selected = candidates[:request.limit]
|
|
179
|
+
return KnowledgeReviewCandidatePage(
|
|
180
|
+
review=run,
|
|
181
|
+
candidates=selected,
|
|
182
|
+
total_candidate_count=len(candidates),
|
|
183
|
+
remaining_candidate_count=max(len(candidates) - len(selected), 0),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
async def record_knowledge_review_progress(
|
|
188
|
+
store,
|
|
189
|
+
actor: dict,
|
|
190
|
+
request: KnowledgeReviewProgress,
|
|
191
|
+
*,
|
|
192
|
+
changed_at: datetime | None = None,
|
|
193
|
+
) -> KnowledgeReviewDecision:
|
|
194
|
+
store._require_personal()
|
|
195
|
+
await _personal_space(store, actor, request.personal_space_id, 'maintainer')
|
|
196
|
+
updated_at = changed_at or datetime.now(timezone.utc)
|
|
197
|
+
decision_id = stable_uuid(
|
|
198
|
+
request.personal_space_id,
|
|
199
|
+
'knowledge-review-decision',
|
|
200
|
+
request.review_id,
|
|
201
|
+
request.candidate_key,
|
|
202
|
+
)
|
|
203
|
+
records, _, _ = await store.runtime.driver.execute_query(
|
|
204
|
+
'''
|
|
205
|
+
MATCH (space:FuliSpace {id: $space_id, kind: 'personal'})-
|
|
206
|
+
[:HAS_KNOWLEDGE_REVIEW]->(run:FuliKnowledgeReviewRun {id: $review_id})
|
|
207
|
+
WHERE run.status IN ['active', 'paused']
|
|
208
|
+
MERGE (decision:FuliKnowledgeReviewDecision {id: $decision_id})
|
|
209
|
+
ON CREATE SET decision.created_at = $updated_at
|
|
210
|
+
SET decision.review_id = $review_id,
|
|
211
|
+
decision.candidate_key = $candidate_key,
|
|
212
|
+
decision.outcome = $outcome,
|
|
213
|
+
decision.note = $note,
|
|
214
|
+
decision.updated_at = $updated_at,
|
|
215
|
+
run.updated_at = $updated_at
|
|
216
|
+
MERGE (run)-[:HAS_REVIEW_DECISION]->(decision)
|
|
217
|
+
RETURN decision
|
|
218
|
+
''',
|
|
219
|
+
space_id=request.personal_space_id,
|
|
220
|
+
review_id=request.review_id,
|
|
221
|
+
decision_id=decision_id,
|
|
222
|
+
candidate_key=request.candidate_key,
|
|
223
|
+
outcome=request.outcome,
|
|
224
|
+
note=request.note,
|
|
225
|
+
updated_at=updated_at,
|
|
226
|
+
)
|
|
227
|
+
if not records:
|
|
228
|
+
raise HTTPException(status_code=404, detail='active knowledge review not found')
|
|
229
|
+
return _review_decision(dict(records[0]['decision']))
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
async def finish_knowledge_review(
|
|
233
|
+
store,
|
|
234
|
+
actor: dict,
|
|
235
|
+
request: KnowledgeReviewFinish,
|
|
236
|
+
*,
|
|
237
|
+
changed_at: datetime | None = None,
|
|
238
|
+
) -> KnowledgeReviewRun:
|
|
239
|
+
store._require_personal()
|
|
240
|
+
await _personal_space(store, actor, request.personal_space_id, 'maintainer')
|
|
241
|
+
updated_at = changed_at or datetime.now(timezone.utc)
|
|
242
|
+
completed_at = updated_at if request.disposition == 'completed' else None
|
|
243
|
+
records, _, _ = await store.runtime.driver.execute_query(
|
|
244
|
+
'''
|
|
245
|
+
MATCH (:FuliSpace {id: $space_id, kind: 'personal'})-
|
|
246
|
+
[:HAS_KNOWLEDGE_REVIEW]->(run:FuliKnowledgeReviewRun {id: $review_id})
|
|
247
|
+
WHERE run.status IN ['active', 'paused']
|
|
248
|
+
SET run.status = $status,
|
|
249
|
+
run.updated_at = $updated_at,
|
|
250
|
+
run.completed_at = $completed_at,
|
|
251
|
+
run.active_key = CASE WHEN $status = 'completed'
|
|
252
|
+
THEN null ELSE run.active_key END
|
|
253
|
+
RETURN run
|
|
254
|
+
''',
|
|
255
|
+
space_id=request.personal_space_id,
|
|
256
|
+
review_id=request.review_id,
|
|
257
|
+
status=request.disposition,
|
|
258
|
+
updated_at=updated_at,
|
|
259
|
+
completed_at=completed_at,
|
|
260
|
+
)
|
|
261
|
+
if not records:
|
|
262
|
+
raise HTTPException(status_code=404, detail='active knowledge review not found')
|
|
263
|
+
return _review_run(dict(records[0]['run']))
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def build_review_candidates(
|
|
267
|
+
rows: list[dict[str, Any]],
|
|
268
|
+
*,
|
|
269
|
+
scope: str,
|
|
270
|
+
personal_project_id: str | None,
|
|
271
|
+
previous_completed_at: datetime | None,
|
|
272
|
+
review_cutoff_at: datetime | None = None,
|
|
273
|
+
conflict_item_keys: set[str],
|
|
274
|
+
decided_candidate_keys: set[str],
|
|
275
|
+
) -> list[KnowledgeReviewCandidate]:
|
|
276
|
+
prepared = []
|
|
277
|
+
for row in rows:
|
|
278
|
+
candidate_key = f"{row['item_kind']}:{row['item_id']}"
|
|
279
|
+
normalized = _normalize_item_row(row)
|
|
280
|
+
if not _item_in_scope(normalized, scope, personal_project_id):
|
|
281
|
+
continue
|
|
282
|
+
if (
|
|
283
|
+
review_cutoff_at is not None
|
|
284
|
+
and normalized['changed_at'] is not None
|
|
285
|
+
and normalized['changed_at'] > review_cutoff_at
|
|
286
|
+
):
|
|
287
|
+
continue
|
|
288
|
+
normalized['_candidate_key'] = candidate_key
|
|
289
|
+
prepared.append(normalized)
|
|
290
|
+
|
|
291
|
+
# One stable entity usually accumulates several sessions, while repeated
|
|
292
|
+
# relationships may have distinct IDs. Aggregate both shapes, but attach the
|
|
293
|
+
# cross-session reason to only one representative so the user is not asked
|
|
294
|
+
# the same globalization question several times.
|
|
295
|
+
pattern_groups: dict[tuple, list[dict]] = {}
|
|
296
|
+
for item in prepared:
|
|
297
|
+
pattern_groups.setdefault(_pattern_signature(item), []).append(item)
|
|
298
|
+
repeated_representatives = {}
|
|
299
|
+
for signature, items in pattern_groups.items():
|
|
300
|
+
session_ids = {
|
|
301
|
+
session_id
|
|
302
|
+
for item in items
|
|
303
|
+
for session_id in item['_session_ids']
|
|
304
|
+
}
|
|
305
|
+
eligible = [
|
|
306
|
+
item for item in items
|
|
307
|
+
if item['_candidate_key'] not in decided_candidate_keys
|
|
308
|
+
]
|
|
309
|
+
if len(session_ids) < REPEATED_SESSION_COUNT or not eligible:
|
|
310
|
+
continue
|
|
311
|
+
representative = max(
|
|
312
|
+
eligible,
|
|
313
|
+
key=lambda item: (
|
|
314
|
+
item['changed_at'] or datetime.min.replace(tzinfo=timezone.utc),
|
|
315
|
+
item['_candidate_key'],
|
|
316
|
+
),
|
|
317
|
+
)
|
|
318
|
+
repeated_representatives[representative['_candidate_key']] = len(session_ids)
|
|
319
|
+
|
|
320
|
+
candidates = []
|
|
321
|
+
for normalized in prepared:
|
|
322
|
+
candidate_key = normalized['_candidate_key']
|
|
323
|
+
if candidate_key in decided_candidate_keys:
|
|
324
|
+
continue
|
|
325
|
+
if candidate_key in repeated_representatives:
|
|
326
|
+
normalized['distinct_session_count'] = repeated_representatives[candidate_key]
|
|
327
|
+
reasons = _candidate_reasons(
|
|
328
|
+
normalized,
|
|
329
|
+
previous_completed_at,
|
|
330
|
+
candidate_key in conflict_item_keys,
|
|
331
|
+
)
|
|
332
|
+
if not reasons:
|
|
333
|
+
continue
|
|
334
|
+
candidates.append(KnowledgeReviewCandidate(
|
|
335
|
+
candidate_key=candidate_key,
|
|
336
|
+
priority=_reason_priority(reasons[0]),
|
|
337
|
+
reasons=reasons,
|
|
338
|
+
**{
|
|
339
|
+
key: normalized[key]
|
|
340
|
+
for key in KnowledgeReviewCandidate.model_fields
|
|
341
|
+
if key not in {'candidate_key', 'priority', 'reasons'}
|
|
342
|
+
},
|
|
343
|
+
))
|
|
344
|
+
return sorted(candidates, key=_candidate_sort_key)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
async def _personal_space(store, actor, space_id: str, role: str) -> dict:
|
|
348
|
+
space = await store.authorize(actor, space_id, role)
|
|
349
|
+
if space['kind'] != 'personal':
|
|
350
|
+
raise HTTPException(status_code=422, detail='knowledge review is personal-only')
|
|
351
|
+
return space
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
async def _read_review_run(store, space_id: str, review_id: str) -> KnowledgeReviewRun:
|
|
355
|
+
records, _, _ = await store.runtime.driver.execute_query(
|
|
356
|
+
'''
|
|
357
|
+
MATCH (:FuliSpace {id: $space_id, kind: 'personal'})-
|
|
358
|
+
[:HAS_KNOWLEDGE_REVIEW]->(run:FuliKnowledgeReviewRun {id: $review_id})
|
|
359
|
+
RETURN run
|
|
360
|
+
''',
|
|
361
|
+
space_id=space_id,
|
|
362
|
+
review_id=review_id,
|
|
363
|
+
routing_='r',
|
|
364
|
+
)
|
|
365
|
+
if not records:
|
|
366
|
+
raise HTTPException(status_code=404, detail='knowledge review not found')
|
|
367
|
+
return _review_run(dict(records[0]['run']))
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _review_run(value: dict, *, resumed: bool = False) -> KnowledgeReviewRun:
|
|
371
|
+
return KnowledgeReviewRun(
|
|
372
|
+
review_id=value['id'],
|
|
373
|
+
personal_space_id=value['personal_space_id'],
|
|
374
|
+
scope=value['scope'],
|
|
375
|
+
personal_project_id=value.get('personal_project_id'),
|
|
376
|
+
scope_key=value['scope_key'],
|
|
377
|
+
status=value['status'],
|
|
378
|
+
previous_completed_at=native_datetime(value.get('previous_completed_at')),
|
|
379
|
+
review_cutoff_at=native_datetime(
|
|
380
|
+
value.get('review_cutoff_at') or value['started_at']
|
|
381
|
+
),
|
|
382
|
+
started_at=native_datetime(value['started_at']),
|
|
383
|
+
updated_at=native_datetime(value['updated_at']),
|
|
384
|
+
completed_at=native_datetime(value.get('completed_at')),
|
|
385
|
+
resumed=resumed,
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _review_decision(value: dict) -> KnowledgeReviewDecision:
|
|
390
|
+
return KnowledgeReviewDecision(
|
|
391
|
+
decision_id=value['id'],
|
|
392
|
+
review_id=value['review_id'],
|
|
393
|
+
candidate_key=value['candidate_key'],
|
|
394
|
+
outcome=value['outcome'],
|
|
395
|
+
note=value.get('note'),
|
|
396
|
+
created_at=native_datetime(value['created_at']),
|
|
397
|
+
updated_at=native_datetime(value['updated_at']),
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _scope_key(scope: str, personal_project_id: str | None) -> str:
|
|
402
|
+
return f'{scope}:{personal_project_id}' if personal_project_id else scope
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _normalize_item_row(row: dict[str, Any]) -> dict[str, Any]:
|
|
406
|
+
project_ids = sorted({
|
|
407
|
+
value
|
|
408
|
+
for key in ('project_ids', 'assigned_project_ids', 'evidence_project_ids')
|
|
409
|
+
for value in (row.get(key) or [])
|
|
410
|
+
if value
|
|
411
|
+
})
|
|
412
|
+
session_ids = sorted({value for value in row.get('session_ids', []) if value})
|
|
413
|
+
changed_at = _latest_datetime(
|
|
414
|
+
row.get('created_at'),
|
|
415
|
+
row.get('last_human_changed_at'),
|
|
416
|
+
row.get('last_feedback_at'),
|
|
417
|
+
row.get('last_revision_at'),
|
|
418
|
+
)
|
|
419
|
+
return {
|
|
420
|
+
'item_id': row['item_id'],
|
|
421
|
+
'item_kind': row['item_kind'],
|
|
422
|
+
'title': row.get('title') or row['item_id'],
|
|
423
|
+
'content': row.get('content') or '',
|
|
424
|
+
'profile_aspect': row.get('profile_aspect'),
|
|
425
|
+
'preference_scope': row.get('preference_scope'),
|
|
426
|
+
'preference_project_id': row.get('preference_project_id'),
|
|
427
|
+
'project_ids': project_ids,
|
|
428
|
+
'confirmation_status': row.get('confirmation_status') or 'pending',
|
|
429
|
+
'utility_score': _float_or_default(row.get('utility_score'), 0),
|
|
430
|
+
'confidence_score': _float_or_default(row.get('confidence_score'), 0.5),
|
|
431
|
+
'qualified_use_count': int(row.get('qualified_use_count') or 0),
|
|
432
|
+
'distinct_task_count': int(row.get('distinct_task_count') or 0),
|
|
433
|
+
'negative_evidence_count': int(row.get('negative_evidence_count') or 0),
|
|
434
|
+
'requires_attention': row.get('requires_attention') is True,
|
|
435
|
+
'last_feedback_kind': row.get('last_feedback_kind'),
|
|
436
|
+
'distinct_session_count': len(session_ids),
|
|
437
|
+
'changed_at': changed_at,
|
|
438
|
+
'_session_ids': session_ids,
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _pattern_signature(item: dict) -> tuple:
|
|
443
|
+
content = ' '.join((item['content'] or item['title']).casefold().split())
|
|
444
|
+
return item['item_kind'], item['profile_aspect'], content
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _item_in_scope(item: dict, scope: str, project_id: str | None) -> bool:
|
|
448
|
+
is_preference = item['profile_aspect'] is not None
|
|
449
|
+
preference_scope = item['preference_scope'] or 'global'
|
|
450
|
+
is_global_preference = is_preference and preference_scope == 'global'
|
|
451
|
+
is_project_preference = is_preference and preference_scope == 'project'
|
|
452
|
+
is_project_knowledge = not is_preference and bool(item['project_ids'])
|
|
453
|
+
if scope == 'all':
|
|
454
|
+
return is_global_preference or is_project_preference or is_project_knowledge
|
|
455
|
+
if scope == 'preferences_global':
|
|
456
|
+
return is_global_preference
|
|
457
|
+
if scope == 'preferences_project':
|
|
458
|
+
return is_project_preference and item['preference_project_id'] == project_id
|
|
459
|
+
if scope == 'projects_all':
|
|
460
|
+
return is_project_preference or is_project_knowledge
|
|
461
|
+
if scope == 'project':
|
|
462
|
+
return (
|
|
463
|
+
is_project_preference and item['preference_project_id'] == project_id
|
|
464
|
+
) or (
|
|
465
|
+
is_project_knowledge and project_id in item['project_ids']
|
|
466
|
+
)
|
|
467
|
+
raise ValueError(f'Unknown knowledge review scope: {scope}')
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _candidate_reasons(
|
|
471
|
+
item: dict,
|
|
472
|
+
previous_completed_at: datetime | None,
|
|
473
|
+
has_conflict: bool,
|
|
474
|
+
) -> list[str]:
|
|
475
|
+
reasons = []
|
|
476
|
+
if previous_completed_at is None or (
|
|
477
|
+
item['changed_at'] is not None and item['changed_at'] > previous_completed_at
|
|
478
|
+
):
|
|
479
|
+
reasons.append('changed_since_last')
|
|
480
|
+
if (
|
|
481
|
+
has_conflict
|
|
482
|
+
or item['requires_attention']
|
|
483
|
+
or item['negative_evidence_count'] > 0
|
|
484
|
+
):
|
|
485
|
+
reasons.append('conflict_or_attention')
|
|
486
|
+
if (
|
|
487
|
+
item['utility_score'] <= LOW_UTILITY_SCORE
|
|
488
|
+
or item['confidence_score'] <= LOW_CONFIDENCE_SCORE
|
|
489
|
+
):
|
|
490
|
+
reasons.append('low_weight')
|
|
491
|
+
if item['distinct_session_count'] >= REPEATED_SESSION_COUNT:
|
|
492
|
+
reasons.append('repeated_cross_session')
|
|
493
|
+
return reasons
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _reason_priority(reason: str) -> int:
|
|
497
|
+
return {
|
|
498
|
+
'changed_since_last': 1,
|
|
499
|
+
'conflict_or_attention': 2,
|
|
500
|
+
'low_weight': 3,
|
|
501
|
+
'repeated_cross_session': 4,
|
|
502
|
+
}[reason]
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _candidate_sort_key(candidate: KnowledgeReviewCandidate) -> tuple:
|
|
506
|
+
changed_at = candidate.changed_at or datetime.min.replace(tzinfo=timezone.utc)
|
|
507
|
+
return (
|
|
508
|
+
candidate.priority,
|
|
509
|
+
not candidate.requires_attention,
|
|
510
|
+
-candidate.negative_evidence_count,
|
|
511
|
+
-changed_at.timestamp(),
|
|
512
|
+
candidate.utility_score,
|
|
513
|
+
candidate.candidate_key,
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _latest_datetime(*values) -> datetime | None:
|
|
518
|
+
normalized = [native_datetime(value) for value in values if value is not None]
|
|
519
|
+
return max(normalized) if normalized else None
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _float_or_default(value, default: float) -> float:
|
|
523
|
+
return default if value is None else float(value)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
_ENTITY_CANDIDATE_QUERY = '''
|
|
527
|
+
MATCH (item:Entity {group_id: $group_id})
|
|
528
|
+
WHERE item.fuli_invalid_at IS NULL
|
|
529
|
+
OPTIONAL MATCH (episode:Episodic {group_id: $group_id})-[:MENTIONS]->(item)
|
|
530
|
+
OPTIONAL MATCH (assignment:FuliKnowledgeAssignment {
|
|
531
|
+
space_id: $space_id, item_kind: 'entity', item_id: item.uuid
|
|
532
|
+
})
|
|
533
|
+
OPTIONAL MATCH (:FuliSpace {id: $space_id})-[:HAS_KNOWLEDGE_REVISION]->
|
|
534
|
+
(revision:FuliKnowledgeRevision {item_kind: 'entity', item_id: item.uuid})
|
|
535
|
+
RETURN item.uuid AS item_id,
|
|
536
|
+
'entity' AS item_kind,
|
|
537
|
+
item.name AS title,
|
|
538
|
+
coalesce(item.summary, '') AS content,
|
|
539
|
+
item.fuli_profile_aspect AS profile_aspect,
|
|
540
|
+
item.fuli_preference_scope AS preference_scope,
|
|
541
|
+
item.fuli_preference_project_id AS preference_project_id,
|
|
542
|
+
collect(DISTINCT assignment.project_id) AS assigned_project_ids,
|
|
543
|
+
collect(DISTINCT episode.fuli_personal_project_id) AS evidence_project_ids,
|
|
544
|
+
collect(DISTINCT episode.fuli_session_id) AS session_ids,
|
|
545
|
+
coalesce(item.fuli_confirmation_status, 'pending') AS confirmation_status,
|
|
546
|
+
coalesce(item.fuli_utility_score, 0.0) AS utility_score,
|
|
547
|
+
coalesce(item.fuli_confidence_score, 0.5) AS confidence_score,
|
|
548
|
+
coalesce(item.fuli_qualified_use_count, 0) AS qualified_use_count,
|
|
549
|
+
coalesce(item.fuli_distinct_task_count, 0) AS distinct_task_count,
|
|
550
|
+
coalesce(item.fuli_negative_evidence_count, 0) AS negative_evidence_count,
|
|
551
|
+
coalesce(item.fuli_requires_attention, false) AS requires_attention,
|
|
552
|
+
item.fuli_last_feedback_kind AS last_feedback_kind,
|
|
553
|
+
item.created_at AS created_at,
|
|
554
|
+
item.fuli_last_human_changed_at AS last_human_changed_at,
|
|
555
|
+
item.fuli_last_feedback_at AS last_feedback_at,
|
|
556
|
+
max(revision.created_at) AS last_revision_at
|
|
557
|
+
'''
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
_RELATIONSHIP_CANDIDATE_QUERY = '''
|
|
561
|
+
MATCH ()-[item:RELATES_TO {group_id: $group_id}]->()
|
|
562
|
+
WHERE item.invalid_at IS NULL
|
|
563
|
+
OPTIONAL MATCH (episode:Episodic {group_id: $group_id})
|
|
564
|
+
WHERE episode.uuid IN coalesce(item.episodes, [])
|
|
565
|
+
OPTIONAL MATCH (assignment:FuliKnowledgeAssignment {
|
|
566
|
+
space_id: $space_id, item_kind: 'relationship', item_id: item.uuid
|
|
567
|
+
})
|
|
568
|
+
OPTIONAL MATCH (:FuliSpace {id: $space_id})-[:HAS_KNOWLEDGE_REVISION]->
|
|
569
|
+
(revision:FuliKnowledgeRevision {
|
|
570
|
+
item_kind: 'relationship', item_id: item.uuid
|
|
571
|
+
})
|
|
572
|
+
RETURN item.uuid AS item_id,
|
|
573
|
+
'relationship' AS item_kind,
|
|
574
|
+
coalesce(item.name, 'RELATES_TO') AS title,
|
|
575
|
+
coalesce(item.fact, '') AS content,
|
|
576
|
+
item.fuli_profile_aspect AS profile_aspect,
|
|
577
|
+
item.fuli_preference_scope AS preference_scope,
|
|
578
|
+
item.fuli_preference_project_id AS preference_project_id,
|
|
579
|
+
collect(DISTINCT assignment.project_id) AS assigned_project_ids,
|
|
580
|
+
collect(DISTINCT episode.fuli_personal_project_id) AS evidence_project_ids,
|
|
581
|
+
collect(DISTINCT episode.fuli_session_id) AS session_ids,
|
|
582
|
+
coalesce(item.fuli_confirmation_status, 'pending') AS confirmation_status,
|
|
583
|
+
coalesce(item.fuli_utility_score, 0.0) AS utility_score,
|
|
584
|
+
coalesce(item.fuli_confidence_score, 0.5) AS confidence_score,
|
|
585
|
+
coalesce(item.fuli_qualified_use_count, 0) AS qualified_use_count,
|
|
586
|
+
coalesce(item.fuli_distinct_task_count, 0) AS distinct_task_count,
|
|
587
|
+
coalesce(item.fuli_negative_evidence_count, 0) AS negative_evidence_count,
|
|
588
|
+
coalesce(item.fuli_requires_attention, false) AS requires_attention,
|
|
589
|
+
item.fuli_last_feedback_kind AS last_feedback_kind,
|
|
590
|
+
item.created_at AS created_at,
|
|
591
|
+
item.fuli_last_human_changed_at AS last_human_changed_at,
|
|
592
|
+
item.fuli_last_feedback_at AS last_feedback_at,
|
|
593
|
+
max(revision.created_at) AS last_revision_at
|
|
594
|
+
'''
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
_CONFLICT_ITEM_QUERY = '''
|
|
598
|
+
MATCH (space:FuliSpace {id: $space_id, kind: 'personal'})
|
|
599
|
+
OPTIONAL MATCH (space)-[:HAS_KNOWLEDGE_CONFLICT]->
|
|
600
|
+
(knowledge:FuliKnowledgeConflict {status: 'pending'})
|
|
601
|
+
WITH space, collect(DISTINCT 'entity:' + knowledge.item_id) +
|
|
602
|
+
collect(DISTINCT 'relationship:' + knowledge.item_id) +
|
|
603
|
+
collect(DISTINCT 'entity:' + knowledge.target_item_id) +
|
|
604
|
+
collect(DISTINCT 'relationship:' + knowledge.target_item_id)
|
|
605
|
+
AS knowledge_keys
|
|
606
|
+
OPTIONAL MATCH (space)-[:HAS_PREFERENCE_CONFLICT]->
|
|
607
|
+
(preference:FuliPreferenceConflict {status: 'ai_pending'})
|
|
608
|
+
RETURN knowledge_keys +
|
|
609
|
+
collect(DISTINCT preference.left_item_kind + ':' + preference.left_item_id) +
|
|
610
|
+
collect(DISTINCT preference.right_item_kind + ':' + preference.right_item_id)
|
|
611
|
+
AS candidate_keys
|
|
612
|
+
'''
|