歡迎來到 open-notebook
的教學!在這個系列中,我們將一步步探索構成這個專案的核心概念。這是我們的第一站,我們將深入了解「物件模型 (ObjectModel)」。
想像一下,你正在開發一個數位筆記本應用程式,例如 open-notebook
。在這個應用程式中,你會需要處理許多不同種類的「東西」:
每一種「東西」都有它自己的特性(例如,筆記本有「名稱」和「描述」,筆記有「標題」和「內容」),也都需要一些共通的操作,像是:
如果沒有一個統一的設計藍圖,我們就必須為每種「東西」分別撰寫這些儲存、讀取、刪除的程式碼。這樣不僅工作量龐大、容易出錯,未來要新增或修改功能也會非常困難。
這就是「物件模型 (ObjectModel)」派上用場的地方!它提供了一個基礎框架,讓所有這些不同的資料物件都能遵循一套共同的標準。
物件模型 (ObjectModel) 是 open-notebook
系統中核心資料結構的基礎藍圖。它定義了所有資料物件(如筆記本、來源、筆記)都必須擁有的共同屬性與共通行為。
您可以把它想像成一個製作不同玩具的通用模具:
id
、created
創建時間、updated
更新時間)和基本操作方式(例如:save
儲存、get
讀取、delete
刪除)。Notebook
筆記本模型、Note
筆記模型): 則是在這個通用模具的基礎上,添加自己獨特的零件(例如,筆記本模型有 name
名稱、description
描述;筆記模型有 title
標題、content
內容)。這樣一來,無論是什麼類型的資料物件,它們都共享一套標準化的接口和底層邏輯,使得管理和操作這些物件變得更加簡單和一致。
在 open-notebook
中,ObjectModel
這個基礎類別位於 open_notebook/domain/base.py
檔案中。所有代表核心資料(如筆記本、筆記、來源等)的類別,都會「繼承」自 ObjectModel
。
讓我們以「筆記本 (Notebook)」為例,看看它是如何利用 ObjectModel
的。一個 Notebook
物件在我們的應用程式中代表一本數位筆記本。
以下是 Notebook
類別定義的簡化版本(原始碼位於 open_notebook/domain/notebook.py
):
# 從 open_notebook.domain.base 模組匯入 ObjectModel 基礎類別
from open_notebook.domain.base import ObjectModel
# 從 typing 模組匯入 ClassVar (用於類別變數) 和 Optional (用於可選屬性)
from typing import ClassVar, Optional
class Notebook(ObjectModel): # Notebook 繼承自 ObjectModel
# table_name 是 ClassVar,代表這個類別對應到資料庫中的 'notebook' 表格
table_name: ClassVar[str] = "notebook"
# 以下是 Notebook 特有的屬性
name: str # 筆記本的名稱,類型為字串
description: str # 筆記本的描述,類型為字串
archived: Optional[bool] = False # 筆記本是否已封存,類型為可選的布林值,預設為 False
程式碼解釋:
class Notebook(ObjectModel):
:這行宣告了 Notebook
類別,並表示它繼承自 ObjectModel
。這意味著 Notebook
自動擁有了 ObjectModel
中定義的所有共同屬性(如 id
, created
, updated
)和共通方法(如 save()
, get()
, delete()
)。table_name: ClassVar[str] = "notebook"
:這是一個類別變數,它告訴 ObjectModel
這個 Notebook
類別的資料應該儲存在資料庫中名為 notebook
的表格裡。name: str
、description: str
、archived: Optional[bool] = False
:這些是 Notebook
類別特有的屬性。ObjectModel
本身並不知道這些屬性,它們是 Notebook
作為一個具體「玩具」所獨有的「零件」。Optional[bool]
表示 archived
屬性可以是布林值,也可以是 None
(未設定)。透過這種方式,我們為「筆記本」定義了一個清晰的結構,同時也利用了 ObjectModel
提供的所有標準功能。
現在我們已經定義了 Notebook
模型,讓我們看看如何使用 ObjectModel
提供的共通方法來操作筆記本物件。
要建立一個新的筆記本,我們只需像平常建立 Python 物件一樣,傳入它特有的屬性值:
# 建立一個 Notebook 類別的實例 (物件)
my_notebook = Notebook(name="我的學習筆記", description="關於 open-notebook 的學習心得。")
# 此時,my_notebook 物件已經在記憶體中,但尚未儲存到資料庫
# 它會有 name 和 description 屬性,但 id, created, updated 此時通常是 None
print(f"新筆記本名稱:{my_notebook.name}")
print(f"ID (儲存前):{my_notebook.id}")
程式碼解釋:
Notebook
物件,並賦予它 name
和 description
。id
通常是 None
,因為它還沒有被儲存到資料庫中,所以資料庫還沒有為它分配一個唯一的 ID。要將這個新的筆記本儲存到資料庫,我們只需呼叫它從 ObjectModel
繼承來的 save()
方法:
# 呼叫 save() 方法將筆記本儲存到資料庫
my_notebook.save()
print(f"筆記本已儲存!")
print(f"ID (儲存後):{my_notebook.id}")
print(f"建立時間:{my_notebook.created}")
程式碼解釋:
my_notebook
物件本身,包含了 name
和 description
等資料。save()
方法首先會驗證資料的有效性。created
(如果是第一次儲存)和 updated
時間戳記。repo_create
),將資料寫入名為 notebook
的資料庫表格中。id
。save()
方法會將這個 id
和其他從資料庫回傳的資訊(如精確的 created
時間)更新回 my_notebook
物件。my_notebook
物件現在有了 id
、created
和 updated
屬性,並且它的資料已經持久化儲存在資料庫中。如果我們知道了某個筆記本的 id
,就可以使用 Notebook.get()
這個類別方法從資料庫中將它讀取出來:
# 假設我們從 my_notebook.save() 後得到了 notebook_id
notebook_id_to_load = my_notebook.id
if notebook_id_to_load: # 確保 ID 存在
# 使用 Notebook.get() 方法並傳入 ID 來讀取筆記本
loaded_notebook = Notebook.get(id=notebook_id_to_load)
print(f"成功載入筆記本!")
print(f"載入的筆記本名稱:{loaded_notebook.name}")
print(f"載入的筆記本 ID:{loaded_notebook.id}")
else:
print("沒有有效的 ID 可以載入。")
程式碼解釋:
id
。Notebook.get()
方法會使用這個 id
。repo_query
),在 notebook
表格中查詢具有該 id
的紀錄。Notebook
物件。Notebook
物件,其屬性填充了從資料庫讀取的資料。如果找不到對應 id
的筆記本,通常會引發一個錯誤(例如 NotFoundError
)。同樣地,我們也可以使用繼承來的 delete()
方法來刪除一個筆記本:
# 假設 loaded_notebook 是我們想要刪除的筆記本物件
# loaded_notebook.delete()
# print(f"筆記本 {loaded_notebook.id} 已被刪除。")
程式碼解釋:
delete()
方法。id
,指示資料庫儲存庫 (Database Repository) 從相應的表格中刪除該筆紀錄。我們已經看到了如何使用 ObjectModel
。現在,讓我們稍微深入了解一下它的內部是如何運作的。這有助於我們更好地理解它的魔法。
save()
方法的執行流程(概覽)當您呼叫 my_notebook.save()
時,大致會發生以下事情:
save()
: 您的程式碼呼叫 Notebook
物件的 save()
方法。由於 Notebook
繼承自 ObjectModel
,實際上是執行了 ObjectModel
中定義的 save()
方法。ObjectModel
會準備要儲存的資料(例如,將物件的屬性轉換成字典格式)。它也可能使用 Pydantic 的功能來驗證資料是否符合 Notebook
類別的定義(例如,name
是不是字串)。ObjectModel
會自動設定或更新 updated
時間戳記。如果是新物件(即 id
為 None
),它也會設定 created
時間戳記。id
是 None
,ObjectModel
知道這是一個全新的物件,需要「建立」(create) 一條新的資料庫紀錄。id
,則表示這是一個已存在的物件,需要「更新」(update) 現有的資料庫紀錄。ObjectModel
會呼叫資料庫儲存庫 (Database Repository) 提供的函式(例如 repo_create
或 repo_update
),並傳入表格名稱 (Notebook.table_name
,即 "notebook"
) 和準備好的資料。id
或更新後的時間戳記)。ObjectModel
會用這些回傳的資料來更新當前物件(例如 my_notebook
)的屬性。以下是一個簡化的序列圖,展示了 save()
的過程:
ObjectModel
的核心程式碼片段讓我們看看 open_notebook/domain/base.py
中 ObjectModel
類別的一些關鍵部分(已簡化):
from datetime import datetime
from typing import ClassVar, Optional, Type, TypeVar
from pydantic import BaseModel # Pydantic 用於資料驗證與模型定義
# 匯入資料庫操作函式
from open_notebook.database.repository import repo_create, repo_update, repo_query
T = TypeVar("T", bound="ObjectModel") # 用於類型提示
class ObjectModel(BaseModel): # ObjectModel 繼承自 Pydantic 的 BaseModel
id: Optional[str] = None # 物件的唯一 ID,可選
table_name: ClassVar[str] = "" # 對應的資料庫表格名稱,由子類別定義
created: Optional[datetime] = None # 物件的建立時間,可選
updated: Optional[datetime] = None # 物件的最後更新時間,可選
# ... (其他方法,例如 get_all, delete, relate) ...
def save(self) -> None:
try:
# 1. 驗證資料 (使用 Pydantic 的功能)
self.model_validate(self.model_dump(), strict=True)
# 2. 準備要儲存的資料
data = self._prepare_save_data() # 一個輔助方法,將物件轉為字典
data["updated"] = datetime.now() # 設定更新時間
if self.id is None: # 3. 如果沒有 ID,代表是新的物件
data["created"] = datetime.now() # 設定建立時間
# 4. 呼叫資料庫函式來建立紀錄
repo_result = repo_create(self.__class__.table_name, data)
else: # 如果有 ID,代表是更新現有物件
# 4. 呼叫資料庫函式來更新紀錄
repo_result = repo_update(self.id, data)
# 5. 更新物件自身屬性 (例如,從 repo_result 中獲取 ID)
if repo_result: # 確保 repo_result 不是空的
for key, value in repo_result[0].items():
if hasattr(self, key):
setattr(self, key, value)
except Exception as e:
# 錯誤處理...
raise DatabaseOperationError(e) # 拋出一個自訂的資料庫操作錯誤
@classmethod
def get(cls: Type[T], id: str) -> T:
if not id:
raise InvalidInputError("ID 不能為空")
try:
# 假設 ID 格式為 "table_name:unique_part" 或直接是 SurrealDB 的 Record ID
table_name_from_id = id.split(":")[0] if ":" in id else cls.table_name
# 找到正確的子類別 (target_class) 來實例化,這部分邏輯較複雜
# 簡化:假設 cls 就是我們要的 target_class
target_class: Type[T] = cls
if cls.table_name and cls.table_name != table_name_from_id:
# 如果從父類別呼叫,或 ID 中的 table_name 與當前類別不符
# 則需要動態查找正確的子類別
found_subclass = cls._get_class_by_table_name(table_name_from_id)
if not found_subclass:
raise InvalidInputError(f"找不到表格 {table_name_from_id} 對應的類別")
target_class = cast(Type[T], found_subclass)
# 呼叫資料庫函式來查詢紀錄
result = repo_query(f"SELECT * FROM {id}") # SurrealDB可以直接用 ID 查詢
if result:
# 用查詢結果建立物件 (Pydantic 會自動處理欄位對應)
return target_class(**result[0])
else:
raise NotFoundError(f"ID 為 {id} 的物件未找到")
except Exception as e:
# 錯誤處理...
raise NotFoundError(f"獲取 ID 為 {id} 的物件失敗 - {str(e)}")
def _prepare_save_data(self) -> Dict[str, Any]:
# 此輔助方法將物件模型轉換為字典,通常會排除值為 None 的欄位
data = self.model_dump()
return {key: value for key, value in data.items() if value is not None}
@classmethod
def _get_class_by_table_name(cls, table_name: str) -> Optional[Type["ObjectModel"]]:
"""根據 table_name 尋找對應的 ObjectModel 子類別。"""
# 此方法會遍歷所有 ObjectModel 的子類別
# 找到 table_name 屬性相符的那個子類別並回傳
# 這樣 get() 方法才能正確地將資料庫記錄轉換回正確的物件類型
# (此處省略詳細實作)
for subclass in cls.__subclasses__(): # 取得所有直接子類別
if hasattr(subclass, "table_name") and subclass.table_name == table_name:
return subclass
# 遞迴查找子類別的子類別 (更完整的實作會這樣做)
found_in_sub_subclass = subclass._get_class_by_table_name(table_name)
if found_in_sub_subclass:
return found_in_sub_subclass
return None
程式碼解釋:
ObjectModel(BaseModel)
: 我們的 ObjectModel
繼承自 Pydantic 函式庫的 BaseModel
。Pydantic 幫助我們輕鬆定義資料的結構(有哪些欄位、什麼類型),並提供自動的資料驗證。id
, table_name
, created
, updated
: 這些是所有物件共有的核心屬性,如前所述。table_name
由每個繼承 ObjectModel
的具體類別(如 Notebook
)自己定義。save()
方法:self.model_validate()
(Pydantic 功能) 來確保資料符合模型定義。_prepare_save_data()
是一個輔助方法,通常用來將物件的資料轉換成適合存入資料庫的字典格式,並可能過濾掉一些不必要的欄位。self.__class__.table_name
用來獲取當前物件所屬類別的 table_name
(例如,對於 Notebook
物件,這就是 "notebook"
)。repo_create
和 repo_update
是實際與資料庫溝通的函式,它們屬於資料庫儲存庫 (Database Repository) 的一部分。id
)。get()
類別方法:id
作為參數。_get_class_by_table_name()
是一個重要的內部輔助方法。當我們呼叫例如 ObjectModel.get("notebook:xyz")
時,get
方法需要知道這個 "notebook:xyz"
應該被轉換成一個 Notebook
物件,而不是其他類型的物件(如 Note
)。此方法幫助找到 table_name
匹配的那個子類別。repo_query
也是資料庫儲存庫 (Database Repository) 的一部分,用於執行資料庫查詢。target_class(**result[0])
:這裡 Pydantic 再次發揮作用。它會將從資料庫查詢到的結果(一個字典 result[0]
)自動解包並填充到 target_class
(例如 Notebook
)的對應屬性中,從而建立物件。ObjectModel
中還有其他有用的共通方法,如 delete()
(刪除物件)、get_all()
(獲取某類型所有物件) 和 relate()
(建立物件之間的關係)。有些物件可能還需要被「嵌入」(embedding) 以便進行語意搜尋,ObjectModel
也提供了 needs_embedding()
和 get_embedding_content()
方法來支持這種需求。
在本章中,我們認識了 ObjectModel
,它是 open-notebook
專案中所有核心資料物件的基礎藍圖。
ObjectModel
解決了如何以一致且高效的方式管理不同類型資料(如筆記本、筆記)的問題。id
, created
, updated
)和共通的行為(如 save()
, get()
, delete()
)。Notebook
)透過繼承 ObjectModel
來獲得這些標準功能,並可以添加自己特有的屬性。save()
和 get()
等方法的內部運作原理,以及它們如何與資料庫儲存庫 (Database Repository) 互動。ObjectModel
為我們提供了一個堅實的基礎來定義和操作資料。但是,僅僅能夠操作單一的資料物件是不夠的。在 open-notebook
中,我們還需要管理 AI 模型,例如用於聊天、文本轉換或語音識別的模型。這些模型本身也需要被管理和配置。
在下一章,我們將探討 模型管理器 (ModelManager),了解它是如何幫助我們管理和存取系統中各種 AI 模型的。