10xgraph-api 0.7.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 (203) hide show
  1. 10xgraph_api-0.7.0.dist-info/METADATA +618 -0
  2. 10xgraph_api-0.7.0.dist-info/RECORD +203 -0
  3. 10xgraph_api-0.7.0.dist-info/WHEEL +5 -0
  4. 10xgraph_api-0.7.0.dist-info/entry_points.txt +3 -0
  5. 10xgraph_api-0.7.0.dist-info/licenses/LICENSE +21 -0
  6. 10xgraph_api-0.7.0.dist-info/top_level.txt +2 -0
  7. agentflow_cli/__init__.py +69 -0
  8. tenxgraph_api/__init__.py +23 -0
  9. tenxgraph_api/cli/__init__.py +6 -0
  10. tenxgraph_api/cli/capabilities.py +127 -0
  11. tenxgraph_api/cli/commands/__init__.py +49 -0
  12. tenxgraph_api/cli/commands/api.py +262 -0
  13. tenxgraph_api/cli/commands/audit.py +213 -0
  14. tenxgraph_api/cli/commands/build.py +305 -0
  15. tenxgraph_api/cli/commands/config.py +71 -0
  16. tenxgraph_api/cli/commands/demo.py +93 -0
  17. tenxgraph_api/cli/commands/eval.py +909 -0
  18. tenxgraph_api/cli/commands/init.py +531 -0
  19. tenxgraph_api/cli/commands/skills.py +532 -0
  20. tenxgraph_api/cli/commands/test.py +113 -0
  21. tenxgraph_api/cli/commands/version.py +47 -0
  22. tenxgraph_api/cli/config_editor/__init__.py +15 -0
  23. tenxgraph_api/cli/config_editor/schema.py +566 -0
  24. tenxgraph_api/cli/config_editor/server.py +240 -0
  25. tenxgraph_api/cli/config_editor/static/app.css +2 -0
  26. tenxgraph_api/cli/config_editor/static/app.js +3 -0
  27. tenxgraph_api/cli/config_editor/static/favicon.svg +11 -0
  28. tenxgraph_api/cli/config_editor/static/index.html +20 -0
  29. tenxgraph_api/cli/config_editor/store.py +120 -0
  30. tenxgraph_api/cli/config_editor/validation.py +377 -0
  31. tenxgraph_api/cli/constants.py +112 -0
  32. tenxgraph_api/cli/context.py +46 -0
  33. tenxgraph_api/cli/core/__init__.py +0 -0
  34. tenxgraph_api/cli/core/animations.py +550 -0
  35. tenxgraph_api/cli/core/config.py +279 -0
  36. tenxgraph_api/cli/core/output.py +625 -0
  37. tenxgraph_api/cli/core/prompts.py +220 -0
  38. tenxgraph_api/cli/core/screen.py +287 -0
  39. tenxgraph_api/cli/core/steps.py +621 -0
  40. tenxgraph_api/cli/core/theme.py +242 -0
  41. tenxgraph_api/cli/core/validation.py +251 -0
  42. tenxgraph_api/cli/exceptions.py +129 -0
  43. tenxgraph_api/cli/logger.py +84 -0
  44. tenxgraph_api/cli/main.py +936 -0
  45. tenxgraph_api/cli/templates/__init__.py +0 -0
  46. tenxgraph_api/cli/templates/defaults.py +844 -0
  47. tenxgraph_api/cli/templates/dev/.env.example +1 -0
  48. tenxgraph_api/cli/templates/dev/10xgraph.json +16 -0
  49. tenxgraph_api/cli/templates/dev/graph/__init__.py +0 -0
  50. tenxgraph_api/cli/templates/dev/graph/agent.py +63 -0
  51. tenxgraph_api/cli/templates/prod/.env.example +75 -0
  52. tenxgraph_api/cli/templates/prod/.pre-commit-config.yaml +50 -0
  53. tenxgraph_api/cli/templates/prod/.python-version +1 -0
  54. tenxgraph_api/cli/templates/prod/10xgraph.json +42 -0
  55. tenxgraph_api/cli/templates/prod/auth/__init__.py +0 -0
  56. tenxgraph_api/cli/templates/prod/auth/agent_auth.py +46 -0
  57. tenxgraph_api/cli/templates/prod/evals/__init__.py +0 -0
  58. tenxgraph_api/cli/templates/prod/evals/user_simulator_eval.py +76 -0
  59. tenxgraph_api/cli/templates/prod/evals/weather_agents_eval.py +53 -0
  60. tenxgraph_api/cli/templates/prod/graph/__init__.py +0 -0
  61. tenxgraph_api/cli/templates/prod/graph/agent.py +50 -0
  62. tenxgraph_api/cli/templates/prod/graph/state.py +5 -0
  63. tenxgraph_api/cli/templates/prod/graph/thread_name_generator.py +7 -0
  64. tenxgraph_api/cli/templates/prod/graph/tools/__init__.py +0 -0
  65. tenxgraph_api/cli/templates/prod/graph/tools/weather_tool.py +43 -0
  66. tenxgraph_api/cli/templates/prod/graph/validators/__init__.py +0 -0
  67. tenxgraph_api/cli/templates/prod/graph/validators/lifecyle.py +107 -0
  68. tenxgraph_api/cli/templates/prod/graph/validators/manager.py +9 -0
  69. tenxgraph_api/cli/templates/prod/graph/validators/validators.py +21 -0
  70. tenxgraph_api/cli/templates/prod/pyproject.toml +79 -0
  71. tenxgraph_api/cli/templates/prod/tests/__init__.py +0 -0
  72. tenxgraph_api/cli/templates/prod/tests/conftest.py +6 -0
  73. tenxgraph_api/cli/templates/prod/tests/test_agent_eval.py +78 -0
  74. tenxgraph_api/cli/templates/prod/tests/test_catalog_tools.py +47 -0
  75. tenxgraph_api/cli/templates/prod/tests/test_graph_nodes.py +57 -0
  76. tenxgraph_api/cli/templates/skills/10xgraph/SKILL.md +92 -0
  77. tenxgraph_api/cli/templates/skills/10xgraph/references/agents-and-tools.md +389 -0
  78. tenxgraph_api/cli/templates/skills/10xgraph/references/api-client.md +61 -0
  79. tenxgraph_api/cli/templates/skills/10xgraph/references/api-configuration.md +75 -0
  80. tenxgraph_api/cli/templates/skills/10xgraph/references/api-settings-and-middleware.md +89 -0
  81. tenxgraph_api/cli/templates/skills/10xgraph/references/architecture.md +91 -0
  82. tenxgraph_api/cli/templates/skills/10xgraph/references/auth-and-authorization.md +149 -0
  83. tenxgraph_api/cli/templates/skills/10xgraph/references/callbacks-and-command.md +283 -0
  84. tenxgraph_api/cli/templates/skills/10xgraph/references/checkpointing-and-threads.md +63 -0
  85. tenxgraph_api/cli/templates/skills/10xgraph/references/cli-commands.md +73 -0
  86. tenxgraph_api/cli/templates/skills/10xgraph/references/client-auth-and-errors.md +94 -0
  87. tenxgraph_api/cli/templates/skills/10xgraph/references/client-messages-invoke-stream.md +115 -0
  88. tenxgraph_api/cli/templates/skills/10xgraph/references/client-threads-memory-files.md +80 -0
  89. tenxgraph_api/cli/templates/skills/10xgraph/references/context-id-background.md +80 -0
  90. tenxgraph_api/cli/templates/skills/10xgraph/references/dependency-injection.md +58 -0
  91. tenxgraph_api/cli/templates/skills/10xgraph/references/evaluation.md +500 -0
  92. tenxgraph_api/cli/templates/skills/10xgraph/references/id-and-thread-name-generators.md +69 -0
  93. tenxgraph_api/cli/templates/skills/10xgraph/references/media-and-files.md +66 -0
  94. tenxgraph_api/cli/templates/skills/10xgraph/references/memory-and-store.md +70 -0
  95. tenxgraph_api/cli/templates/skills/10xgraph/references/prebuilt-agents-and-tools.md +394 -0
  96. tenxgraph_api/cli/templates/skills/10xgraph/references/production-runtime.md +56 -0
  97. tenxgraph_api/cli/templates/skills/10xgraph/references/providers-and-adapters.md +89 -0
  98. tenxgraph_api/cli/templates/skills/10xgraph/references/publishers-and-runtime-protocols.md +84 -0
  99. tenxgraph_api/cli/templates/skills/10xgraph/references/rate-limiting.md +194 -0
  100. tenxgraph_api/cli/templates/skills/10xgraph/references/realtime.md +172 -0
  101. tenxgraph_api/cli/templates/skills/10xgraph/references/remote-tools.md +55 -0
  102. tenxgraph_api/cli/templates/skills/10xgraph/references/rest-api-and-errors.md +97 -0
  103. tenxgraph_api/cli/templates/skills/10xgraph/references/security-and-validators.md +86 -0
  104. tenxgraph_api/cli/templates/skills/10xgraph/references/state-and-messages.md +68 -0
  105. tenxgraph_api/cli/templates/skills/10xgraph/references/state-graph.md +232 -0
  106. tenxgraph_api/cli/templates/skills/10xgraph/references/stream-emitter.md +144 -0
  107. tenxgraph_api/cli/templates/skills/10xgraph/references/streaming.md +53 -0
  108. tenxgraph_api/cli/templates/skills/10xgraph/references/testing-and-evaluation.md +64 -0
  109. tenxgraph_api/cli/templates/skills/10xgraph/references/unit-testing.md +202 -0
  110. tenxgraph_api/cli/templates/skills/copilot/10xgraph.instructions.md +108 -0
  111. tenxgraph_api/py.typed +0 -0
  112. tenxgraph_api/src/__init__.py +0 -0
  113. tenxgraph_api/src/app/__init__.py +13 -0
  114. tenxgraph_api/src/app/core/__init__.py +15 -0
  115. tenxgraph_api/src/app/core/auth/__init__.py +0 -0
  116. tenxgraph_api/src/app/core/auth/auth_backend.py +38 -0
  117. tenxgraph_api/src/app/core/auth/authorization.py +379 -0
  118. tenxgraph_api/src/app/core/auth/base_auth.py +27 -0
  119. tenxgraph_api/src/app/core/auth/jwt_auth.py +122 -0
  120. tenxgraph_api/src/app/core/auth/ownership_resolver.py +170 -0
  121. tenxgraph_api/src/app/core/auth/permissions.py +354 -0
  122. tenxgraph_api/src/app/core/auth/request_config.py +130 -0
  123. tenxgraph_api/src/app/core/auth/route_guard.py +105 -0
  124. tenxgraph_api/src/app/core/config/__init__.py +0 -0
  125. tenxgraph_api/src/app/core/config/graph_config.py +532 -0
  126. tenxgraph_api/src/app/core/config/media_settings.py +72 -0
  127. tenxgraph_api/src/app/core/config/sentry_config.py +60 -0
  128. tenxgraph_api/src/app/core/config/settings.py +202 -0
  129. tenxgraph_api/src/app/core/config/setup_logs.py +65 -0
  130. tenxgraph_api/src/app/core/config/setup_middleware.py +483 -0
  131. tenxgraph_api/src/app/core/config/worker_middleware.py +99 -0
  132. tenxgraph_api/src/app/core/exceptions/__init__.py +17 -0
  133. tenxgraph_api/src/app/core/exceptions/general_exception.py +58 -0
  134. tenxgraph_api/src/app/core/exceptions/handle_errors.py +363 -0
  135. tenxgraph_api/src/app/core/exceptions/resources_exceptions.py +72 -0
  136. tenxgraph_api/src/app/core/exceptions/user_exception.py +58 -0
  137. tenxgraph_api/src/app/core/middleware/__init__.py +25 -0
  138. tenxgraph_api/src/app/core/middleware/rate_limit/__init__.py +15 -0
  139. tenxgraph_api/src/app/core/middleware/rate_limit/base.py +27 -0
  140. tenxgraph_api/src/app/core/middleware/rate_limit/factory.py +98 -0
  141. tenxgraph_api/src/app/core/middleware/rate_limit/keying.py +133 -0
  142. tenxgraph_api/src/app/core/middleware/rate_limit/memory.py +98 -0
  143. tenxgraph_api/src/app/core/middleware/rate_limit/middleware.py +90 -0
  144. tenxgraph_api/src/app/core/middleware/rate_limit/redis.py +115 -0
  145. tenxgraph_api/src/app/core/middleware/request_limits.py +154 -0
  146. tenxgraph_api/src/app/core/middleware/security_headers.py +239 -0
  147. tenxgraph_api/src/app/core/utils/__init__.py +14 -0
  148. tenxgraph_api/src/app/core/utils/log_sanitizer.py +216 -0
  149. tenxgraph_api/src/app/loader.py +516 -0
  150. tenxgraph_api/src/app/main.py +180 -0
  151. tenxgraph_api/src/app/routers/__init__.py +4 -0
  152. tenxgraph_api/src/app/routers/ag_ui/__init__.py +10 -0
  153. tenxgraph_api/src/app/routers/ag_ui/converter.py +195 -0
  154. tenxgraph_api/src/app/routers/ag_ui/event_mapper.py +353 -0
  155. tenxgraph_api/src/app/routers/ag_ui/router.py +90 -0
  156. tenxgraph_api/src/app/routers/ag_ui/service.py +193 -0
  157. tenxgraph_api/src/app/routers/checkpointer/__init__.py +1 -0
  158. tenxgraph_api/src/app/routers/checkpointer/router.py +482 -0
  159. tenxgraph_api/src/app/routers/checkpointer/schemas/__init__.py +1 -0
  160. tenxgraph_api/src/app/routers/checkpointer/schemas/checkpointer_schemas.py +121 -0
  161. tenxgraph_api/src/app/routers/checkpointer/services/__init__.py +1 -0
  162. tenxgraph_api/src/app/routers/checkpointer/services/checkpointer_service.py +300 -0
  163. tenxgraph_api/src/app/routers/evals/__init__.py +6 -0
  164. tenxgraph_api/src/app/routers/evals/router.py +38 -0
  165. tenxgraph_api/src/app/routers/evals/services/__init__.py +1 -0
  166. tenxgraph_api/src/app/routers/evals/services/eval_report_service.py +354 -0
  167. tenxgraph_api/src/app/routers/graph/__init__.py +6 -0
  168. tenxgraph_api/src/app/routers/graph/realtime_guard.py +161 -0
  169. tenxgraph_api/src/app/routers/graph/router.py +821 -0
  170. tenxgraph_api/src/app/routers/graph/schemas/__init__.py +1 -0
  171. tenxgraph_api/src/app/routers/graph/schemas/graph_schemas.py +386 -0
  172. tenxgraph_api/src/app/routers/graph/services/__init__.py +1 -0
  173. tenxgraph_api/src/app/routers/graph/services/graph_service.py +1219 -0
  174. tenxgraph_api/src/app/routers/graph/services/multimodal_preprocessor.py +164 -0
  175. tenxgraph_api/src/app/routers/media/__init__.py +506 -0
  176. tenxgraph_api/src/app/routers/media/router.py +242 -0
  177. tenxgraph_api/src/app/routers/media/schemas.py +39 -0
  178. tenxgraph_api/src/app/routers/ping/__init__.py +4 -0
  179. tenxgraph_api/src/app/routers/ping/router.py +28 -0
  180. tenxgraph_api/src/app/routers/setup_router.py +60 -0
  181. tenxgraph_api/src/app/routers/store/__init__.py +4 -0
  182. tenxgraph_api/src/app/routers/store/router.py +200 -0
  183. tenxgraph_api/src/app/routers/store/schemas/__init__.py +28 -0
  184. tenxgraph_api/src/app/routers/store/schemas/store_schemas.py +200 -0
  185. tenxgraph_api/src/app/routers/store/services/__init__.py +4 -0
  186. tenxgraph_api/src/app/routers/store/services/store_service.py +203 -0
  187. tenxgraph_api/src/app/tasks/__init__.py +0 -0
  188. tenxgraph_api/src/app/tasks/user_tasks.py +38 -0
  189. tenxgraph_api/src/app/utils/__init__.py +12 -0
  190. tenxgraph_api/src/app/utils/callable_helper.py +26 -0
  191. tenxgraph_api/src/app/utils/media/__init__.py +15 -0
  192. tenxgraph_api/src/app/utils/media/extractor.py +172 -0
  193. tenxgraph_api/src/app/utils/media/pipeline.py +83 -0
  194. tenxgraph_api/src/app/utils/parse_output.py +17 -0
  195. tenxgraph_api/src/app/utils/response_helper.py +129 -0
  196. tenxgraph_api/src/app/utils/schemas/__init__.py +11 -0
  197. tenxgraph_api/src/app/utils/schemas/output_schemas.py +28 -0
  198. tenxgraph_api/src/app/utils/schemas/user_schemas.py +13 -0
  199. tenxgraph_api/src/app/utils/snowflake_id_generator.py +90 -0
  200. tenxgraph_api/src/app/utils/swagger_helper.py +160 -0
  201. tenxgraph_api/src/app/utils/telemetry_store.py +123 -0
  202. tenxgraph_api/src/app/utils/thread_name_generator.py +304 -0
  203. tenxgraph_api/src/app/worker.py +91 -0
