母案:FR-049(來源側 goto,四段已 commit 未部署——jedi-survey
4e9cd41/ FE5d73876/ BE checkpoint 快照)。本案是資料模型翻轉:跳題規則從「來源選項宣告目的地(goto_*)」改為「目標題目/題組宣告顯示條件(display_condition)」。 設計真相:docs/features/FR-049.1-2607-survey-display-conditions/design.md(§2 六決策全拍板,不重開)。 狀態:plan 待 review(階段一)。核准後才進實作(階段二)。 For agentic workers: 逐 Task 做;jedi-survey 驗證邏輯 TDD 先測後碼;migration 寫好【先不套】,套用時機回報指揮官。
Goal: 把 FR-049 的來源側 goto 模型翻成目標側 display_condition:BE(jedi-survey)加 display_condition 欄位(刻意排除 translatable_fields,繞開 jedi-common 翻譯 bug)+ 驗證/清理改對象 → Designer 目標側「顯示條件」UI + 子題入口重構(決策 2/3)+ mapper 改鍵 → 填答端 computeVisibility 換輸入形狀 → 稽核檢視端快照換內容。FR-049 骨架(可見性引擎雙函數、快照機制全鏈、三消費點同源架構、子題 pid 接線)保留換輸入(design §5 delta 不 revert)。
Architecture: display_condition 是題目/題組級屬性(非選項級),落 SurveyQuestion / SurveyPage 本體各一個新 JSONB 欄位(需 migration)。填答端沿用單一純函數,語意翻轉為「掃全部目標的 display_condition,判斷每個目標當下該不該顯示」——比 goto 起訖區間演算法更簡單、無 OR 合併爭議。
Tech Stack: Python(jedi-survey path dependency dev loop,目前已是 path 狀態,symlink 已驗)/ Vue 3 + PrimeVue / pytest / Cucumber+Playwright(E2E 另派)。
逐項對 jedi-survey / jedi-common / FE / BE 原始碼 + DEV DB 實查。
display_condition 落欄位 + translatable_fields 家族檢查 ✅ 決定:新增專用欄位、刻意排除 translatable_fieldsSurveyQuestion(jedi_survey/infra/models/survey_question.py)JSONB 欄位只有 options / tags / references,皆有既定語意,不可借掛。SurveyPage(.../survey_page.py)連 JSONB 欄位都沒有(只有 name / description / sort / no)。→ 兩表各需新增一個 display_condition JSONB 欄位(migration)。SurveyQuestion 的可翻譯欄位 = ['question', 'description', 'options'](survey_question_repo_impl.py:36 _setup_translation + survey_question_mapper.py:12)。SurveyPage 的可翻譯欄位 = ['name', 'description'](survey_page_repo_impl.py + survey_page_mapper.py:12)。display_condition 不進 translatable_fields,就完全繞開 jedi-common bug:直讀 jedi-common _get_failover_query()(base_repository_impl.py:642-690)——failover 讀取對非可翻譯欄位走 load_only(直接讀主表原值),只有可翻譯欄位才套 coalesce(trans.col, main.col)。而寫入端 bug(base_repository_impl.py:628-633 的 field not in self._translatable_fields 型別誤判)造成「所有欄位都被當非可翻譯、無條件寫回主表」——對一個本來就非可翻譯的欄位而言,「無條件寫回主表」正是我們要的正確行為。
display_condition 不加入 translatable_fields。讀(load_only 讀主表)、寫(主表)雙路徑都乾淨落主表,與 docs/issues/pending/2026-07-18-jedi-common-translation-exclude-never-works.md 的 bug 零耦合——不像 FR-049 的 goto(掛在可翻譯的 options 內、正確性依賴 bug 副作用)。這是目標側模型比來源側模型在工程上更乾淨的附帶收益。display_condition 塞進 options(沿用 FR-049 落點)就會重新踩進翻譯家族耦合——故 design §3.2「落 SurveyQuestion 本體」的裁決在此得到工程佐證,照辦。jedi-survey(commit 4e9cd41): | 資產 | 座標 | FR-049.1 處置 | |------|------|--------------| | _validate_flow_rules 方法 | survey_question_domain_service.py:123-157 | 重做→ _validate_display_condition(驗對象換成 display_condition) | | 驗證呼叫點 | 同檔 :100(add_question)、:118(update_question) | 換呼叫新方法 | | _cleanup_goto_question_refs | 同檔 :173-186 | 重做→ 清理「引用被刪題的 display_condition」(含 page 表) | | delete_question 清理呼叫 | 同檔 :168 | 換呼叫新清理 | | _cleanup_goto_page_refs | survey_page_domain_service.py:291-311 | 退役(display_condition source 只會是 question,page 不當 source,刪 page 無「引用它的規則」要清) | | delete_page 清理呼叫 | 同檔 :285 | 刪除該行 | | error codes | common/enum/error_code.py:27-30(SURVEY_409010~013 FLOW_RULE 系列) | 重做→ DISPLAY_CONDITION 系列(沿用 409010~013 序號,改語意) | | goto 驗證/清理 14 測試 | tests/unittest/domain/service/test_survey_question_domain_service.py:533-668 + test_survey_page_domain_service.py:412 | 退役,改寫 display_condition 對應測試 |
FE(commit 5d73876): | 資產 | 座標 | 處置 | |------|------|------| | options goto 鍵映射 | survey-mapper.js:122-123(from):217-218(to) | 退役(改 question/page 級 displayCondition 映射) | | flow rules 編輯 UI | AddQuestionDialog.vue:418-463(step3 流程規則區) | 退役,換「顯示條件」區 | | 父題目下拉 | 同檔 :401-407 + parentQuestionOptions:166 + form.parentId | 退役(決策 3,改母題卡「+新增延伸題」入口) | | flowRules 組裝 | 同檔 onSubmit:223-246 addFlowRule:110 removeFlowRule:121 canSetFlowRules:80 | 退役 | | flow rules 卡片預覽 | SurveyDesigner.vue:811-821 | 重做→ 條件顯示 badge | | hydrateFlowRules | SurveyDesigner.vue(grep hydrateFlowRules 定位,含 :91 gotoPageUids 反查) | 退役(display_condition 直接掛題目/題組,無需 hydrate 重組 label) | | goto 透傳 | useSurveyApi.js createQuestion/updateQuestionApi options map(goto 鍵) | 退役,改 display_condition 透傳 | | extractRulesFromQuestions / extractRulesFromSnapshot | useSurveyVisibility.js:14-53 | 重做(換掃 display_condition) | | computeVisibilityFromRules 核心 | 同檔 :59-138(goto 起訖區間 + OR 合併) | 重做(目標側逐目標判斷,演算法簡化) |
BE checkpoint: _extract_flow_rules_snapshot(app/task_survey/service/question_answer_service.py:32-62)重做→ 抽 display_condition 快照。
computeVisibility / computeVisibilityFromSnapshot 現行輸入形狀 ✅computeVisibility(allQuestions, allPages, answers)(useSurveyVisibility.js:141)→ extractRulesFromQuestions 掃 question.options[].gotoQuestionUid/gotoPageUids。computeVisibilityFromSnapshot(flowRulesSnapshot, allQuestions, allPages, answers)(:152)→ extractRulesFromSnapshot 掃 [{source_question_uid, options:[{option_key, goto_question_uid, goto_page_uids}]}]。computeVisibilityFromRules(:59)產出 {visibleQuestionIds, visiblePageIds, hiddenReasons}。SurveyPreview.vue)全部只吃輸出的三個集合,未直接碰規則形狀:visibility(:570)/auditVisibility(:594)/totalRequired(:654)/answeredCount(:659)/progress/currentQuestions(:633)/allPagesFlat(:624)/progressItems(:674)/onSave 必填(:827)/hiddenReasons 文案(:611)。useSurveyVisibility.js 兩個 extract 函式 + core 演算法;SurveyPreview.vue 消費點幾乎零改(輸出契約不變),唯一新增:allPages 需帶 displayCondition 欄位(目前 allPagesRaw 只 flatten,未帶 display_condition,Task 3-2 補)。SurveyPage.questions relationship primaryjoin 帶 SurveyQuestion.pid == None(只回根題),根題的 sub_questions relationship 自動載入子題;serializer SurveyPageResponse.questions(api/survey/serializers/survey_page.py:42)+ SurveyQuestionResponse.sub_questions(survey_question.py:58)巢狀 dump。DEV 實查有真實子題資料(survey_id=360 有 206 筆 pid≠null)。survey-mapper.js:106 subQuestions: (q.sub_questions||[]).map(...);SurveyDesigner.vue:828-843 有子題嵌套 render(縮排 + 左邊框)。onQuestionSubmit/onCopyQuestion 建立成功後直接 selectedPage.value.questions.push(q)(如 :530),把新題(含子題)平推進根層 questions 陣列,沒依 pid 掛回母題的 subQuestions,畫面立即出現一張平級卡片;要重整(重走巢狀讀取路徑)才會正確。決策 3 把子題入口改成「母題卡 +新增延伸題」後,建立成功的本地插入需改為 push 進母題的 subQuestions(而非根 questions),此 bug 一併修掉。_extract_flow_rules_snapshot(questions)(question_answer_service.py:32-62)——掃 question.options[].goto_*,產 [{source_question_uid, options:[{option_key, goto_question_uid, goto_page_uids}]}]。只吃 questions,不吃 pages(goto 掛在 option 上,pages 無 goto)。→ 重做:display_condition 掛在 question 與 page 本體,新抽取需同時掃兩者(checkpoint 目前只 get_questions(),需補載 pages,見 Task 4-1)。flow_rules_snapshot(:339)。survey.question_answer_histories.flow_rules_snapshot(jsonb, nullable)——DEV 已存在(本 session psql 實查確認),migration scripts/sql/2026-07-18-fr049-flow-rules-snapshot.sql 已套 DEV。照 design §3.3 沿用不回滾、不改欄名,只換 JSONB 內容形狀。question_answer_history_service.py:110 建 revert 複本時 flow_rules_snapshot=answer_history.flow_rules_snapshot 繼承目標快照——行為不變(機制與內容無關)。SurveyPreview.vue:586 讀 target.flow_rules_snapshot → computeVisibilityFromSnapshot。內容形狀改變後,extractRulesFromSnapshot 同步改(Task 4-2)。段① jedi-survey BE(欄位+migration+驗證+清理)
↓ Designer 依賴 BE 能存 display_condition
段② Designer(顯示條件 UI + 子題入口重構 + mapper 改鍵)
↓ 填答端依賴 Designer 能正確寫入 display_condition
段③ 填答端(computeVisibility 換輸入)
↓ 稽核端依賴 checkpoint 落新形狀快照
段④ 稽核檢視端(snapshot 內容換 + computeVisibilityFromSnapshot 換輸入)
_validate_flow_rules 全 goto 邏輯、_cleanup_goto_page_refs 整個方法、goto 14 測試、FLOW_RULE error code 文案(序號沿用改語意)。hydrateFlowRules、mapper goto 鍵映射、useSurveyApi goto 透傳、useSurveyVisibility 舊 extract + goto 起訖演算法。_extract_flow_rules_snapshot goto 抽取邏輯。import_survey)不支援 display_condition,匯入問卷無條件、可後續 Designer 補設。環境:jedi-survey path dependency(已是 path 狀態、symlink 已驗,不需重切)。
Files(jedi-survey):
jedi_survey/infra/models/survey_question.py(加 display_condition JSONB 欄)jedi_survey/infra/models/survey_page.py(加 display_condition JSONB 欄)jedi_survey/domain/entities/survey_question_entity.py(__init__ 加 display_condition=None + self.display_condition)jedi_survey/domain/entities/survey_page_entity.py(同)jedi_survey/infra/mapper/survey_question_mapper.py(to_entity 加 display_condition=survey_question.display_condition;translatable_fields 不動)jedi_survey/infra/mapper/survey_page_mapper.py(同)display_condition: Mapped[Optional[Any]] = mapped_column(
JSONB, nullable=True,
comment="顯示條件(FR-049.1):{source_question_uid, answers:[option_key]};null=恆顯示"
)驗證方式:python3 -c "import jedi_survey; from jedi_survey.infra.models.survey_question import SurveyQuestion; print('display_condition' in SurveyQuestion.__table__.columns)" → True。
Files:
scripts/sql/2026-07-18-fr0491-display-condition.sql(主專案)-- Date: 2026-07-18
-- FR-049.1 問卷顯示條件(目標側)—— 題目/題組各加 display_condition JSONB
-- 語意:{source_question_uid, answers:[option_key]};null=恆顯示。刻意不列入 translatable_fields,
-- 讀寫走主表、與 jedi-common 翻譯 bug 零耦合(見 implementation-plan §0-a)。
-- Run with: psql --single-transaction -v ON_ERROR_STOP=1 -f <file> (account: cmmgr)
ALTER TABLE survey.survey_questions
ADD COLUMN IF NOT EXISTS display_condition JSONB NULL; -- 2026-07-18
COMMENT ON COLUMN survey.survey_questions.display_condition IS
'FR-049.1 顯示條件:{source_question_uid, answers:[option_key]};null=恆顯示'; -- 2026-07-18
ALTER TABLE survey.survey_pages
ADD COLUMN IF NOT EXISTS display_condition JSONB NULL; -- 2026-07-18
COMMENT ON COLUMN survey.survey_pages.display_condition IS
'FR-049.1 顯示條件:{source_question_uid, answers:[option_key]};null=恆顯示'; -- 2026-07-18
INSERT INTO public.schema_migrations(filename, note) VALUES
('2026-07-18-fr0491-display-condition.sql',
'FR-049.1 / add display_condition to survey.survey_questions + survey.survey_pages(目標側顯示條件)')
ON CONFLICT (filename) DO NOTHING; -- 2026-07-18Files:
jedi_survey/domain/service/survey_question_domain_service.py(_validate_display_condition 取代 _validate_flow_rules)jedi_survey/domain/service/survey_page_domain_service.py(page 也要驗 display_condition)jedi_survey/common/enum/error_code.py(FLOW_RULE 系列改 DISPLAY_CONDITION)tests/unittest/domain/service/test_survey_question_domain_service.py + test_survey_page_domain_service.py驗證規則(design §3.1 v1 範圍):
display_condition 存在 → 必含 source_question_uid(str)+ answers(非空 list),否則 malformed 擋。source_question_uid 必須存在、同問卷、type=='radio'(單選來源限定)。def test_question_rejects_display_condition_non_radio_source(...):
"""source 指向非單選題 → SURVEY_DISPLAY_CONDITION_SOURCE_NOT_RADIO"""
def test_question_rejects_display_condition_self_reference(...):
"""source 指向自己 → SURVEY_DISPLAY_CONDITION_SELF_REFERENCE"""
def test_question_rejects_display_condition_source_not_found(...):
"""source uid 不存在 → SURVEY_DISPLAY_CONDITION_SOURCE_NOT_FOUND"""
def test_question_rejects_display_condition_cross_survey(...):
"""source 在別份問卷 → SURVEY_DISPLAY_CONDITION_CROSS_SURVEY"""
def test_question_rejects_display_condition_malformed(...):
"""缺 source_question_uid 或 answers 空 → SURVEY_DISPLAY_CONDITION_MALFORMED"""
def test_question_allows_valid_display_condition(...):
"""合法(radio source、同問卷、answers 非空)→ 通過"""
def test_question_no_display_condition_unaffected(...):
"""display_condition=None → 零影響(驗收情境對照 design §6-1 未作答全可見前置)"""
def test_page_rejects_display_condition_non_radio_source(...): # page 版
def test_page_allows_valid_display_condition(...): # page 版SURVEY_DISPLAY_CONDITION_SOURCE_NOT_RADIO = ("顯示條件的來源題必須是單選題", "SURVEY_409010")
SURVEY_DISPLAY_CONDITION_SELF_REFERENCE = ("顯示條件的來源題不可是自己", "SURVEY_409011")
SURVEY_DISPLAY_CONDITION_SOURCE_NOT_FOUND = ("顯示條件的來源題不存在", "SURVEY_409012")
SURVEY_DISPLAY_CONDITION_CROSS_SURVEY = ("顯示條件的來源題必須與本題屬於同一份問卷", "SURVEY_409013")
SURVEY_DISPLAY_CONDITION_MALFORMED = ("顯示條件格式錯誤(缺來源題或答案清單為空)", "SURVEY_400001") # 序號接既有 400 系列def _validate_display_condition(self, entity, is_question: bool, locale: str = None):
dc = entity.display_condition
if not dc:
return
source_uid = dc.get("source_question_uid")
answers = dc.get("answers")
if not source_uid or not isinstance(answers, list) or len(answers) == 0:
raise BadRequestError(ErrorCode.SURVEY_DISPLAY_CONDITION_MALFORMED)
if is_question and source_uid == entity.uid:
raise ConflictError(ErrorCode.SURVEY_DISPLAY_CONDITION_SELF_REFERENCE)
source = self.survey_question_repo.get_by_uid(source_uid, locale)
if source is None:
raise ConflictError(ErrorCode.SURVEY_DISPLAY_CONDITION_SOURCE_NOT_FOUND)
if source.survey_id != entity.survey_id:
raise ConflictError(ErrorCode.SURVEY_DISPLAY_CONDITION_CROSS_SURVEY)
if source.type != "radio":
raise ConflictError(ErrorCode.SURVEY_DISPLAY_CONDITION_SOURCE_NOT_RADIO)在 add_question/update_question(:100/:118 改呼叫)與 add_page/update_page 呼叫(page domain service 需能取 survey_question_repo——查證 _cleanup_goto_page_refs 已在用 self.survey_question_repo,代表已注入,可直接用)。
Files:
jedi_survey/domain/service/survey_question_domain_service.py(_cleanup_goto_question_refs → _cleanup_display_condition_refs,掃 question 與 page 兩表)jedi_survey/domain/service/survey_page_domain_service.py(刪 _cleanup_goto_page_refs:291-311 + delete_page 呼叫 :285——page 非 source 無需清理)def test_delete_question_clears_display_condition_on_questions_and_pages(...):
"""刪來源題 → 引用它的 question.display_condition 與 page.display_condition 皆被清空(限同問卷)"""
def test_delete_page_no_display_condition_cleanup_needed(...):
"""刪 page 不觸發 display_condition 清理(page 非 source)——回歸確認 _cleanup_goto_page_refs 已退役"""def _cleanup_display_condition_refs(self, deleted_uid, survey_id, locale=None):
# 掃同問卷所有題目
for q in self.survey_question_repo.get_all_by_fields(
SurveyQuestionQueryEntity(survey_id=survey_id), locale):
dc = q.display_condition
if dc and dc.get("source_question_uid") == deleted_uid:
q.display_condition = None
self.survey_question_repo.update(q, locale)
# 掃同問卷所有題組(需注入 survey_page_repo;查證已有 self.survey_page_repo)
for p in self.survey_page_repo.get_all_by_fields(
SurveyPageQueryEntity(survey_id=survey_id), locale):
dc = p.display_condition
if dc and dc.get("source_question_uid") == deleted_uid:
p.display_condition = None
self.survey_page_repo.update(p, locale)Files(主專案):
api/survey/serializers/survey_question.py(Create/Update Request + Response 加 display_condition = fields.Raw(allow_none=True, load_default=None))api/survey/serializers/survey_page.py(同)⚠️ 段① 驗證需 DEV 套 migration + 重啟 BE:Task 1-2 migration 套用時機回報指揮官;BE 改套件後提醒 user 重啟。
Files(FE): src/views/survey-v2/utils/survey-mapper.js
Files: src/views/survey-v2/components/AddQuestionDialog.vue
form.displayCondition = { sourceQuestionUid: null, answers: [] }(或 null)。radioQuestionOptions:allQuestionsFlat filter type==='radio' && id!==本題 && 非本題子題。sourceAnswerOptions:選中來源題後,列該題 options 的 {label:opt.label, value:opt.id}(id=key||name,與填答值同源)。Files: src/views/survey-v2/components/AddPageDialog.vue(或題組設定入口,grep 定位)
Files: src/views/survey-v2/SurveyDesigner.vue
Files: src/views/survey-v2/composables/useSurveyVisibility.js(重做,保留匯出函式名 computeVisibility/computeVisibilityFromSnapshot 讓 SurveyPreview 消費點零改)
目標側語意(design §3.1,比 goto 簡單):
displayCondition:
hiddenReasons:target → source_question_uid(供「依 X-N 略過」文案)。function computeVisibilityFromConditions(questionConds, pageConds, allQuestions, allPages, answers) {
const hiddenQ = new Set(), hiddenP = new Set(), hiddenReasons = new Map()
const isHidden = (dc) => {
if (!dc?.sourceQuestionUid || !dc.answers?.length) return false
const ans = answers[dc.sourceQuestionUid]
const v = ans && typeof ans === 'object' && 'answer' in ans ? ans.answer : ans
if (v === undefined || v === null || v === '') return false // 未作答=顯示
return !dc.answers.includes(v) // 已作答未命中=隱藏
}
for (const p of allPages) {
if (isHidden(p.displayCondition)) { hiddenP.add(p.id); hiddenReasons.set(p.id, p.displayCondition.sourceQuestionUid) }
}
for (const q of allQuestions) {
if (hiddenP.has(q._pageId)) { hiddenQ.add(q.id); if(!hiddenReasons.has(q.id)) hiddenReasons.set(q.id, allPages.find(p=>p.id===q._pageId)?.displayCondition?.sourceQuestionUid) ; continue }
if (isHidden(q.displayCondition)) { hiddenQ.add(q.id); hiddenReasons.set(q.id, q.displayCondition.sourceQuestionUid) }
}
return {
visibleQuestionIds: new Set(allQuestions.filter(q=>!hiddenQ.has(q.id)).map(q=>q.id)),
visiblePageIds: new Set(allPages.filter(p=>!hiddenP.has(p.id)).map(p=>p.id)),
hiddenReasons,
}
}
export function computeVisibility(allQuestions, allPages, answers) {
return computeVisibilityFromConditions(null, null, allQuestions, allPages, answers)
// question/page 的 displayCondition 直接掛在物件上,無需另抽
}Files: src/views/survey-v2/SurveyPreview.vue
Files: app/task_survey/service/question_answer_service.py
def _extract_display_conditions_snapshot(questions, pages) -> list[dict]:
snap = []
for q in questions:
dc = getattr(q, "display_condition", None)
if dc and dc.get("source_question_uid"):
snap.append({"target_type": "question", "target_uid": q.uid,
"source_question_uid": dc["source_question_uid"], "answers": dc.get("answers") or []})
for p in pages:
dc = getattr(p, "display_condition", None)
if dc and dc.get("source_question_uid"):
snap.append({"target_type": "page", "target_uid": p.uid,
"source_question_uid": dc["source_question_uid"], "answers": dc.get("answers") or []})
return snapFiles: src/views/survey-v2/SurveyPreview.vue
| # | design §6 情境 | 對應 Task | 驗證方式 |
|---|---|---|---|
| 1 | 部署環境三分流(B 設 A-1∈{地端,混合}、C 設 A-1∈{雲端,混合}) | 2-3(page 條件 UI)+ 3-1/3-2(填答引擎) | 手測 + E2E:答地端出 A,B,D~L;雲端出 A,C,D~L;混合全出;未作答全出 |
| 2 | 新增選項無漏網(A-1 加「其他」不動條件 → 答其他 B,C 不顯示、D~L 照常) | 3-1 演算法(字面語意) | 手測:加選項後答之,B/C 隱藏、其餘正常——無靜默漏答整章 |
| 3 | 同組跳題(Q2-Q4 各設 Q1≠2 → Q1 答 2 消失、進度分母同步、改答恢復保留) | 2-2(question 條件)+ 3-2(進度同源)+ 3-3(答案保留) | 手測:Q1 答 2 → Q2-4 隱藏、分母減;改答恢復、B 已填答案還在 |
| 4 | 必填同源(被隱藏必填不擋提交、可見必填未填仍擋) | 3-2 Step 2(onSave 必填吃 visibleQuestionIds) | 手測:隱藏必填不擋 submit;可見必填擋 |
| 5 | 稽核回溯(提交後改條件不漂移、revert 繼承) | 4-1(快照落地)+ 4-2(快照重算) | 手測:改條件後檢視按快照判定;revert 繼承 |
| 6 | Designer 防呆(刪來源題/改題型連動清空、badge 正確) | 1-4(清理)+ 2-4(badge) | pytest(清理)+ 手測(badge 顯示/消失) |
| 7 | 子題(母題卡新增延伸題、嵌套顯示、無獨立排序、跟母題出現) | 2-4 Step 3-5(入口+平推修復+排序) | 手測:不重整即嵌套;填答端跟母題一起出現 |
cd ~/Projects/Jedicogy/module/jedi-python-package/jedi-survey && pytest tests/unittest/domain/service/ -v依 CLAUDE.md「收尾等命令」:完成所有段落 + 手測過關後,停下回報 status + checklist 結果,不主動做:
docs/specs/ 頁面 spec 更新(writing-feature-specs skill)question_answer_histories.flow_rules_snapshot 不回滾不改名,只換內容形狀(design §3.3)。