建議 model:opus/effort:medium — 定 port 契約是整案的骨架,簽名定錯後面四棒都要返工。
本卡屬 FR-107(母卡 CM-1843)第 .2 棒(子需求卡 CM-1845),子任務 T-2.1:把套件對外的三個插座(port)定義補齊,舊的 Drive 插座標成不再使用。
port(插座) 是套件開出來的抽象介面:套件只說「我需要有人幫我做這件事」,實際怎麼做由主專案插上去的 adapter(轉接頭) 決定。這樣分類引擎就不必知道檔案存在哪、任務怎麼查、框架版本怎麼算。
T-1.3 已經定了第一個插座 IEvidenceStorage(存檔/取檔/刪檔)。這張卡補完剩下的:
IEvidenceStorage.stage_to_dir 的簽名——「把整批檔拉到一個本機工作目錄」,這是 D2 甲案的核心(主專案先把檔拉好,容器只讀目錄)。ITaskEvidenceSink——「找任務」與「把檔掛進任務」。IClassificationContext——「這個輪次存在嗎」與「這個專案該用哪幾個框架版本」。同時把舊的 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,讓舊宿主不炸
@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 解析
Protocol(照套件既有 ports.py 的風格,不要改成 ABC)。plugin/contract.py 的 EvidenceClassificationAdapters 加三個欄位,預設值一律 None。理由:舊的宿主(還沒寫新轉接頭的)註冊時不給這三個也要能起得來,不能因為多了欄位就整個系統起不來。AttributeError: 'NoneType' object has no attribute ...。在套件 service 取用 adapter 之前先檢查,回「宿主未註冊 XXX adapter」這種說得出問題的訊息。這條是驗收項。IEvidenceSource(:73)與 EvidenceDriveOps 都在 docstring 第一行加一句「已由 IEvidenceStorage 取代(FR-107),僅供 legacy Drive route 使用,下一版移除」。不要在檔案裡寫「第 N 棒改的」「等發版後刪」這類施工日誌——那是 commit message 的事。ITaskEvidenceSink 之前先 grep 套件裡有沒有已經存在的「掛證據」能力(按行為 grep job_evidence/evidence 不是 grep 方法名)。設計已核對過沒有,但慣例照走。