@@ -0,0 +1,164 @@
1
+ """Pre-processing utilities for multimodal messages at the API boundary."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ import logging
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ from tenxgraph.core.state import Message
10
+ from tenxgraph.core.state.message_block import DocumentBlock, TextBlock
11
+ from tenxgraph.utils.media_scheme import MEDIA_SCHEME, is_internal_media_url, strip_media_scheme
12
+
13
+
14
+ if TYPE_CHECKING:
15
+ from tenxgraph_api.src.app.routers.media import MediaService
16
+
17
+ logger = logging.getLogger("tenxgraph_api.media")
18
+
19
+
20
+ def _internal_media_keys(message: Message | dict[str, Any]) -> list[str]:
21
+ """Every internal media key referenced anywhere in a message's content.
22
+
23
+ Both ``graph://media/`` and the legacy ``agentflow://media/`` resolve to a stored
24
+ file, so both are collected for the ownership check.
25
+
26
+ Walks the whole content tree, not just top-level blocks, so references nested in tool
27
+ results or other structured output are found too.
28
+ """
29
+ keys: list[str] = []
30
+
31
+ def walk(node: Any) -> None:
32
+ if isinstance(node, dict):
33
+ for key, value in node.items():
34
+ if key == "url" and isinstance(value, str) and is_internal_media_url(value):
35
+ keys.append(strip_media_scheme(value))
36
+ else:
37
+ walk(value)
38
+ elif isinstance(node, list):
39
+ for item in node:
40
+ walk(item)
41
+
42
+ if isinstance(message, dict):
43
+ walk(message.get("content"))
44
+ else:
45
+ walk(message.model_dump(mode="python").get("content"))
46
+ return keys
47
+
48
+
49
+ async def _ensure_access(media_service: MediaService, file_id: str, user_id: str) -> None:
50
+ """Ownership check with one answer for missing and foreign files.
51
+
52
+ Both become the same ``ValueError`` (a 422 on invoke), so the response never tells a
53
+ caller whether another user's file exists.
54
+ """
55
+ try:
56
+ await media_service.ensure_can_access(file_id, user_id)
57
+ except (KeyError, PermissionError) as exc:
58
+ raise ValueError(f"File not found: {file_id}") from exc
59
+
60
+
61
+ async def check_media_references(
62
+ messages: list[Message] | list[dict[str, Any]],
63
+ media_service: MediaService | None,
64
+ user_id: str,
65
+ ) -> None:
66
+ """Run the ownership check on every internal media reference a client sent.
67
+
68
+ A client may reference ``graph://media/<key>`` directly instead of a ``file_id``;
69
+ the graph resolves both the same way, so both must be checked. Also used for messages
70
+ written straight into a thread, since the next run resolves those references too.
71
+ """
72
+ for message in messages:
73
+ keys = list(dict.fromkeys(_internal_media_keys(message)))
74
+ if not keys:
75
+ continue
76
+ if media_service is None:
77
+ raise ValueError(
78
+ "Internal media references cannot be verified: file uploads are not "
79
+ "configured on this server."
80
+ )
81
+ for key in keys:
82
+ await _ensure_access(media_service, key, user_id)
83
+
84
+
85
+ async def _get_cached_extraction(media_service: MediaService, file_id: str) -> str | None:
86
+ """Read cached extraction from async or sync service APIs."""
87
+ async_getter = getattr(media_service, "aget_cached_extraction", None)
88
+ if callable(async_getter):
89
+ result = async_getter(file_id)
90
+ if inspect.isawaitable(result):
91
+ return await result
92
+
93
+ sync_getter = getattr(media_service, "get_cached_extraction", None)
94
+ if callable(sync_getter):
95
+ return sync_getter(file_id)
96
+
97
+ if callable(async_getter):
98
+ return result
99
+
100
+ return None
101
+
102
+
103
+ async def preprocess_multimodal_messages(
104
+ messages: list[Message],
105
+ media_service: MediaService | None,
106
+ user_id: str | None = None,
107
+ ) -> list[Message]:
108
+ """Resolve file_id references in messages before graph execution.
109
+
110
+ For each ``DocumentBlock`` with a ``file_id``:
111
+ - If cached extracted text exists, replace with a ``TextBlock``.
112
+ - Otherwise pass through unchanged (the agent converter will handle it).
113
+
114
+ For each ``ImageBlock``/``AudioBlock`` with a ``file_id``:
115
+ - Convert to a ``graph://media/{file_id}`` URL-based reference so
116
+ the MediaRefResolver can pick it up at LLM-call time.
117
+
118
+ With an authenticated ``user_id``, every file the messages reference -- by ``file_id``
119
+ or by an internal ``graph://media/`` URL -- must belong to that user.
120
+ ``media_service`` being None skips the rewriting, but not the ownership rule.
121
+ """
122
+ if user_id:
123
+ await check_media_references(messages, media_service, user_id)
124
+ if media_service is None:
125
+ return messages
126
+
127
+ processed: list[Message] = []
128
+ for msg in messages:
129
+ new_content = []
130
+ changed = False
131
+
132
+ for block in msg.content:
133
+ if (
134
+ isinstance(block, DocumentBlock)
135
+ and block.media.kind == "file_id"
136
+ and block.media.file_id
137
+ ):
138
+ if user_id:
139
+ await _ensure_access(media_service, block.media.file_id, user_id)
140
+ cached = await _get_cached_extraction(media_service, block.media.file_id)
141
+ if cached:
142
+ new_content.append(TextBlock(text=cached))
143
+ changed = True
144
+ continue
145
+
146
+ if hasattr(block, "media") and block.media.kind == "file_id" and block.media.file_id:
147
+ fid = block.media.file_id
148
+ if user_id:
149
+ await _ensure_access(media_service, fid, user_id)
150
+ # Convert file_id → graph://media/ URL reference
151
+ if not is_internal_media_url(block.media.url):
152
+ block.media.kind = "url"
153
+ block.media.url = f"{MEDIA_SCHEME}{fid}"
154
+ changed = True
155
+
156
+ new_content.append(block)
157
+
158
+ if changed:
159
+ new_msg = msg.model_copy(update={"content": new_content})
160
+ processed.append(new_msg)
161
+ else:
162
+ processed.append(msg)
163
+
164
+ return processed
@@ -0,0 +1,506 @@
1
+ """Media service — provides the configured MediaStore and DocumentPipeline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ import json
7
+ import logging
8
+ import time
9
+ from collections import OrderedDict
10
+ from typing import Any
11
+
12
+ from injectq import InjectQ, inject, singleton
13
+ from tenxgraph.storage.checkpointer import BaseCheckpointer
14
+ from tenxgraph.storage.media.config import DocumentHandling
15
+ from tenxgraph.storage.media.storage.base import BaseMediaStore
16
+
17
+ from tenxgraph_api.src.app.core.config.media_settings import MediaSettings, MediaStorageType
18
+ from tenxgraph_api.src.app.utils.media.extractor import DocumentExtractor
19
+ from tenxgraph_api.src.app.utils.media.pipeline import DocumentPipeline
20
+
21
+
22
+ logger = logging.getLogger("tenxgraph_api.media")
23
+
24
+
25
+ _SIGNED_URL_NAMESPACE = "media:signed-url"
26
+ _EXTRACTION_NAMESPACE = "media:extraction"
27
+
28
+ # In-process caches in front of the shared checkpointer cache. Bounded so a stream of new
29
+ # uploads cannot grow a worker's memory without limit.
30
+ MAX_SIGNED_URL_ENTRIES = 4096
31
+ MAX_EXTRACTION_ENTRIES = 512
32
+ MAX_EXTRACTION_CHARS = 32_000_000
33
+
34
+
35
+ class _BoundedCache(OrderedDict):
36
+ """A dict that evicts least-recently-used entries past ``max_entries`` or ``max_chars``.
37
+
38
+ ``max_chars`` sums ``len()`` of string values, so a few large extractions cannot hold
39
+ more memory than the entry count suggests.
40
+ """
41
+
42
+ def __init__(self, max_entries: int, max_chars: int | None = None):
43
+ super().__init__()
44
+ self.max_entries = max_entries
45
+ self.max_chars = max_chars
46
+ self._chars = 0
47
+
48
+ @staticmethod
49
+ def _size(value: Any) -> int:
50
+ return len(value) if isinstance(value, str) else 0
51
+
52
+ def get(self, key: Any, default: Any = None) -> Any:
53
+ if key not in self:
54
+ return default
55
+ self.move_to_end(key)
56
+ return self[key]
57
+
58
+ def __setitem__(self, key: Any, value: Any) -> None:
59
+ if key in self:
60
+ self._chars -= self._size(self[key])
61
+ super().__setitem__(key, value)
62
+ self.move_to_end(key)
63
+ self._chars += self._size(value)
64
+ while len(self) > self.max_entries or (
65
+ self.max_chars is not None and self._chars > self.max_chars and len(self) > 1
66
+ ):
67
+ self.popitem(last=False)
68
+
69
+ def __delitem__(self, key: Any) -> None:
70
+ self._chars -= self._size(self[key])
71
+ super().__delitem__(key)
72
+
73
+ def pop(self, key: Any, *default: Any) -> Any:
74
+ if key in self:
75
+ self._chars -= self._size(self[key])
76
+ return super().pop(key, *default)
77
+
78
+ def popitem(self, last: bool = True) -> tuple[Any, Any]:
79
+ key, value = super().popitem(last=last)
80
+ self._chars -= self._size(value)
81
+ return key, value
82
+
83
+
84
+ def _build_config_instance(config_cls: type[Any], values: dict[str, Any]) -> Any:
85
+ """Instantiate third-party config classes using only supported kwargs."""
86
+ params = inspect.signature(config_cls).parameters
87
+ supported = {
88
+ name: value for name, value in values.items() if name in params and value not in (None, "")
89
+ }
90
+ return config_cls(**supported)
91
+
92
+
93
+ def _create_media_store(settings: MediaSettings) -> BaseMediaStore:
94
+ """Factory that instantiates the right MediaStore from settings."""
95
+ stype = settings.MEDIA_STORAGE_TYPE
96
+
97
+ if stype == MediaStorageType.MEMORY:
98
+ from tenxgraph.storage.media.storage.memory_store import InMemoryMediaStore
99
+
100
+ return InMemoryMediaStore()
101
+
102
+ if stype == MediaStorageType.LOCAL:
103
+ from tenxgraph.storage.media.storage.local_store import LocalFileMediaStore
104
+
105
+ return LocalFileMediaStore(base_dir=settings.MEDIA_STORAGE_PATH)
106
+
107
+ if stype == MediaStorageType.CLOUD:
108
+ from cloud_storage_manager import (
109
+ AwsConfig,
110
+ CloudStorageFactory,
111
+ StorageConfig,
112
+ StorageProvider,
113
+ )
114
+ from tenxgraph.storage.media.storage.cloud_store import CloudMediaStore
115
+
116
+ provider_name = settings.MEDIA_CLOUD_PROVIDER.lower()
117
+ provider = getattr(StorageProvider, provider_name.upper(), None)
118
+ if provider is None and provider_name == "gcs":
119
+ provider = getattr(StorageProvider, "GCP", None)
120
+ if provider is None:
121
+ raise ValueError(f"Unsupported MEDIA_CLOUD_PROVIDER: {settings.MEDIA_CLOUD_PROVIDER}")
122
+
123
+ storage_kwargs: dict[str, Any] = {}
124
+
125
+ if provider_name == "aws":
126
+ storage_kwargs["aws"] = _build_config_instance(
127
+ AwsConfig,
128
+ {
129
+ "bucket_name": settings.MEDIA_CLOUD_BUCKET,
130
+ "region": settings.MEDIA_CLOUD_REGION,
131
+ "region_name": settings.MEDIA_CLOUD_REGION,
132
+ "access_key_id": settings.MEDIA_CLOUD_ACCESS_KEY_ID,
133
+ "secret_access_key": settings.MEDIA_CLOUD_SECRET_ACCESS_KEY,
134
+ "session_token": settings.MEDIA_CLOUD_SESSION_TOKEN,
135
+ },
136
+ )
137
+ else:
138
+ from cloud_storage_manager import GcpConfig
139
+
140
+ credentials = None
141
+ if settings.MEDIA_CLOUD_CREDENTIALS_JSON:
142
+ try:
143
+ credentials = json.loads(settings.MEDIA_CLOUD_CREDENTIALS_JSON)
144
+ except json.JSONDecodeError as exc:
145
+ raise ValueError("MEDIA_CLOUD_CREDENTIALS_JSON must be valid JSON") from exc
146
+
147
+ storage_kwargs["gcp"] = _build_config_instance(
148
+ GcpConfig,
149
+ {
150
+ "bucket_name": settings.MEDIA_CLOUD_BUCKET,
151
+ "project_id": settings.MEDIA_CLOUD_PROJECT_ID,
152
+ "credentials": credentials,
153
+ "credentials_json": credentials,
154
+ },
155
+ )
156
+
157
+ storage = CloudStorageFactory.get_storage(provider, StorageConfig(**storage_kwargs))
158
+ return CloudMediaStore(
159
+ storage=storage,
160
+ prefix=settings.MEDIA_CLOUD_PREFIX,
161
+ )
162
+
163
+ raise ValueError(f"Unknown MEDIA_STORAGE_TYPE: {stype}")
164
+
165
+
166
+ def _create_document_pipeline(settings: MediaSettings) -> DocumentPipeline:
167
+ handling_map = {
168
+ "extract_text": DocumentHandling.EXTRACT_TEXT,
169
+ "pass_raw": DocumentHandling.FORWARD_RAW,
170
+ "skip": DocumentHandling.SKIP,
171
+ }
172
+ handling = handling_map.get(settings.DOCUMENT_HANDLING, DocumentHandling.EXTRACT_TEXT)
173
+
174
+ extractor = DocumentExtractor() if handling == DocumentHandling.EXTRACT_TEXT else None
175
+ return DocumentPipeline(document_extractor=extractor, handling=handling)
176
+
177
+
178
+ @singleton
179
+ class MediaService:
180
+ """Central media service providing storage + document extraction."""
181
+
182
+ @inject
183
+ def __init__(
184
+ self,
185
+ settings: MediaSettings | None = None,
186
+ checkpointer: BaseCheckpointer | None = None,
187
+ ):
188
+ from tenxgraph_api.src.app.core.config.media_settings import get_media_settings
189
+
190
+ self._settings = settings or get_media_settings()
191
+ self._checkpointer = checkpointer
192
+ self._store: BaseMediaStore | None = None
193
+ self._pipeline: DocumentPipeline | None = None
194
+ # file_id -> extracted text
195
+ self._extraction_cache = _BoundedCache(MAX_EXTRACTION_ENTRIES, MAX_EXTRACTION_CHARS)
196
+ # file_id -> signed URL payload
197
+ self._signed_url_cache = _BoundedCache(MAX_SIGNED_URL_ENTRIES)
198
+
199
+ @property
200
+ def store(self) -> BaseMediaStore:
201
+ if self._store is None:
202
+ self._store = _create_media_store(self._settings)
203
+ return self._store
204
+
205
+ @property
206
+ def pipeline(self) -> DocumentPipeline:
207
+ if self._pipeline is None:
208
+ self._pipeline = _create_document_pipeline(self._settings)
209
+ return self._pipeline
210
+
211
+ @property
212
+ def checkpointer(self) -> BaseCheckpointer | None:
213
+ if self._checkpointer is None:
214
+ try:
215
+ self._checkpointer = InjectQ.get_instance().try_get(BaseCheckpointer)
216
+ except Exception:
217
+ self._checkpointer = None
218
+ return self._checkpointer
219
+
220
+ @property
221
+ def max_size_bytes(self) -> int:
222
+ return int(self._settings.MEDIA_MAX_SIZE_MB * 1024 * 1024)
223
+
224
+ # ------------------------------------------------------------------
225
+ # File operations
226
+ # ------------------------------------------------------------------
227
+
228
+ async def ensure_can_access(self, file_id: str, user_id: str | None) -> None:
229
+ """Reject access to a file owned by a different user.
230
+
231
+ Files had no owner concept at all: `get_file(file_id)` returned bytes to any
232
+ authenticated caller who knew (or guessed) the id. Ownership is recorded in
233
+ the file's own metadata at upload time, so it is as durable as the file.
234
+
235
+ Legacy files uploaded before ownership existed carry no `owner_id`. Those are
236
+ allowed through (with a warning) rather than becoming unreadable, unless
237
+ ``MEDIA_REQUIRE_OWNER`` is set -- deployments that need a hard guarantee can
238
+ turn the check strict.
239
+ """
240
+ if not user_id:
241
+ return
242
+
243
+ try:
244
+ metadata = await self.store.get_metadata(file_id)
245
+ except Exception:
246
+ metadata = None
247
+
248
+ if metadata is None:
249
+ raise KeyError(f"File not found: {file_id}")
250
+
251
+ owner_id = metadata.get("owner_id")
252
+
253
+ if owner_id is None:
254
+ if self._settings.MEDIA_REQUIRE_OWNER:
255
+ raise PermissionError(f"File has no recorded owner: {file_id}")
256
+ logger.warning(
257
+ "File %s has no recorded owner (uploaded before ownership tracking); "
258
+ "allowing access. Set MEDIA_REQUIRE_OWNER=true to deny these.",
259
+ file_id,
260
+ )
261
+ return
262
+
263
+ if str(owner_id) != str(user_id):
264
+ # Deliberately not "you don't own this" -- do not confirm the id exists.
265
+ logger.warning(
266
+ "User %s attempted to access file %s owned by another user",
267
+ user_id,
268
+ file_id,
269
+ )
270
+ raise PermissionError(f"File not accessible: {file_id}")
271
+
272
+ async def upload_file(
273
+ self,
274
+ data: bytes,
275
+ filename: str,
276
+ mime_type: str,
277
+ user_id: str | None = None,
278
+ ) -> dict[str, Any]:
279
+ """Upload a file: store binary + optionally extract text.
280
+
281
+ Records the uploader as the file's owner, so it cannot later be read by a
282
+ different user who happens to know the id.
283
+
284
+ Returns a dict with file_id, mime_type, size_bytes, filename,
285
+ extracted_text (nullable), and url.
286
+ """
287
+ if len(data) > self.max_size_bytes:
288
+ raise ValueError(
289
+ f"File size {len(data)} exceeds maximum {self._settings.MEDIA_MAX_SIZE_MB}MB"
290
+ )
291
+
292
+ # Store binary in the configured MediaStore. The owner is written into the
293
+ # file's own metadata, so ownership survives as long as the file does --
294
+ # unlike a cache entry, which would expire and silently un-own it.
295
+ file_metadata: dict[str, Any] = {"filename": filename}
296
+ if user_id:
297
+ file_metadata["owner_id"] = str(user_id)
298
+
299
+ storage_key = await self.store.store(
300
+ data,
301
+ mime_type,
302
+ metadata=file_metadata,
303
+ )
304
+
305
+ result: dict[str, Any] = {
306
+ "file_id": storage_key,
307
+ "mime_type": mime_type,
308
+ "size_bytes": len(data),
309
+ "filename": filename,
310
+ "extracted_text": None,
311
+ "url": f"/v1/files/{storage_key}",
312
+ "direct_url": None,
313
+ "direct_url_expires_at": None,
314
+ }
315
+
316
+ # If it's a document, try extraction
317
+ if self.pipeline.handling == DocumentHandling.EXTRACT_TEXT and self._is_extractable(
318
+ mime_type
319
+ ):
320
+ try:
321
+ text = await self.pipeline.extractor.extract(data, filename, mime_type)
322
+ if text:
323
+ result["extracted_text"] = text
324
+ await self._cache_extraction(storage_key, text)
325
+ except Exception as exc:
326
+ logger.warning("Extraction failed for %s: %s", filename, exc)
327
+
328
+ direct_url_info = await self.get_direct_url_info(
329
+ storage_key,
330
+ mime_type=mime_type,
331
+ )
332
+ if direct_url_info:
333
+ result["direct_url"] = direct_url_info["url"]
334
+ result["direct_url_expires_at"] = direct_url_info["expires_at"]
335
+
336
+ return result
337
+
338
+ async def get_file(self, file_id: str, user_id: str | None = None) -> tuple[bytes, str]:
339
+ """Retrieve file bytes and MIME type."""
340
+ if user_id:
341
+ await self.ensure_can_access(file_id, user_id)
342
+ return await self.store.retrieve(file_id)
343
+
344
+ async def get_file_info(self, file_id: str, user_id: str | None = None) -> dict[str, Any]:
345
+ """Return metadata about a stored file."""
346
+ if user_id:
347
+ await self.ensure_can_access(file_id, user_id)
348
+ metadata = await self.store.get_metadata(file_id)
349
+ if metadata is None:
350
+ raise KeyError(f"File not found: {file_id}")
351
+
352
+ info = {
353
+ "file_id": file_id,
354
+ "mime_type": metadata["mime_type"],
355
+ "size_bytes": metadata["size_bytes"],
356
+ "filename": metadata.get("filename"),
357
+ "extracted_text": await self.aget_cached_extraction(file_id),
358
+ "direct_url": None,
359
+ "direct_url_expires_at": None,
360
+ }
361
+ direct_url_info = await self.get_direct_url_info(
362
+ file_id,
363
+ mime_type=metadata["mime_type"],
364
+ )
365
+ if direct_url_info:
366
+ info["direct_url"] = direct_url_info["url"]
367
+ info["direct_url_expires_at"] = direct_url_info["expires_at"]
368
+ return info
369
+
370
+ async def get_direct_url_info(
371
+ self,
372
+ file_id: str,
373
+ mime_type: str | None = None,
374
+ expiration_seconds: int | None = None,
375
+ ) -> dict[str, Any] | None:
376
+ """Return a cached direct URL payload when the store supports it."""
377
+ ttl = expiration_seconds or self._settings.MEDIA_SIGNED_URL_TTL_SECONDS
378
+ refresh_buffer = self._settings.MEDIA_SIGNED_URL_REFRESH_BUFFER_SECONDS
379
+
380
+ if mime_type is None:
381
+ metadata = await self.store.get_metadata(file_id)
382
+ if metadata is None:
383
+ raise KeyError(f"File not found: {file_id}")
384
+ mime_type = metadata["mime_type"]
385
+
386
+ cache_key = f"{file_id}:{mime_type}:{ttl}"
387
+ cached = await self._get_cached_payload(
388
+ namespace=_SIGNED_URL_NAMESPACE,
389
+ cache_key=cache_key,
390
+ local_cache=self._signed_url_cache,
391
+ )
392
+ if cached and cached.get("expires_at", 0) > int(time.time()) + refresh_buffer:
393
+ return cached
394
+
395
+ direct_url = await self.store.get_direct_url(
396
+ file_id,
397
+ mime_type=mime_type,
398
+ expiration=ttl,
399
+ )
400
+ if direct_url is None:
401
+ return None
402
+
403
+ payload = {
404
+ "url": direct_url,
405
+ "expires_at": int(time.time() + ttl),
406
+ }
407
+ await self._cache_payload(
408
+ namespace=_SIGNED_URL_NAMESPACE,
409
+ cache_key=cache_key,
410
+ payload=payload,
411
+ local_cache=self._signed_url_cache,
412
+ ttl_seconds=ttl,
413
+ )
414
+ return payload
415
+
416
+ def get_cached_extraction(self, file_id: str) -> str | None:
417
+ """Return cached extracted text for a file_id."""
418
+ return self._extraction_cache.get(file_id)
419
+
420
+ async def aget_cached_extraction(self, file_id: str) -> str | None:
421
+ """Return cached extracted text from local memory or shared cache."""
422
+ cached = self._extraction_cache.get(file_id)
423
+ if cached is not None:
424
+ return cached
425
+
426
+ payload = await self._get_cached_payload(
427
+ namespace=_EXTRACTION_NAMESPACE,
428
+ cache_key=file_id,
429
+ local_cache=None,
430
+ )
431
+ if isinstance(payload, dict):
432
+ text = payload.get("text")
433
+ if isinstance(text, str):
434
+ self._extraction_cache[file_id] = text
435
+ return text
436
+ return None
437
+
438
+ async def _cache_extraction(self, file_id: str, text: str) -> None:
439
+ self._extraction_cache[file_id] = text
440
+ await self._cache_payload(
441
+ namespace=_EXTRACTION_NAMESPACE,
442
+ cache_key=file_id,
443
+ payload={"text": text},
444
+ local_cache=None,
445
+ ttl_seconds=24 * 3600,
446
+ )
447
+
448
+ async def _get_cached_payload(
449
+ self,
450
+ namespace: str,
451
+ cache_key: str,
452
+ local_cache: dict[str, dict[str, Any]] | None,
453
+ ) -> dict[str, Any] | None:
454
+ if local_cache is not None:
455
+ cached = local_cache.get(cache_key)
456
+ if cached and cached.get("expires_at", float("inf")) > time.time():
457
+ return cached
458
+ if cached:
459
+ local_cache.pop(cache_key, None)
460
+
461
+ if self.checkpointer is None:
462
+ return None
463
+
464
+ payload = await self.checkpointer.aget_cache_value(namespace, cache_key)
465
+ if isinstance(payload, dict) and local_cache is not None:
466
+ local_cache[cache_key] = payload
467
+ return payload if isinstance(payload, dict) else None
468
+
469
+ async def _cache_payload(
470
+ self,
471
+ namespace: str,
472
+ cache_key: str,
473
+ payload: dict[str, Any],
474
+ local_cache: dict[str, dict[str, Any]] | None,
475
+ ttl_seconds: int,
476
+ ) -> None:
477
+ if local_cache is not None:
478
+ local_cache[cache_key] = payload
479
+
480
+ if self.checkpointer is not None:
481
+ await self.checkpointer.aput_cache_value(
482
+ namespace,
483
+ cache_key,
484
+ payload,
485
+ ttl_seconds=ttl_seconds,
486
+ )
487
+
488
+ # ------------------------------------------------------------------
489
+ # Helpers
490
+ # ------------------------------------------------------------------
491
+
492
+ @staticmethod
493
+ def _is_extractable(mime_type: str) -> bool:
494
+ extractable = {
495
+ "application/pdf",
496
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
497
+ "application/msword",
498
+ "text/html",
499
+ "text/xml",
500
+ "application/xml",
501
+ "text/markdown",
502
+ "text/csv",
503
+ "application/json",
504
+ "text/plain",
505
+ }
506
+ return mime_type.lower() in extractable