建議 model:opus/effort:medium — 定 port 契約是整案的骨架,簽名定錯後面四棒都要返工。

本卡屬 FR-107(母卡 CM-1843)第 .2 棒(子需求卡 CM-1845),子任務 T-2.1:把套件對外的三個插座(port)定義補齊,舊的 Drive 插座標成不再使用。

這張卡做什麼(白話)

port(插座) 是套件開出來的抽象介面:套件只說「我需要有人幫我做這件事」,實際怎麼做由主專案插上去的 adapter(轉接頭) 決定。這樣分類引擎就不必知道檔案存在哪、任務怎麼查、框架版本怎麼算。

T-1.3 已經定了第一個插座 IEvidenceStorage(存檔/取檔/刪檔)。這張卡補完剩下的:

同時把舊的 Google Drive 插座(IEvidenceSource)與 Drive 操作類別標成 deprecated(不再使用,但不刪——舊的 route 還在用,D3 決定並存一版)。

這張卡只定義簽名、不寫實作。主專案的轉接頭實作在 T-2.2。

動哪些檔

套件(走 path dependency 開發)
~/Projects/Jedicogy/module/jedi-python-package/jedi-evidence-classification/
  .../domain/ports.py
      :45 IProjectDirectory      ← 不動
      :56 IProjectRoleGuard      ← T-1.3 已加 is_project_participant
      :73 IEvidenceSource        ← 🔴 標 deprecated(docstring,不刪)
      :88 IControlCatalog        ← 不動
      :98 IDocumentConverter     ← 不動
      ← 加 StagedFile / AttachResult dataclass
      ← 加 ITaskEvidenceSink / IClassificationContext
      ← IEvidenceStorage 補 stage_to_dir 簽名(T-1.3 已定,這裡確認)
  .../infra/evidence_drive_ops.py  EvidenceDriveOps  ← 🔴 標 deprecated(不刪,舊 route 仍用)
  .../plugin/contract.py  EvidenceClassificationAdapters
      ← 加三個欄位,🔴 有預設 None,讓舊宿主不炸

插座簽名(design.md §6.3,逐字照抄)

@dataclass(frozen=True)
class StagedFile:
    file_id: int          # 內部 int(upload_files.id)
    file_uid: str         # 對外 uid
    original_name: str
    local_path: Path      # 已拉到 job 目錄的實體路徑
    content_hash: str | None

@dataclass(frozen=True)
class AttachResult:
    evidence_uid: str
    existed: bool         # True=冪等命中既有列,沒有新寫

class ITaskEvidenceSink(Protocol):
    """AO → 任務對照,與把檔掛進任務。主專案接 get_jobs_by_round_id 與 job_evidences。"""

    def resolve_jobs(self, round_id: int) -> dict[str, int]: ...
    # 回 {part_id: job_execution_id}
    # 同一 part_id 只會有一個任務(DEV 792 筆實查成立,首腦已核對)

    def attach(self, *, job_execution_id: int, file_id: int, file_uid: str,
               classification_run_id: int, actor_user_id: int,
               content_hash: str | None, description: str | None) -> AttachResult: ...
    # 寫 job_evidences(source="AI_CLASSIFIED")
    # 命中 (job_execution_id, file_id) 既有未刪列時回 existed=True

    def find_same_content(self, round_id: int, content_hash: str) -> list[tuple[int, str]]: ...
    # D9 提示:回 [(job_execution_id, evidence_uid)],不阻擋

class IClassificationContext(Protocol):
    """解析 AI 分類設定要用的框架版本鏈,與輪次存在性。全是宿主(OSCAL/稽核輪次)的疆界。"""

    def resolve_round(self, round_uid: str) -> tuple[int, int] | None: ...
    # 回 (round_id, project_id);不存在回 None

    def framework_version_chain(self, project_id: int) -> list[int]: ...
    # 依「專案指定 → living SSP → catalog」順序回 framework_version_id 清單(去重、有序)

# IEvidenceStorage 內(T-1.3 已定簽名,本卡確認在,實作 T-2.2)
    def stage_to_dir(self, tenant_id: int, file_uids: list[str], work_dir: Path) -> list[StagedFile]: ...
    # D2 甲案:把整批拉到本機工作目錄
    # 宿主一律走 upload_files_for_tenant 對應的 provider 解析

怎麼做(逐步)