狀態:P1(jedi-ai-bot)實跑驗證後定稿|建立日期:2026-08-29|對應卡:CM-1437 適用範圍:FR-069 P2–P5 的每一支套件抽取。照著做,遇到不一致以本文為準、並回頭更新本文。
| 日期 | 變更 | 對應 |
|---|---|---|
| 2026-08-29 | 初版——P1 jedi-ai-bot 實跑一遍後寫成,含 D6 插件契約、D7 harness/README、四道防線簽名 | CM-1437 |
| 2026-08-29 | P4 jedi-remote-agent 實跑後補:§4.2 migration 隨包機制定案(首例)、§1 盤點加「消費端建構點」與「signature 變更」兩項、§6.1 harness 三道前提、§5 大模組的 DI container 保留判準 | CM-1440 |
| 2026-08-30 | P3 jedi-log-forwarding 實跑後補:§3.4 logger 名三連坑(抽走 logging 相關模組必踩)、§1 盤點加「模組有沒有背景常駐元件」、§5 加「core/ 不可 import api/」、§6 加「跑 logging 的模組要驗『真的送到對面』」 | CM-1439 |
| 2026-08-30 | P5 jedi-license-runtime 實跑後補:§1 盤點加「共用 error code 歸屬」、§3.2 斷法表加「多語系文案」、§4 加 HANDLE_KEY 慣例、§4.2 加「seed 列與非 RLS 表」兩條、§6.2 加「平行 session 共用檔」的 staging 手法、§8 加「schema 對照 DEV 實況」 |
CM-1441 |
抽一支套件=七個階段、一條不可倒的順序:
① 盤點 → ② 建套件骨架 → ③ 搬檔+斷線 → ④ 定 register 簽名
→ ⑤ 主專案改接線 → ⑥ 本地驗證(path dependency) → ⑦ 發版+pin
⑥ 之前不要碰 ⑦,⑦ 之前不要 commit「主專案移除目錄」——順序倒了會出現 「committed 狀態沒源碼、又裝不到套件」的死結。
三個「等 user 明示才做」的動作:發版推 Nexus、push、收尾類文件。 其餘(commit、Notion 回寫子卡)做完就做,不用問。
盤點產出:一張「要搬哪些檔 / 要斷哪幾條線 / 每條線怎麼斷」的清單。斷法選擇見 §3.2。
在 monorepo 根目錄下 mkdir jedi-<name> 後建立:
jedi-<name>/
├── jedi_<name>/
│ ├── __init__.py # logger = logging.getLogger("jedi_<name>")
│ ├── api/ # ★ 插件模式:route 進套件(與舊 jedi-* 慣例不同)
│ ├── app/service/
│ ├── common/ # 套件自帶的小工具(如回應信封)
│ ├── domain/repository/ # port(ABC)放這
│ ├── infra/ # 有 DB 表才需要(ORM model / mapper / repo impl)
│ └── plugin.py # ★ register() / adapters / config,見 §4
├── harness/ # ★ D7:standalone 最小宿主
│ ├── dev_app.py
│ └── docker-compose.yml
├── tests/ # ★ D7:零外部相依,能離開主專案獨立跑
├── README.md # ★ D7:接入說明書,見 §6
├── pyproject.toml
├── poetry.toml # [virtualenvs] in-project = true
└── publish.sh # rm -rf dist/ && poetry publish --build -r nexus
pyproject 要點(照抄 jedi-ai-bot):
| 主專案的東西 | 斷法 |
|---|---|
| 基礎設施(Redis / 檔案系統 / 外部服務 client) | 套件定窄 ABC port(只放真的會用到的方法),主專案寫 adapter 注入 |
| 設定值(API key、host、開關) | 進 <Pkg>Config dataclass,由 consumer 傳入。套件永遠不讀 os.getenv、不 import config.config |
| 認證 / user context | 注入 decorator 與取值函式(見 §4 adapters) |
| 小工具(回應信封、常數字典) | 套件自帶一份最小複製(如 common/response.py、AUTH_PARAMS),不 import 主專案。只複製真的會用到的部分 |
| 多語系文案(信件模板、通知內容) | 套件自帶預設字串 + config 可整組覆寫。不可讀宿主的 config/translations/。字串逐字照搬不趁機潤稿——那是客戶看得到的東西 |
port 要窄:jedi-ai-bot 的 IChatHistoryStore 只有 get / set / delete 三個方法。 port 越窄,換後端越便宜、測試 mock 越省事。
P3 抽 log 轉發時連續踩到三個,症狀全是「功能其實在跑,但相關訊息全部消失」或 反過來「根本沒跑,而你也看不到任何錯誤」。三個都源自同一件事:模組搬進套件後, 它的 logger 名從 common.xxx 變成 jedi_xxx.common.xxx,而宿主的 logging 設定 只認得原本那幾棵樹。
| # | 坑 | 症狀 | 解法 |
|---|---|---|---|
| ① | 套件模組 logger 被 disable_existing_loggers 關掉 |
套件自己的維運訊息(啟用/失敗/重試)整個消失,連壞掉都不出聲 | config 開一個 ops_logger_name,宿主傳自己樹裡的名字 |
| ② | 稽核事件用套件 logger 發 | 事件掉出 system_logs,也不會被轉發 |
adapters 開一個 audit_logger_name,同上 |
| ③ | 宿主在掛載之後才跑 dictConfig |
它整批替換 handlers 清單,把套件掛上去的 handler 默默丟掉 | 套件端每輪自檢「handler 還在不在」,不在就重掛 |
第 ③ 個最陰險:dictConfig 是替換不是附加,而套件若只比「設定有沒有變」就會 判定「沒變、不用動」,於是永遠不會發現 handler 已經被拔掉——狀態查詢函式仍回 「運作中」。主專案必然踩到(jedi_issue 在 import 期呼叫 configure_logging(), 而模組註冊迴圈跑在 create_app() 之後)。
通則:套件若要在宿主的 logging 樹上掛東西或發訊息, logger 名一律做成可注入、預設值只是 fallback;且掛上去的東西要能自我檢查還在不在。
P1 已定案,P2–P5 直接照抄這個形狀:
register(app, adapters, config=None, schema_extensions=None, mount_api=True) -> PluginHandle四個插槽對應「不同產品接同一支套件時,API 可能差在哪」,差異由小到大:
| 插槽 | 解什麼差異 | 做法 |
|---|---|---|
1. config |
參數值差異(預設值 / 上限 / 開關) | dataclass <Pkg>Config,API 形狀不變 |
2. adapters |
行為差異(背後邏輯不同) | port 注入,即 D4 既有機制 |
3. schema_extensions |
欄位差異(多收 / 多吐欄位) | base schema 只認共通欄位+多給不炸;宿主註冊 request_context / response_enricher hook,擴充欄位經 context dict 傳遞 |
4. mount_api=False |
端點形狀差異的逃生門 | 不掛路由、只回 service,宿主自己寫薄 route |
四個插槽第一版就要開齊,即使該套件暫時用不到第 3 道——P2–P5 照抄同一簽名, 之後才不用回頭改五支套件的入口。
套件 ship 建表腳本,但絕不自己執行。
jedi-<name>/jedi_<name>/migrations/
├── 001-<模組>-tables.sql # 建表 + 索引 + COMMENT
└── 002-<模組>-rls.sql # RLS policy + GRANT(依賴宿主生態)
plugin.py 提供讀取入口,宿主自己決定何時套:
def iter_migrations() -> List[Tuple[str, str]]:
"""回 [(檔名, SQL), ...],依檔名序(三位數序號決定套用順序)。"""
return [(p.name, p.read_text(encoding="utf-8"))
for p in sorted(MIGRATIONS_DIR.glob("*.sql"))]五條規則(P4 實跑得出,P3/P5 照抄):
schema_migrations + manifest.tsv + migrate.sh 一整套,套件不該取代它。 主專案的既有 scripts/sql/ 腳本不動、不重複登記——套件版是給其他 consumer 用的。CREATE TABLE IF NOT EXISTS / DO $$ 包 policy 建立)。 宿主很可能在已有這些表的既有庫上執行——主專案就是這種情況(表早就建好, 套件版全程 no-op)。裸 DDL 會讓第二次執行整支失敗。寫個測試把這條焊死:
def test_ddl_is_idempotent():
for name, sql in iter_migrations():
for line in sql.splitlines():
s = line.strip()
if s.startswith(("CREATE TABLE ", "CREATE INDEX ")):
assert "IF NOT EXISTS" in s, f"{name} 有非冪等 DDL:{s}"TenantScopedMixinModel 只在 ENABLE_MULTI_TENANT=true 時才產生這兩個欄位——單租戶宿主的 ORM 根本不會 在 INSERT 帶它們,欄位若一開始就 NOT NULL 則每一筆都寫不進去(P4 harness 首跑實際 踩到 NotNullViolation)。pyproject.toml 要顯式 include,否則 wheel 裡只有 .py,iter_migrations() 在安裝後靜默回空清單(沒有任何錯誤訊息,是最難查的那種):
include = [{ path = "jedi_<name>/migrations/*.sql", format = ["sdist", "wheel"] }]id=1),repo 讀的是 WHERE id = 1、寫的是 UPDATE 該列—— 沒有那一列的話浮水印永遠讀到 NULL(不判回撥)、推進 UPDATE 到 0 列,整道防護靜默 失效。用 INSERT ... ON CONFLICT DO NOTHING 保持冪等,並寫一條測試斷言 SQL 內含 該 INSERT——這種缺失沒有任何執行期症狀,只有測試抓得到。ENABLE ROW LEVEL SECURITY,並寫測試把「002 不含該表的 ENABLE RLS」 焊死——照抄別張表的形狀是最容易犯的錯。DDL 從哪來:pg_dump --schema-only -t <表> 撈 DEV 的實況(唯讀操作,不算環境異動), 再改寫成冪等形式。不要憑 ORM model 反推——model 與實際 schema 常有落差(欄位型別、 索引、policy 形狀),照抄實況才不會出現「裝出來的庫跟現有的不一樣」。
⚠️ 先
echo $VIRTUAL_ENV確認是空的。 Poetry 會沿用已啟用的 venv——若你剛從 主專案 venv 過來,poetry install會把套件相依裝進主專案的 venv 並升掉它的套件 (P1 實際踩到兩次:marshmallow 被升到 4.x,弄壞 flask-apispec,得回主專案poetry install才修好)。已在別的 venv 裡就先deactivate,或每個指令前綴env -u VIRTUAL_ENV。
🔴 有 DB 表的套件,harness 還要補三道前提(P4 首跑逐一炸出來,每個症狀都不直觀):
| 前提 | 不做會怎樣 | 做法 |
|---|---|---|
ENABLE_MULTI_TENANT=true 在 import model 之前設好 |
NotNullViolation:mixin 用 @declared_attr 在 class 定義當下決定要不要產生 tenant 欄位,晚設等於沒設 |
os.environ.setdefault(...) 放在檔案最上方 |
宿主生態的 tenants / org_units 兩張表要在 SQLAlchemy metadata 裡(不是只在 DB 裡) |
NoReferencedTableError: could not find table 'org_units'——mixin 的 FK 在 mapper 設定期就要解析得到;只在 DB 建樁表沒有用 |
harness 用 Base 宣告最小等價 model(見 declare_host_tables()) |
register_error_handlers(app) |
套件拋的業務例外(401/403/404)全部變成 500,看起來像套件壞了 | from jedi_common.handler.handler import register_error_handlers |
這三道都是真實宿主本來就有的東西(產品有 jedi-auth 的 model、有錯誤處理器、有多租戶 開關),harness 補上不是作弊——反而正是它的價值:把「套件對宿主的隱性要求」一條條逼出來 寫進 README,下一個 consumer 才不用重新踩一遍。
順序不可倒:
cd jedi-<name> && ./publish.sh(推 Nexus)[project] dependencies 加 "jedi-<name>==<版號>", 移除 dev group 的 path overridepoetry update jedi-<name> → poetry show 確認裝的是 Nexus 版本docs/claude/jedi-packages.md 該套件一段| 詞 | 意思 |
|---|---|
| 插件(plugin) | 自帶 api 層 + 註冊入口的套件,裝上註冊一行就長出功能;拔掉一行就消失 |
| 拔掉測試 | 驗證「拔掉註冊行後產品照常跑」的手測流程(§6.3) |
| harness | 套件自帶的最小宿主,讓套件不靠主產品也能跑起來 |
| port | 套件定義的窄介面(ABC),由 consumer 提供實作 |
| adapter | consumer side 對 port 的實作 |
邊界誠實聲明:做到的是安裝時可插拔(install-time pluggable), 不是執行中熱插拔——DB schema / RLS 不可能執行中掛載。對齊業界(GitLab / Odoo 模組)等級。