topos-node 0.1.0__py3-none-any.whl

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 (249) hide show
  1. shared/__init__.py +59 -0
  2. shared/filtering.py +640 -0
  3. shared/schema_registry.py +229 -0
  4. topos/__init__.py +5 -0
  5. topos/__version__.py +6 -0
  6. topos/analytics/__init__.py +15 -0
  7. topos/analytics/duckdb_adapter.py +48 -0
  8. topos/analytics/messenger_communities.py +349 -0
  9. topos/analytics/messenger_graph.py +522 -0
  10. topos/analytics/messenger_labels.py +321 -0
  11. topos/analytics/profiles.py +22 -0
  12. topos/analytics/query_engine.py +64 -0
  13. topos/analytics/raw_queries.py +174 -0
  14. topos/api/__init__.py +1 -0
  15. topos/api/analytics.py +52 -0
  16. topos/api/app_registry.py +31 -0
  17. topos/api/backup.py +15 -0
  18. topos/api/compute_remote.py +175 -0
  19. topos/api/data_commit.py +158 -0
  20. topos/api/data_explorer_table_prefs.py +81 -0
  21. topos/api/db.py +10 -0
  22. topos/api/device.py +25 -0
  23. topos/api/enrichment.py +959 -0
  24. topos/api/filter_lab.py +195 -0
  25. topos/api/health.py +61 -0
  26. topos/api/ingestion_api.py +37 -0
  27. topos/api/ingestion_compat.py +21 -0
  28. topos/api/ingestion_sources.py +600 -0
  29. topos/api/llm.py +76 -0
  30. topos/api/local_mcp.py +46 -0
  31. topos/api/messenger_analytics.py +385 -0
  32. topos/api/query_api.py +13 -0
  33. topos/api/sanitization_ollama_config.py +64 -0
  34. topos/api/source_install.py +324 -0
  35. topos/api/sources.py +13 -0
  36. topos/api/sync.py +10 -0
  37. topos/api/ui_config.py +83 -0
  38. topos/api/uma_data.py +311 -0
  39. topos/api/usage.py +49 -0
  40. topos/api/user_identity.py +46 -0
  41. topos/app.py +239 -0
  42. topos/auth.py +17 -0
  43. topos/canonicalization/__init__.py +1 -0
  44. topos/canonicalization/mappers/__init__.py +22 -0
  45. topos/canonicalization/mappers/base.py +26 -0
  46. topos/canonicalization/mappers/chatgpt_mapper.py +40 -0
  47. topos/canonicalization/mappers/grok_mapper.py +17 -0
  48. topos/canonicalization/mappers/messenger_mapper.py +58 -0
  49. topos/canonicalization/models.py +31 -0
  50. topos/canonicalization/resolver.py +23 -0
  51. topos/cli/__init__.py +1 -0
  52. topos/cli/__main__.py +6 -0
  53. topos/cli/commands.py +132 -0
  54. topos/config/__init__.py +1 -0
  55. topos/config/sanitization_ollama.py +189 -0
  56. topos/config/settings.py +310 -0
  57. topos/contacts/__init__.py +5 -0
  58. topos/contacts/identity.py +24 -0
  59. topos/control_plane_client.py +300 -0
  60. topos/core/__init__.py +1 -0
  61. topos/core/api_models.py +128 -0
  62. topos/core/connection_resilience.py +99 -0
  63. topos/core/device_helpers.py +8 -0
  64. topos/core/errors.py +13 -0
  65. topos/core/events.py +12 -0
  66. topos/core/handlers.py +5625 -0
  67. topos/core/logging.py +175 -0
  68. topos/core/metrics.py +21 -0
  69. topos/core/startup_banner.py +62 -0
  70. topos/core/state.py +682 -0
  71. topos/core/table_layers.py +45 -0
  72. topos/core/types.py +13 -0
  73. topos/data_explorer_table_prefs.py +150 -0
  74. topos/engine/__init__.py +29 -0
  75. topos/engine/backends/__init__.py +50 -0
  76. topos/engine/backends/base.py +21 -0
  77. topos/engine/backends/huggingface.py +151 -0
  78. topos/engine/backends/ollama.py +181 -0
  79. topos/engine/backends/stub.py +22 -0
  80. topos/engine/engine.py +165 -0
  81. topos/engine/intake.py +32 -0
  82. topos/engine/queue_manager.py +112 -0
  83. topos/engine/registration.py +126 -0
  84. topos/engine/result_formatter.py +38 -0
  85. topos/engine/router.py +19 -0
  86. topos/engine/scoped_token.py +82 -0
  87. topos/engine/tasks.py +154 -0
  88. topos/engine/transport.py +44 -0
  89. topos/engine/usage_guard.py +100 -0
  90. topos/engine/usage_observation.py +129 -0
  91. topos/engine/validator.py +23 -0
  92. topos/enrichment/__init__.py +1 -0
  93. topos/enrichment/derived_tables.py +214 -0
  94. topos/enrichment/jobs/__init__.py +30 -0
  95. topos/enrichment/jobs/base.py +54 -0
  96. topos/enrichment/jobs/canonical/__init__.py +1 -0
  97. topos/enrichment/jobs/canonical/embeddings_job.py +27 -0
  98. topos/enrichment/jobs/canonical/emo_27_job.py +97 -0
  99. topos/enrichment/jobs/canonical/entities_job.py +27 -0
  100. topos/enrichment/jobs/canonical/sentiment_job.py +27 -0
  101. topos/enrichment/jobs/canonical/topics_job.py +27 -0
  102. topos/enrichment/jobs/raw/__init__.py +1 -0
  103. topos/enrichment/jobs/raw/attachments_job.py +12 -0
  104. topos/enrichment/jobs/raw/language_job.py +12 -0
  105. topos/enrichment/jobs/raw/time_normalization_job.py +12 -0
  106. topos/enrichment/jobs/raw/tool_calls_job.py +12 -0
  107. topos/enrichment/models/__init__.py +1 -0
  108. topos/enrichment/models/manager.py +8 -0
  109. topos/enrichment/models/registry.py +71 -0
  110. topos/enrichment/models/versioning.py +8 -0
  111. topos/enrichment/orchestrator.py +177 -0
  112. topos/enrichment/processor.py +17 -0
  113. topos/enrichment/progress_bar.py +122 -0
  114. topos/enrichment/website_classifier.py +31 -0
  115. topos/filter_lab/__init__.py +1 -0
  116. topos/filter_lab/bundles.py +300 -0
  117. topos/filter_lab/schema.py +86 -0
  118. topos/filter_lab/service.py +167 -0
  119. topos/filter_lab/store.py +374 -0
  120. topos/filter_lab/worker.py +250 -0
  121. topos/hosted_pool_lease.py +153 -0
  122. topos/ingestion/__init__.py +1 -0
  123. topos/ingestion/checkpoints/__init__.py +6 -0
  124. topos/ingestion/checkpoints/checkpoint_store.py +24 -0
  125. topos/ingestion/checkpoints/sqlite_checkpoint_store.py +82 -0
  126. topos/ingestion/ingest_helpers.py +504 -0
  127. topos/ingestion/jobs.py +91 -0
  128. topos/ingestion/local_sync.py +823 -0
  129. topos/ingestion/log_preview.py +21 -0
  130. topos/ingestion/manager.py +1100 -0
  131. topos/ingestion/parser.py +174 -0
  132. topos/ingestion/parsers/__init__.py +32 -0
  133. topos/ingestion/parsers/base.py +24 -0
  134. topos/ingestion/parsers/browser_parser.py +171 -0
  135. topos/ingestion/parsers/calendar_parser.py +21 -0
  136. topos/ingestion/parsers/chatgpt_conversation_flattener.py +266 -0
  137. topos/ingestion/parsers/chatgpt_parser.py +67 -0
  138. topos/ingestion/parsers/grok_parser.py +21 -0
  139. topos/ingestion/parsers/messenger_parser.py +97 -0
  140. topos/ingestion/progress.py +54 -0
  141. topos/ingestion/sources/__init__.py +20 -0
  142. topos/ingestion/sources/base.py +39 -0
  143. topos/ingestion/sources/calendar.py +29 -0
  144. topos/ingestion/sources/chatgpt.py +29 -0
  145. topos/ingestion/sources/contact_importers.py +274 -0
  146. topos/ingestion/sources/grok.py +29 -0
  147. topos/ingestion/sources/imessage_reader.py +479 -0
  148. topos/ingestion/sources/signal_export_parser.py +132 -0
  149. topos/ingestion/sources/signal_reader.py +491 -0
  150. topos/ingestion/state_machine.py +70 -0
  151. topos/ingestion/triggers/__init__.py +1 -0
  152. topos/ingestion/triggers/file_trigger.py +36 -0
  153. topos/ingestion/triggers/sqlite_trigger.py +18 -0
  154. topos/ingestion/validation/__init__.py +1 -0
  155. topos/ingestion/validation/base.py +27 -0
  156. topos/ingestion/validation/schema_registry.py +111 -0
  157. topos/ingestion/validation/schema_validator.py +13 -0
  158. topos/lineage/__init__.py +1 -0
  159. topos/lineage/provenance.py +9 -0
  160. topos/lineage/tracker.py +9 -0
  161. topos/mcp_stdio_proxy.py +83 -0
  162. topos/observability/__init__.py +1 -0
  163. topos/observability/alerts.py +7 -0
  164. topos/observability/metrics.py +25 -0
  165. topos/observability/tracing.py +18 -0
  166. topos/openai_client.py +69 -0
  167. topos/projections/__init__.py +1 -0
  168. topos/projections/vector_index/__init__.py +1 -0
  169. topos/projections/vector_index/base.py +21 -0
  170. topos/projections/vector_index/builders.py +11 -0
  171. topos/projections/vector_index/health_checks.py +5 -0
  172. topos/rate_limit.py +43 -0
  173. topos/sanitization/__init__.py +16 -0
  174. topos/sanitization/ollama_transforms.py +276 -0
  175. topos/scope_resolution.py +89 -0
  176. topos/services/__init__.py +1 -0
  177. topos/services/container.py +46 -0
  178. topos/services/embeddings/__init__.py +1 -0
  179. topos/services/embeddings/base.py +7 -0
  180. topos/services/embeddings/local.py +9 -0
  181. topos/services/embeddings/remote.py +9 -0
  182. topos/services/interfaces.py +40 -0
  183. topos/services/llm/__init__.py +1 -0
  184. topos/services/llm/base.py +7 -0
  185. topos/services/llm/openai.py +126 -0
  186. topos/services/local.py +123 -0
  187. topos/services/postgres.py +385 -0
  188. topos/sources/__init__.py +6 -0
  189. topos/sources/definitions.py +114 -0
  190. topos/sources/install_service.py +836 -0
  191. topos/sources/registry.py +263 -0
  192. topos/sources/runtime_install.py +427 -0
  193. topos/storage/__init__.py +1 -0
  194. topos/storage/canonical/__init__.py +18 -0
  195. topos/storage/canonical/ai_chat/__init__.py +22 -0
  196. topos/storage/canonical/ai_chat/canonicalizer.py +147 -0
  197. topos/storage/canonical/ai_chat/mapper.py +168 -0
  198. topos/storage/canonical/ai_chat/model.py +87 -0
  199. topos/storage/canonical/ai_chat/tables.py +179 -0
  200. topos/storage/canonical/canonical_store.py +24 -0
  201. topos/storage/canonical/conversations_tables.py +1020 -0
  202. topos/storage/canonical/mapping_store.py +30 -0
  203. topos/storage/canonical/postgres.py +10 -0
  204. topos/storage/db/__init__.py +1 -0
  205. topos/storage/db/client.py +8 -0
  206. topos/storage/db/migrations/__init__.py +1 -0
  207. topos/storage/db/migrations/stage9_column_renames.py +78 -0
  208. topos/storage/db/paths.py +122 -0
  209. topos/storage/db/postgres.py +240 -0
  210. topos/storage/db/schema.py +6 -0
  211. topos/storage/enrichment/__init__.py +1 -0
  212. topos/storage/enrichment/canonical_enrichment_store.py +7 -0
  213. topos/storage/enrichment/raw_enrichment_store.py +18 -0
  214. topos/storage/normalized/__init__.py +1 -0
  215. topos/storage/normalized/normalized_store.py +24 -0
  216. topos/storage/oplog/__init__.py +1 -0
  217. topos/storage/oplog/decision.py +6 -0
  218. topos/storage/oplog/oplog_store.py +17 -0
  219. topos/storage/oplog/postgres.py +10 -0
  220. topos/storage/projections/__init__.py +1 -0
  221. topos/storage/projections/index_ops_store.py +6 -0
  222. topos/storage/projections/vector_index_store.py +6 -0
  223. topos/storage/raw/__init__.py +1 -0
  224. topos/storage/raw/browser_flat_tables.py +303 -0
  225. topos/storage/raw/file_store.py +100 -0
  226. topos/storage/raw/raw_store.py +29 -0
  227. topos/storage/raw/raw_tables_manager.py +295 -0
  228. topos/storage/raw/sqlite_raw_store.py +17 -0
  229. topos/storage/security/encryption.py +21 -0
  230. topos/storage/signal_identity.py +71 -0
  231. topos/storage/source_settings.py +116 -0
  232. topos/storage/user_identity.py +69 -0
  233. topos/sync/__init__.py +5 -0
  234. topos/sync/client.py +272 -0
  235. topos/sync_handlers.py +70 -0
  236. topos/testing/__init__.py +1 -0
  237. topos/testing/lifespan.py +7 -0
  238. topos/uma_contact_enrichment.py +1032 -0
  239. topos/uma_filters.py +669 -0
  240. topos/uma_resource_id.py +24 -0
  241. topos/uma_rpt.py +69 -0
  242. topos/utils/base_object.py +61 -0
  243. topos/websocket_client.py +21 -0
  244. topos_node-0.1.0.dist-info/METADATA +199 -0
  245. topos_node-0.1.0.dist-info/RECORD +249 -0
  246. topos_node-0.1.0.dist-info/WHEEL +5 -0
  247. topos_node-0.1.0.dist-info/entry_points.txt +2 -0
  248. topos_node-0.1.0.dist-info/licenses/LICENSE +201 -0
  249. topos_node-0.1.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,324 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import uuid
