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.
- 10xgraph_api-0.7.0.dist-info/METADATA +618 -0
- 10xgraph_api-0.7.0.dist-info/RECORD +203 -0
- 10xgraph_api-0.7.0.dist-info/WHEEL +5 -0
- 10xgraph_api-0.7.0.dist-info/entry_points.txt +3 -0
- 10xgraph_api-0.7.0.dist-info/licenses/LICENSE +21 -0
- 10xgraph_api-0.7.0.dist-info/top_level.txt +2 -0
- agentflow_cli/__init__.py +69 -0
- tenxgraph_api/__init__.py +23 -0
- tenxgraph_api/cli/__init__.py +6 -0
- tenxgraph_api/cli/capabilities.py +127 -0
- tenxgraph_api/cli/commands/__init__.py +49 -0
- tenxgraph_api/cli/commands/api.py +262 -0
- tenxgraph_api/cli/commands/audit.py +213 -0
- tenxgraph_api/cli/commands/build.py +305 -0
- tenxgraph_api/cli/commands/config.py +71 -0
- tenxgraph_api/cli/commands/demo.py +93 -0
- tenxgraph_api/cli/commands/eval.py +909 -0
- tenxgraph_api/cli/commands/init.py +531 -0
- tenxgraph_api/cli/commands/skills.py +532 -0
- tenxgraph_api/cli/commands/test.py +113 -0
- tenxgraph_api/cli/commands/version.py +47 -0
- tenxgraph_api/cli/config_editor/__init__.py +15 -0
- tenxgraph_api/cli/config_editor/schema.py +566 -0
- tenxgraph_api/cli/config_editor/server.py +240 -0
- tenxgraph_api/cli/config_editor/static/app.css +2 -0
- tenxgraph_api/cli/config_editor/static/app.js +3 -0
- tenxgraph_api/cli/config_editor/static/favicon.svg +11 -0
- tenxgraph_api/cli/config_editor/static/index.html +20 -0
- tenxgraph_api/cli/config_editor/store.py +120 -0
- tenxgraph_api/cli/config_editor/validation.py +377 -0
- tenxgraph_api/cli/constants.py +112 -0
- tenxgraph_api/cli/context.py +46 -0
- tenxgraph_api/cli/core/__init__.py +0 -0
- tenxgraph_api/cli/core/animations.py +550 -0
- tenxgraph_api/cli/core/config.py +279 -0
- tenxgraph_api/cli/core/output.py +625 -0
- tenxgraph_api/cli/core/prompts.py +220 -0
- tenxgraph_api/cli/core/screen.py +287 -0
- tenxgraph_api/cli/core/steps.py +621 -0
- tenxgraph_api/cli/core/theme.py +242 -0
- tenxgraph_api/cli/core/validation.py +251 -0
- tenxgraph_api/cli/exceptions.py +129 -0
- tenxgraph_api/cli/logger.py +84 -0
- tenxgraph_api/cli/main.py +936 -0
- tenxgraph_api/cli/templates/__init__.py +0 -0
- tenxgraph_api/cli/templates/defaults.py +844 -0
- tenxgraph_api/cli/templates/dev/.env.example +1 -0
- tenxgraph_api/cli/templates/dev/10xgraph.json +16 -0
- tenxgraph_api/cli/templates/dev/graph/__init__.py +0 -0
- tenxgraph_api/cli/templates/dev/graph/agent.py +63 -0
- tenxgraph_api/cli/templates/prod/.env.example +75 -0
- tenxgraph_api/cli/templates/prod/.pre-commit-config.yaml +50 -0
- tenxgraph_api/cli/templates/prod/.python-version +1 -0
- tenxgraph_api/cli/templates/prod/10xgraph.json +42 -0
- tenxgraph_api/cli/templates/prod/auth/__init__.py +0 -0
- tenxgraph_api/cli/templates/prod/auth/agent_auth.py +46 -0
- tenxgraph_api/cli/templates/prod/evals/__init__.py +0 -0
- tenxgraph_api/cli/templates/prod/evals/user_simulator_eval.py +76 -0
- tenxgraph_api/cli/templates/prod/evals/weather_agents_eval.py +53 -0
- tenxgraph_api/cli/templates/prod/graph/__init__.py +0 -0
- tenxgraph_api/cli/templates/prod/graph/agent.py +50 -0
- tenxgraph_api/cli/templates/prod/graph/state.py +5 -0
- tenxgraph_api/cli/templates/prod/graph/thread_name_generator.py +7 -0
- tenxgraph_api/cli/templates/prod/graph/tools/__init__.py +0 -0
- tenxgraph_api/cli/templates/prod/graph/tools/weather_tool.py +43 -0
- tenxgraph_api/cli/templates/prod/graph/validators/__init__.py +0 -0
- tenxgraph_api/cli/templates/prod/graph/validators/lifecyle.py +107 -0
- tenxgraph_api/cli/templates/prod/graph/validators/manager.py +9 -0
- tenxgraph_api/cli/templates/prod/graph/validators/validators.py +21 -0
- tenxgraph_api/cli/templates/prod/pyproject.toml +79 -0
- tenxgraph_api/cli/templates/prod/tests/__init__.py +0 -0
- tenxgraph_api/cli/templates/prod/tests/conftest.py +6 -0
- tenxgraph_api/cli/templates/prod/tests/test_agent_eval.py +78 -0
- tenxgraph_api/cli/templates/prod/tests/test_catalog_tools.py +47 -0
- tenxgraph_api/cli/templates/prod/tests/test_graph_nodes.py +57 -0
- tenxgraph_api/cli/templates/skills/10xgraph/SKILL.md +92 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/agents-and-tools.md +389 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/api-client.md +61 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/api-configuration.md +75 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/api-settings-and-middleware.md +89 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/architecture.md +91 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/auth-and-authorization.md +149 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/callbacks-and-command.md +283 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/checkpointing-and-threads.md +63 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/cli-commands.md +73 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/client-auth-and-errors.md +94 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/client-messages-invoke-stream.md +115 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/client-threads-memory-files.md +80 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/context-id-background.md +80 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/dependency-injection.md +58 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/evaluation.md +500 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/id-and-thread-name-generators.md +69 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/media-and-files.md +66 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/memory-and-store.md +70 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/prebuilt-agents-and-tools.md +394 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/production-runtime.md +56 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/providers-and-adapters.md +89 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/publishers-and-runtime-protocols.md +84 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/rate-limiting.md +194 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/realtime.md +172 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/remote-tools.md +55 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/rest-api-and-errors.md +97 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/security-and-validators.md +86 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/state-and-messages.md +68 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/state-graph.md +232 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/stream-emitter.md +144 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/streaming.md +53 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/testing-and-evaluation.md +64 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/unit-testing.md +202 -0
- tenxgraph_api/cli/templates/skills/copilot/10xgraph.instructions.md +108 -0
- tenxgraph_api/py.typed +0 -0
- tenxgraph_api/src/__init__.py +0 -0
- tenxgraph_api/src/app/__init__.py +13 -0
- tenxgraph_api/src/app/core/__init__.py +15 -0
- tenxgraph_api/src/app/core/auth/__init__.py +0 -0
- tenxgraph_api/src/app/core/auth/auth_backend.py +38 -0
- tenxgraph_api/src/app/core/auth/authorization.py +379 -0
- tenxgraph_api/src/app/core/auth/base_auth.py +27 -0
- tenxgraph_api/src/app/core/auth/jwt_auth.py +122 -0
- tenxgraph_api/src/app/core/auth/ownership_resolver.py +170 -0
- tenxgraph_api/src/app/core/auth/permissions.py +354 -0
- tenxgraph_api/src/app/core/auth/request_config.py +130 -0
- tenxgraph_api/src/app/core/auth/route_guard.py +105 -0
- tenxgraph_api/src/app/core/config/__init__.py +0 -0
- tenxgraph_api/src/app/core/config/graph_config.py +532 -0
- tenxgraph_api/src/app/core/config/media_settings.py +72 -0
- tenxgraph_api/src/app/core/config/sentry_config.py +60 -0
- tenxgraph_api/src/app/core/config/settings.py +202 -0
- tenxgraph_api/src/app/core/config/setup_logs.py +65 -0
- tenxgraph_api/src/app/core/config/setup_middleware.py +483 -0
- tenxgraph_api/src/app/core/config/worker_middleware.py +99 -0
- tenxgraph_api/src/app/core/exceptions/__init__.py +17 -0
- tenxgraph_api/src/app/core/exceptions/general_exception.py +58 -0
- tenxgraph_api/src/app/core/exceptions/handle_errors.py +363 -0
- tenxgraph_api/src/app/core/exceptions/resources_exceptions.py +72 -0
- tenxgraph_api/src/app/core/exceptions/user_exception.py +58 -0
- tenxgraph_api/src/app/core/middleware/__init__.py +25 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/__init__.py +15 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/base.py +27 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/factory.py +98 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/keying.py +133 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/memory.py +98 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/middleware.py +90 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/redis.py +115 -0
- tenxgraph_api/src/app/core/middleware/request_limits.py +154 -0
- tenxgraph_api/src/app/core/middleware/security_headers.py +239 -0
- tenxgraph_api/src/app/core/utils/__init__.py +14 -0
- tenxgraph_api/src/app/core/utils/log_sanitizer.py +216 -0
- tenxgraph_api/src/app/loader.py +516 -0
- tenxgraph_api/src/app/main.py +180 -0
- tenxgraph_api/src/app/routers/__init__.py +4 -0
- tenxgraph_api/src/app/routers/ag_ui/__init__.py +10 -0
- tenxgraph_api/src/app/routers/ag_ui/converter.py +195 -0
- tenxgraph_api/src/app/routers/ag_ui/event_mapper.py +353 -0
- tenxgraph_api/src/app/routers/ag_ui/router.py +90 -0
- tenxgraph_api/src/app/routers/ag_ui/service.py +193 -0
- tenxgraph_api/src/app/routers/checkpointer/__init__.py +1 -0
- tenxgraph_api/src/app/routers/checkpointer/router.py +482 -0
- tenxgraph_api/src/app/routers/checkpointer/schemas/__init__.py +1 -0
- tenxgraph_api/src/app/routers/checkpointer/schemas/checkpointer_schemas.py +121 -0
- tenxgraph_api/src/app/routers/checkpointer/services/__init__.py +1 -0
- tenxgraph_api/src/app/routers/checkpointer/services/checkpointer_service.py +300 -0
- tenxgraph_api/src/app/routers/evals/__init__.py +6 -0
- tenxgraph_api/src/app/routers/evals/router.py +38 -0
- tenxgraph_api/src/app/routers/evals/services/__init__.py +1 -0
- tenxgraph_api/src/app/routers/evals/services/eval_report_service.py +354 -0
- tenxgraph_api/src/app/routers/graph/__init__.py +6 -0
- tenxgraph_api/src/app/routers/graph/realtime_guard.py +161 -0
- tenxgraph_api/src/app/routers/graph/router.py +821 -0
- tenxgraph_api/src/app/routers/graph/schemas/__init__.py +1 -0
- tenxgraph_api/src/app/routers/graph/schemas/graph_schemas.py +386 -0
- tenxgraph_api/src/app/routers/graph/services/__init__.py +1 -0
- tenxgraph_api/src/app/routers/graph/services/graph_service.py +1219 -0
- tenxgraph_api/src/app/routers/graph/services/multimodal_preprocessor.py +164 -0
- tenxgraph_api/src/app/routers/media/__init__.py +506 -0
- tenxgraph_api/src/app/routers/media/router.py +242 -0
- tenxgraph_api/src/app/routers/media/schemas.py +39 -0
- tenxgraph_api/src/app/routers/ping/__init__.py +4 -0
- tenxgraph_api/src/app/routers/ping/router.py +28 -0
- tenxgraph_api/src/app/routers/setup_router.py +60 -0
- tenxgraph_api/src/app/routers/store/__init__.py +4 -0
- tenxgraph_api/src/app/routers/store/router.py +200 -0
- tenxgraph_api/src/app/routers/store/schemas/__init__.py +28 -0
- tenxgraph_api/src/app/routers/store/schemas/store_schemas.py +200 -0
- tenxgraph_api/src/app/routers/store/services/__init__.py +4 -0
- tenxgraph_api/src/app/routers/store/services/store_service.py +203 -0
- tenxgraph_api/src/app/tasks/__init__.py +0 -0
- tenxgraph_api/src/app/tasks/user_tasks.py +38 -0
- tenxgraph_api/src/app/utils/__init__.py +12 -0
- tenxgraph_api/src/app/utils/callable_helper.py +26 -0
- tenxgraph_api/src/app/utils/media/__init__.py +15 -0
- tenxgraph_api/src/app/utils/media/extractor.py +172 -0
- tenxgraph_api/src/app/utils/media/pipeline.py +83 -0
- tenxgraph_api/src/app/utils/parse_output.py +17 -0
- tenxgraph_api/src/app/utils/response_helper.py +129 -0
- tenxgraph_api/src/app/utils/schemas/__init__.py +11 -0
- tenxgraph_api/src/app/utils/schemas/output_schemas.py +28 -0
- tenxgraph_api/src/app/utils/schemas/user_schemas.py +13 -0
- tenxgraph_api/src/app/utils/snowflake_id_generator.py +90 -0
- tenxgraph_api/src/app/utils/swagger_helper.py +160 -0
- tenxgraph_api/src/app/utils/telemetry_store.py +123 -0
- tenxgraph_api/src/app/utils/thread_name_generator.py +304 -0
- 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
|