Chapter 1: 物件模型 (ObjectModel)

歡迎來到 open-notebook 的教學!在這個系列中,我們將一步步探索構成這個專案的核心概念。這是我們的第一站,我們將深入了解「物件模型 (ObjectModel)」。

為什麼我們需要物件模型?

想像一下,你正在開發一個數位筆記本應用程式,例如 open-notebook。在這個應用程式中,你會需要處理許多不同種類的「東西」:

每一種「東西」都有它自己的特性(例如,筆記本有「名稱」和「描述」,筆記有「標題」和「內容」),也都需要一些共通的操作,像是:

如果沒有一個統一的設計藍圖,我們就必須為每種「東西」分別撰寫這些儲存、讀取、刪除的程式碼。這樣不僅工作量龐大、容易出錯,未來要新增或修改功能也會非常困難。

這就是「物件模型 (ObjectModel)」派上用場的地方!它提供了一個基礎框架,讓所有這些不同的資料物件都能遵循一套共同的標準。

什麼是物件模型 (ObjectModel)?

物件模型 (ObjectModel) 是 open-notebook 系統中核心資料結構的基礎藍圖。它定義了所有資料物件(如筆記本、來源、筆記)都必須擁有的共同屬性共通行為

您可以把它想像成一個製作不同玩具的通用模具

這樣一來,無論是什麼類型的資料物件,它們都共享一套標準化的接口和底層邏輯,使得管理和操作這些物件變得更加簡單和一致。

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

程式碼解釋:

  1. class Notebook(ObjectModel)::這行宣告了 Notebook 類別,並表示它繼承自 ObjectModel。這意味著 Notebook 自動擁有了 ObjectModel 中定義的所有共同屬性(如 id, created, updated)和共通方法(如 save(), get(), delete())。
  2. table_name: ClassVar[str] = "notebook":這是一個類別變數,它告訴 ObjectModel 這個 Notebook 類別的資料應該儲存在資料庫中名為 notebook 的表格裡。
  3. name: strdescription: strarchived: Optional[bool] = False:這些是 Notebook 類別特有的屬性。ObjectModel 本身並不知道這些屬性,它們是 Notebook 作為一個具體「玩具」所獨有的「零件」。Optional[bool] 表示 archived 屬性可以是布林值,也可以是 None(未設定)。

透過這種方式,我們為「筆記本」定義了一個清晰的結構,同時也利用了 ObjectModel 提供的所有標準功能。

使用物件模型:基本操作

現在我們已經定義了 Notebook 模型,讓我們看看如何使用 ObjectModel 提供的共通方法來操作筆記本物件。

1. 建立一個新的筆記本物件

要建立一個新的筆記本,我們只需像平常建立 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}")

程式碼解釋:

2. 儲存筆記本物件

要將這個新的筆記本儲存到資料庫,我們只需呼叫它從 ObjectModel 繼承來的 save() 方法:

# 呼叫 save() 方法將筆記本儲存到資料庫
my_notebook.save()

print(f"筆記本已儲存!")
print(f"ID (儲存後):{my_notebook.id}")
print(f"建立時間:{my_notebook.created}")

程式碼解釋:

3. 讀取 (獲取) 筆記本物件

如果我們知道了某個筆記本的 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 可以載入。")

程式碼解釋:

4. 刪除筆記本物件

同樣地,我們也可以使用繼承來的 delete() 方法來刪除一個筆記本:

# 假設 loaded_notebook 是我們想要刪除的筆記本物件
# loaded_notebook.delete() 
# print(f"筆記本 {loaded_notebook.id} 已被刪除。")

程式碼解釋:

深入探索:ObjectModel 的內部機制

我們已經看到了如何使用 ObjectModel。現在,讓我們稍微深入了解一下它的內部是如何運作的。這有助於我們更好地理解它的魔法。

save() 方法的執行流程(概覽)

當您呼叫 my_notebook.save() 時,大致會發生以下事情:

  1. 呼叫 save() 您的程式碼呼叫 Notebook 物件的 save() 方法。由於 Notebook 繼承自 ObjectModel,實際上是執行了 ObjectModel 中定義的 save() 方法。
  2. 資料準備與驗證: ObjectModel 會準備要儲存的資料(例如,將物件的屬性轉換成字典格式)。它也可能使用 Pydantic 的功能來驗證資料是否符合 Notebook 類別的定義(例如,name 是不是字串)。
  3. 設定時間戳記: ObjectModel 會自動設定或更新 updated 時間戳記。如果是新物件(即 idNone),它也會設定 created 時間戳記。
  4. 判斷是新增還是更新:
    • 如果物件的 idNoneObjectModel 知道這是一個全新的物件,需要「建立」(create) 一條新的資料庫紀錄。
    • 如果物件已經有 id,則表示這是一個已存在的物件,需要「更新」(update) 現有的資料庫紀錄。
  5. 與資料庫互動: ObjectModel 會呼叫資料庫儲存庫 (Database Repository) 提供的函式(例如 repo_createrepo_update),並傳入表格名稱 (Notebook.table_name,即 "notebook") 和準備好的資料。
  6. 更新物件狀態: 資料庫操作完成後,會回傳儲存後的資料(可能包含新產生的 id 或更新後的時間戳記)。ObjectModel 會用這些回傳的資料來更新當前物件(例如 my_notebook)的屬性。

以下是一個簡化的序列圖,展示了 save() 的過程:

sequenceDiagram participant UserCode as 使用者程式碼 participant NotebookObject as 筆記本物件 (Notebook) participant ObjectModelLogic as ObjectModel 邏輯 participant DatabaseRepo as 資料庫儲存庫 UserCode->>NotebookObject: my_notebook.save() NotebookObject->>ObjectModelLogic: 執行 save() 方法 ObjectModelLogic->>ObjectModelLogic: 準備資料 (如名稱、描述) ObjectModelLogic->>ObjectModelLogic: 設定時間戳記 (created, updated) alt 如果是新物件 (id 為 None) ObjectModelLogic->>DatabaseRepo: repo_create("notebook", 資料) DatabaseRepo-->>ObjectModelLogic: 回傳已儲存資料 (包含新 ID) else 更新現有物件 ObjectModelLogic->>DatabaseRepo: repo_update(id, 資料) DatabaseRepo-->>ObjectModelLogic: 回傳已更新資料 end ObjectModelLogic->>NotebookObject: 更新物件屬性 (如 id, updated) NotebookObject-->>UserCode: 儲存完成

ObjectModel 的核心程式碼片段

讓我們看看 open_notebook/domain/base.pyObjectModel 類別的一些關鍵部分(已簡化):

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 中還有其他有用的共通方法,如 delete() (刪除物件)、get_all() (獲取某類型所有物件) 和 relate() (建立物件之間的關係)。有些物件可能還需要被「嵌入」(embedding) 以便進行語意搜尋,ObjectModel 也提供了 needs_embedding()get_embedding_content() 方法來支持這種需求。

總結

在本章中,我們認識了 ObjectModel,它是 open-notebook 專案中所有核心資料物件的基礎藍圖。

ObjectModel 為我們提供了一個堅實的基礎來定義和操作資料。但是,僅僅能夠操作單一的資料物件是不夠的。在 open-notebook 中,我們還需要管理 AI 模型,例如用於聊天、文本轉換或語音識別的模型。這些模型本身也需要被管理和配置。

在下一章,我們將探討 模型管理器 (ModelManager),了解它是如何幫助我們管理和存取系統中各種 AI 模型的。