5
+ from typing import Any, Dict, Optional
6
+
7
+ from fastapi import APIRouter, Body, Depends, HTTPException, Query
8
+
9
+ from ..auth import require_api_key
10
+ from ..ingestion.ingest_helpers import ingest_file_payload, ingest_ui_payload
11
+ from ..api.enrichment import _process_enrichment_core
12
+ from ..engine.usage_observation import emit_usage_observation
13
+ from ..sources import install_service
14
+
15
+ router = APIRouter()
16
+ logger = logging.getLogger("topos.api.source_install")
17
+
18
+
19
+ def _ok_envelope(request_id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
20
+ return {"status": "ok", "request_id": request_id, **payload}
21
+
22
+
23
+ def _log_request(action: str, request_id: str, payload: Optional[Dict[str, Any]]) -> None:
24
+ payload = payload or {}
25
+ logger.info(
26
+ "[SOURCE_INSTALL] action=%s request_id=%s source_id=%s version_id=%s dataset_id=%s",
27
+ action,
28
+ request_id,
29
+ str(payload.get("source_id") or ""),
30
+ str(payload.get("version_id") or ""),
31
+ str(payload.get("dataset_id") or ""),
32
+ )
33
+
34
+
35
+ def _scope_from_payload(payload: Optional[Dict[str, Any]]) -> Dict[str, Any]:
36
+ payload = payload or {}
37
+ raw_scope = payload.get("scope")
38
+ if isinstance(raw_scope, dict):
39
+ return raw_scope
40
+ return {
41
+ "user_id": payload.get("user_id"),
42
+ "device_id": payload.get("device_id"),
43
+ "topos_id": payload.get("topos_id"),
44
+ "dataset_id": payload.get("dataset_id"),
45
+ }
46
+
47
+
48
+ def _require_scope_fields(scope: Dict[str, Any], *, required: tuple[str, ...]) -> None:
49
+ missing = [field for field in required if not str(scope.get(field) or "").strip()]
50
+ if missing:
51
+ raise ValueError(f"{', '.join(missing)} required")
52
+
53
+
54
+ def _scope_candidates(scope: Dict[str, Any]) -> list[Dict[str, Any]]:
55
+ """Generate progressively relaxed scope candidates for source lookup."""
56
+ user_id = str(scope.get("user_id") or "*").strip() or "*"
57
+ device_id = str(scope.get("device_id") or "*").strip() or "*"
58
+ topos_id = str(scope.get("topos_id") or "*").strip() or "*"
59
+ dataset_id = str(scope.get("dataset_id") or "*").strip() or "*"
60
+
61
+ candidates = [
62
+ {"user_id": user_id, "device_id": device_id, "topos_id": topos_id, "dataset_id": dataset_id},
63
+ {"user_id": user_id, "device_id": device_id, "topos_id": topos_id, "dataset_id": "*"},
64
+ {"user_id": user_id, "device_id": device_id, "topos_id": "*", "dataset_id": "*"},
65
+ {"user_id": user_id, "device_id": "*", "topos_id": "*", "dataset_id": "*"},
66
+ {"user_id": "*", "device_id": "*", "topos_id": "*", "dataset_id": "*"},
67
+ ]
68
+
69
+ deduped: list[Dict[str, Any]] = []
70
+ seen: set[tuple[str, str, str, str]] = set()
71
+ for cand in candidates:
72
+ key = (
73
+ str(cand.get("user_id") or "*"),
74
+ str(cand.get("device_id") or "*"),
75
+ str(cand.get("topos_id") or "*"),
76
+ str(cand.get("dataset_id") or "*"),
77
+ )
78
+ if key in seen:
79
+ continue
80
+ seen.add(key)
81
+ deduped.append(cand)
82
+ return deduped
83
+
84
+
85
+ def _resolve_active_source_definition(source_id: str, scope: Dict[str, Any]) -> Optional[Dict[str, Any]]:
86
+ """Resolve active source def with tolerant scope fallback for test endpoints."""
87
+ for candidate_scope in _scope_candidates(scope):
88
+ source_def = install_service.get_active_source_definition(source_id=source_id, scope=candidate_scope)
89
+ if source_def:
90
+ return source_def
91
+
92
+ # Final fallback: any active install for this source owned by the same user.
93
+ wanted_user = str(scope.get("user_id") or "").strip()
94
+ installs = install_service.list_installs(source_id=source_id)
95
+ for rec in installs:
96
+ if not rec.is_active:
97
+ continue
98
+ rec_user = str((rec.scope or {}).get("user_id") or "*").strip()
99
+ if wanted_user and rec_user not in (wanted_user, "*"):
100
+ continue
101
+ return rec.source_definition_json
102
+ return None
103
+
104
+
105
+ async def _install_source_core(payload: Dict[str, Any]) -> Dict[str, Any]:
106
+ record = install_service.install_source(
107
+ source_definition_json=payload.get("source_definition_json")
108
+ if isinstance(payload.get("source_definition_json"), dict)
109
+ else None,
110
+ source_version_row_json=payload.get("source_version_row_json")
111
+ if isinstance(payload.get("source_version_row_json"), dict)
112
+ else None,
113
+ version_id=str(payload.get("version_id")).strip() if payload.get("version_id") else None,
114
+ scope=_scope_from_payload(payload),
115
+ )
116
+ return {"status": "ok", "install": record.to_dict()}
117
+
118
+
119
+ async def _list_install_status_core(payload: Dict[str, Any]) -> Dict[str, Any]:
120
+ scope = _scope_from_payload(payload)
121
+ source_id = str(payload.get("source_id")).strip() if payload.get("source_id") else None
122
+ installs = install_service.list_installs(
123
+ scope=scope,
124
+ source_id=source_id,
125
+ )
126
+ return {"status": "ok", "installs": [record.to_dict() for record in installs]}
127
+
128
+
129
+ async def _list_sources_core(payload: Dict[str, Any]) -> Dict[str, Any]:
130
+ scope = _scope_from_payload(payload)
131
+ _require_scope_fields(scope, required=("user_id", "dataset_id", "topos_id"))
132
+ install_service.rehydrate_active_installs_runtime()
133
+ installs = install_service.list_installs(scope=scope)
134
+ # Keep one active source definition per source_id.
135
+ active_by_source: Dict[str, Dict[str, Any]] = {}
136
+ for rec in installs:
137
+ if not rec.is_active:
138
+ continue
139
+ sid = str(rec.source_id or "").strip()
140
+ source_def = rec.source_definition_json if isinstance(rec.source_definition_json, dict) else {}
141
+ if not sid or sid in active_by_source or not source_def:
142
+ continue
143
+ active_by_source[sid] = source_def
144
+ sources = list(active_by_source.values())
145
+ return {"status": "ok", "sources": sources}
146
+
147
+
148
+ async def _uninstall_source_core(payload: Dict[str, Any]) -> Dict[str, Any]:
149
+ source_id = str(payload.get("source_id") or "").strip()
150
+ if not source_id:
151
+ raise ValueError("source_id is required")
152
+ result = install_service.uninstall_source(
153
+ source_id=source_id,
154
+ scope=_scope_from_payload(payload),
155
+ delete_source_tables=bool(payload.get("delete_source_tables")),
156
+ )
157
+ return {"status": "ok", **result}
158
+
159
+
160
+ async def _test_ingestion_core(payload: Dict[str, Any]) -> Dict[str, Any]:
161
+ source_id = str(payload.get("source_id") or "").strip()
162
+ dataset_id = str(payload.get("dataset_id") or "").strip()
163
+ if not source_id or not dataset_id:
164
+ raise ValueError("source_id and dataset_id are required")
165
+ source_scope = _scope_from_payload(payload)
166
+ source_def = _resolve_active_source_definition(source_id=source_id, scope=source_scope)
167
+ if not source_def:
168
+ raise LookupError(f"No active install found for source_id={source_id}")
169
+
170
+ source_type = str(source_def.get("source_type") or "file")
171
+ schema_id = str(source_def.get("schema_id") or source_def.get("parser_id") or "").strip()
172
+ if not schema_id:
173
+ raise ValueError("Installed source definition is missing schema_id/parser_id")
174
+
175
+ if source_type == "file":
176
+ file_path = str(payload.get("sample_file_path") or "").strip()
177
+ if not file_path:
178
+ raise ValueError("sample_file_path is required for file source tests")
179
+ result = await ingest_file_payload(
180
+ dataset_id=dataset_id,
181
+ schema_id=schema_id,
182
+ file_path=file_path,
183
+ source_id=source_id,
184
+ )
185
+ elif source_type == "ui_stream":
186
+ sample_payload = payload.get("sample_payload")
187
+ if not isinstance(sample_payload, dict):
188
+ raise ValueError("sample_payload object is required for ui_stream source tests")
189
+ result = await ingest_ui_payload(
190
+ dataset_id=dataset_id,
191
+ schema_id=schema_id,
192
+ payload=sample_payload,
193
+ source_id=source_id,
194
+ )
195
+ else:
196
+ raise ValueError(f"Unsupported installed source_type for test ingestion: {source_type}")
197
+
198
+ return {"status": "ok", "source_id": source_id, "dataset_id": dataset_id, "result": result}
199
+
200
+
201
+ async def _test_enrichment_core(payload: Dict[str, Any]) -> Dict[str, Any]:
202
+ source_id = str(payload.get("source_id") or "").strip()
203
+ dataset_id = str(payload.get("dataset_id") or "").strip() or None
204
+ if not source_id:
205
+ raise ValueError("source_id is required")
206
+ job_names = payload.get("job_names")
207
+ if job_names is not None and not isinstance(job_names, list):
208
+ raise ValueError("job_names must be a list when provided")
209
+ result = await _process_enrichment_core(
210
+ source_id=source_id,
211
+ dataset_id=dataset_id,
212
+ job_names=job_names,
213
+ force_reprocess=bool(payload.get("force_reprocess")),
214
+ )
215
+ return {"status": "ok", "source_id": source_id, "dataset_id": dataset_id, "result": result}
216
+
217
+
218
+ @router.post("/source-install", dependencies=[Depends(require_api_key)])
219
+ async def install_source(payload: Dict[str, Any] = Body(default_factory=dict)) -> Dict[str, Any]:
220
+ request_id = str(uuid.uuid4())
221
+ _log_request("install_source", request_id, payload)
222
+ try:
223
+ result = await _install_source_core(payload)
224
+ scope = _scope_from_payload(payload)
225
+ await emit_usage_observation(
226
+ action="source.install.completed",
227
+ quantity=1,
228
+ producer="api.source_install",
229
+ canonical_action_identity={
230
+ "source_id": str(payload.get("source_id") or ""),
231
+ "version_id": str(payload.get("version_id") or ""),
232
+ "dataset_id": str(scope.get("dataset_id") or ""),
233
+ "user_id": str(scope.get("user_id") or ""),
234
+ },
235
+ topos_id=str(scope.get("dataset_id") or ""),
236
+ trust_class="observe_only",
237
+ metadata={"endpoint": "/source-install"},
238
+ )
239
+ return _ok_envelope(request_id, result)
240
+ except ValueError as exc:
241
+ raise HTTPException(status_code=400, detail=str(exc))
242
+ except RuntimeError as exc:
243
+ raise HTTPException(status_code=503, detail=str(exc))
244
+ except Exception as exc: # noqa: BLE001
245
+ raise HTTPException(status_code=500, detail=str(exc))
246
+
247
+
248
+ @router.get("/source-install-status", dependencies=[Depends(require_api_key)])
249
+ async def source_install_status(
250
+ source_id: Optional[str] = Query(default=None),
251
+ user_id: Optional[str] = Query(default=None),
252
+ device_id: Optional[str] = Query(default=None),
253
+ topos_id: Optional[str] = Query(default=None),
254
+ dataset_id: Optional[str] = Query(default=None),
255
+ ) -> Dict[str, Any]:
256
+ request_id = str(uuid.uuid4())
257
+ payload = {
258
+ "source_id": source_id,
259
+ "user_id": user_id,
260
+ "device_id": device_id,
261
+ "topos_id": topos_id,
262
+ "dataset_id": dataset_id,
263
+ }
264
+ _log_request("source_install_status", request_id, payload)
265
+ result = await _list_install_status_core(payload)
266
+ return _ok_envelope(request_id, result)
267
+
268
+
269
+ @router.delete("/source-install", dependencies=[Depends(require_api_key)])
270
+ async def uninstall_source(payload: Dict[str, Any] = Body(default_factory=dict)) -> Dict[str, Any]:
271
+ request_id = str(uuid.uuid4())
272
+ _log_request("uninstall_source", request_id, payload)
273
+ try:
274
+ result = await _uninstall_source_core(payload)
275
+ return _ok_envelope(request_id, result)
276
+ except ValueError as exc:
277
+ raise HTTPException(status_code=400, detail=str(exc))
278
+ except RuntimeError as exc:
279
+ raise HTTPException(status_code=503, detail=str(exc))
280
+ except Exception as exc: # noqa: BLE001
281
+ raise HTTPException(status_code=500, detail=str(exc))
282
+
283
+
284
+ @router.post("/source-test-ingestion", dependencies=[Depends(require_api_key)])
285
+ async def source_test_ingestion(payload: Dict[str, Any] = Body(default_factory=dict)) -> Dict[str, Any]:
286
+ request_id = str(uuid.uuid4())
287
+ _log_request("source_test_ingestion", request_id, payload)
288
+ try:
289
+ result = await _test_ingestion_core(payload)
290
+ return _ok_envelope(request_id, result)
291
+ except LookupError as exc:
292
+ raise HTTPException(status_code=404, detail=str(exc))
293
+ except ValueError as exc:
294
+ raise HTTPException(status_code=400, detail=str(exc))
295
+ except Exception as exc: # noqa: BLE001
296
+ raise HTTPException(status_code=500, detail=str(exc))
297
+
298
+
299
+ @router.post("/source-test-enrichment", dependencies=[Depends(require_api_key)])
300
+ async def source_test_enrichment(payload: Dict[str, Any] = Body(default_factory=dict)) -> Dict[str, Any]:
301
+ request_id = str(uuid.uuid4())
302
+ _log_request("source_test_enrichment", request_id, payload)
303
+ try:
304
+ result = await _test_enrichment_core(payload)
305
+ return _ok_envelope(request_id, result)
306
+ except ValueError as exc:
307
+ raise HTTPException(status_code=400, detail=str(exc))
308
+ except Exception as exc: # noqa: BLE001
309
+ raise HTTPException(status_code=500, detail=str(exc))
310
+
311
+
312
+ @router.post("/source-test-enrichment-trigger", dependencies=[Depends(require_api_key)])
313
+ async def source_test_enrichment_trigger(payload: Dict[str, Any] = Body(default_factory=dict)) -> Dict[str, Any]:
314
+ # Alias endpoint for explicit "manual trigger" semantics used by control plane/UI contracts.
315
+ request_id = str(uuid.uuid4())
316
+ _log_request("source_test_enrichment_trigger", request_id, payload)
317
+ try:
318
+ result = await _test_enrichment_core(payload)
319
+ return _ok_envelope(request_id, result)
320
+ except ValueError as exc:
321
+ raise HTTPException(status_code=400, detail=str(exc))
322
+ except Exception as exc: # noqa: BLE001
323
+ raise HTTPException(status_code=500, detail=str(exc))
324
+
topos/api/sources.py ADDED
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter, Depends
4
+
5
+ from ..auth import require_api_key
6
+ from ..sources.registry import list_sources
7
+
8
+ router = APIRouter()
9
+
10
+
11
+ @router.get("/sources", dependencies=[Depends(require_api_key)])
12
+ async def get_sources() -> dict:
13
+ return {"sources": [source.to_dict() for source in list_sources()]}
topos/api/sync.py ADDED
@@ -0,0 +1,10 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter
4
+
5
+ router = APIRouter()
6
+
7
+
8
+ @router.post("/sync")
9
+ async def trigger_sync() -> dict:
10
+ return {"status": "stub"}
topos/api/ui_config.py ADDED
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ from fastapi import APIRouter, Body, Depends
7
+
8
+ from ..auth import require_api_key
9
+ from ..core.state import get_db_connection, get_engine_config_value, set_engine_config_value
10
+
11
+ router = APIRouter(tags=["ui-config"])
12
+
13
+ UI_CONFIG_KEY = "ui_config"
14
+ ALLOWED_WIDGETS = {
15
+ "umaAllTime",
16
+ "uma24h",
17
+ "mcpRows",
18
+ "mcp24h",
19
+ "topConnector",
20
+ "umaStatusMix",
21
+ "topConnectors",
22
+ "mcpSources",
23
+ }
24
+ MAX_PINNED = 3
25
+
26
+
27
+ def _default_ui_config() -> dict[str, Any]:
28
+ return {"version": 1, "topbar": {"pinnedAnalytics": []}}
29
+
30
+
31
+ def _normalize_ui_config(value: Any) -> dict[str, Any]:
32
+ base = _default_ui_config()
33
+ if not isinstance(value, dict):
34
+ return base
35
+ out = {
36
+ "version": int(value.get("version", 1)) if str(value.get("version", "")).isdigit() else 1,
37
+ "topbar": {"pinnedAnalytics": []},
38
+ }
39
+ topbar = value.get("topbar")
40
+ pinned = topbar.get("pinnedAnalytics") if isinstance(topbar, dict) else []
41
+ if not isinstance(pinned, list):
42
+ return out
43
+ seen: set[str] = set()
44
+ result: list[str] = []
45
+ for item in pinned:
46
+ wid = str(item or "").strip()
47
+ if not wid or wid in seen or wid not in ALLOWED_WIDGETS:
48
+ continue
49
+ seen.add(wid)
50
+ result.append(wid)
51
+ if len(result) >= MAX_PINNED:
52
+ break
53
+ out["topbar"]["pinnedAnalytics"] = result
54
+ return out
55
+
56
+
57
+ @router.get("/v1/ui-config", dependencies=[Depends(require_api_key)])
58
+ async def get_ui_config() -> dict[str, Any]:
59
+ conn = get_db_connection()
60
+ if not conn:
61
+ return {"status": "error", "error": "Database not available"}
62
+ raw = get_engine_config_value(conn, UI_CONFIG_KEY)
63
+ if not raw:
64
+ cfg = _default_ui_config()
65
+ return {"status": "ok", "ui_config": cfg}
66
+ try:
67
+ parsed = json.loads(raw)
68
+ except Exception:
69
+ parsed = {}
70
+ cfg = _normalize_ui_config(parsed)
71
+ return {"status": "ok", "ui_config": cfg}
72
+
73
+
74
+ @router.put("/v1/ui-config", dependencies=[Depends(require_api_key)])
75
+ async def put_ui_config(body: dict[str, Any] = Body(default=None)) -> dict[str, Any]:
76
+ conn = get_db_connection()
77
+ if not conn:
78
+ return {"status": "error", "error": "Database not available"}
79
+ payload = (body or {}).get("ui_config")
80
+ cfg = _normalize_ui_config(payload)
81
+ set_engine_config_value(conn, UI_CONFIG_KEY, json.dumps(cfg))
82
+ return {"status": "ok", "ui_config": cfg}
83
+