From e6cef9963bd8f9492597cd54eb13df3512f896ad Mon Sep 17 00:00:00 2001 From: huo-ju Date: Tue, 19 May 2026 01:44:58 +0000 Subject: [PATCH] Expose cardbox API and restore storage helpers Update the public cardbox package entrypoint to export the core runtime, strategy, adapter, and external object APIs consistently. Restore PostgreSQL card box upsert behavior and hydrate_card_box support, and validate JsonContent against any JSON value rather than only dict/list roots. --- cardbox/__init__.py | 106 ++++++++++++++++++++---------- cardbox/adapters/async_storage.py | 49 +++++++------- cardbox/structures.py | 18 ++++- 3 files changed, 115 insertions(+), 58 deletions(-) diff --git a/cardbox/__init__.py b/cardbox/__init__.py index ad5f45a..14ed9c8 100644 --- a/cardbox/__init__.py +++ b/cardbox/__init__.py @@ -1,45 +1,85 @@ -# Expose main data structures from the structures module -from cardbox.structures import ( - Card, - CardBox, - TextContent, - JsonContent, - FieldSchema, - ToolContent, - ToolCallContent, - ToolResultContent, - TextFileContent, - FileContent, - PreviewImage, - FieldsSchemaContent, - ImageFileContent, - PdfFileContent, - VideoFileContent, - AudioFileContent, - MultiFileContent, -) -from cardbox.services import CardStore, CardHistory, CardBoxHistory -from cardbox.engine import ContextEngine -from cardbox.strategies import ( - ExtractCodeStrategy, - PdfToTextStrategy, - Input, - InlineTextFileContentStrategy, - PdfToTextStrategyInput, -) from cardbox.a2aclient import A2AHelperClient from cardbox.adapters import ( AsyncPostgresStorageAdapter, FileStorageAdapter, - LocalFileStorageAdapter, InMemoryMappingAdapter, + LocalFileStorageAdapter, ) +from cardbox.config import configure +from cardbox.engine import ContextEngine from cardbox.external import ( + ExternalObjectError, + ExternalObjectNotFoundError, ExternalObjectPointer, ExternalObjectReader, - ExternalObjectError, InvalidExternalURIError, - ExternalObjectNotFoundError, S3ObjectReader, ) -from cardbox.config import configure +from cardbox.services import CardBoxHistory, CardHistory, CardStore +from cardbox.strategies import ( + ExtractCodeStrategy, + InlineTextFileContentStrategy, + Input, + PdfToTextStrategy, + PdfToTextStrategyInput, +) +from cardbox.structures import ( + AudioFileContent, + Card, + CardBox, + FieldSchema, + FieldsSchemaContent, + FileContent, + ImageFileContent, + JsonContent, + MultiFileContent, + PdfFileContent, + PreviewImage, + TextContent, + TextFileContent, + ToolCallContent, + ToolContent, + ToolResultContent, + VideoFileContent, +) + +__all__ = [ + "A2AHelperClient", + "AsyncPostgresStorageAdapter", + "AudioFileContent", + "Card", + "CardBox", + "CardBoxHistory", + "CardHistory", + "CardStore", + "ContextEngine", + "ExternalObjectError", + "ExternalObjectNotFoundError", + "ExternalObjectPointer", + "ExternalObjectReader", + "ExtractCodeStrategy", + "FieldSchema", + "FieldsSchemaContent", + "FileContent", + "FileStorageAdapter", + "ImageFileContent", + "InMemoryMappingAdapter", + "InlineTextFileContentStrategy", + "Input", + "InvalidExternalURIError", + "JsonContent", + "LocalFileStorageAdapter", + "MultiFileContent", + "PdfFileContent", + "PdfToTextStrategy", + "PdfToTextStrategyInput", + "PreviewImage", + "S3ObjectReader", + "TextContent", + "TextFileContent", + "ToolCallContent", + "ToolContent", + "ToolResultContent", + "VideoFileContent", + "configure", +] diff --git a/cardbox/adapters/async_storage.py b/cardbox/adapters/async_storage.py index ec6392e..c760772 100644 --- a/cardbox/adapters/async_storage.py +++ b/cardbox/adapters/async_storage.py @@ -348,28 +348,20 @@ async def save_card_box(self, box: "CardBox", tenant_id: str, *, conn: Any = Non async with self._connection(conn) as db_conn: if box.box_id is None: box.box_id = str(uuid6.uuid7()) - result = await db_conn.execute( - """ - INSERT INTO card_boxes (box_id, tenant_id, card_ids, parent_ids, updated_at) - VALUES (%s, %s, %s, %s, current_timestamp) - RETURNING box_id - """, - (box.box_id, tenant_id, card_ids_json, parent_ids_json), - ) - row = await result.fetchone() - else: - result = await db_conn.execute( - """ - UPDATE card_boxes - SET card_ids = %s, parent_ids = %s, updated_at = current_timestamp - WHERE box_id = %s AND tenant_id = %s - RETURNING box_id - """, - (card_ids_json, parent_ids_json, box.box_id, tenant_id), - ) - row = await result.fetchone() - if not row: - raise ValueError(f"card_box with id {box.box_id} not found for tenant {tenant_id}") + result = await db_conn.execute( + """ + INSERT INTO card_boxes (box_id, tenant_id, card_ids, parent_ids, updated_at) + VALUES (%s, %s, %s, %s, current_timestamp) + ON CONFLICT (box_id) DO UPDATE SET + tenant_id = EXCLUDED.tenant_id, + card_ids = EXCLUDED.card_ids, + parent_ids = EXCLUDED.parent_ids, + updated_at = current_timestamp + RETURNING box_id + """, + (box.box_id, tenant_id, card_ids_json, parent_ids_json), + ) + row = await result.fetchone() box_id = str(row.get("box_id")) box.box_id = box_id @@ -419,6 +411,19 @@ async def load_card_box(self, box_id: str, tenant_id: str, *, conn: Any = None) box.card_ids = card_ids return box + async def hydrate_card_box(self, box_id: str, tenant_id: str, *, conn: Any = None) -> Optional[tuple["CardBox", List["Card"]]]: + box = await self.load_card_box(box_id, tenant_id, conn=conn) + if box is None: + return None + + cards: List["Card"] = [] + for card_id in box.card_ids: + card = await self.get_card(card_id, tenant_id, conn=conn) + if card is None: + return None + cards.append(card) + return box, cards + async def add_sync_task(self, card_id: str, tenant_id: str, operation: str, *, conn: Any = None) -> None: async with self._connection(conn) as db_conn: await db_conn.execute( diff --git a/cardbox/structures.py b/cardbox/structures.py index 03a8521..46bed36 100644 --- a/cardbox/structures.py +++ b/cardbox/structures.py @@ -201,16 +201,28 @@ class TextContent(Content): text: str +def _is_valid_json_value(value: Any) -> bool: + if value is None or isinstance(value, (str, bool, int)): + return True + if isinstance(value, float): + return value == value and value not in (float("inf"), -float("inf")) + if isinstance(value, list): + return all(_is_valid_json_value(item) for item in value) + if isinstance(value, dict): + return all(isinstance(key, str) and _is_valid_json_value(item) for key, item in value.items()) + return False + + class JsonContent(Content): """Represents structured JSON content (dict/list).""" - data: Union[Dict[str, Any], List[Any]] + data: Any @field_validator("data") @classmethod def _validate_json_data(cls, value: Any) -> Any: - if not isinstance(value, (dict, list)): - raise InvalidCardContentError("JsonContent.data must be a dict or list.") + if not _is_valid_json_value(value): + raise InvalidCardContentError("JsonContent.data must be a valid JSON value.") return value