diff --git a/.gitignore b/.gitignore index 6c773a4..594202d 100644 --- a/.gitignore +++ b/.gitignore @@ -16,9 +16,10 @@ build/ # Environment .env +.env* +!.env.example .env.local .env.*.local -!.env.example # SQLite databases (local data) *.db @@ -47,6 +48,7 @@ Thumbs.db # Never commit actual interview transcripts or extracted SOUL/VOICE/SKILL files artifacts/ transcripts/ +exports/ *.transcript.md SOUL-*.md VOICE-*.md @@ -67,6 +69,25 @@ STATE-*.md # Internal working dir (not for public) concept-for-chatgpt-diagram.md +GEMINI.md +docs/design/ +.private/ +private/ # Claude Code local settings (operator-specific MCP refs, not for public) .claude/ + +# === Moat protocol: v2 methodology + internal roadmap, withdrawn 2026-05-18 === +/src/virtualme/data/question-pool-v2.yaml +/src/virtualme/data/domain-packs-v2.yaml +/specs/09-interview-engine-v2.md +/docs/research/virtualme-domain-pack-8-fields.md +/src/virtualme/interview/v2_schema.py +/src/virtualme/interview/v2_loader.py +/tests/unit/test_interview_v2_loader.py +/tests/unit/test_domain_packs_v2.py +/docs/TRUNK.md + +# === Moat protocol: methodology implementations stay private(含 Version D 腳本 + 個人 PII)=== +/src/virtualme/interview/bait_reaction.py +/tests/unit/test_bait_reaction.py diff --git a/README.en.md b/README.en.md index dac5282..17e67db 100644 --- a/README.en.md +++ b/README.en.md @@ -2,6 +2,21 @@ > Extract a person into an AI agent — through an 8-week interview, not a form. +**Current release: v1.1.0** — baseline interview + coverage tracking + persona markdown export, plus Constitution v1.1 (six Stability & Restraint Principles) and matching M1 hard gates. + +### v1.1.0 Highlights + +- **[Constitution v1.1](specs/11-constitution.md)** — ratified by seven-agent council on 2026-05-20. Codifies the project's "caution, restraint, reverence" stance previously scattered across `docs/TRUNK.md` / `specs/05` / milestones into six principles: P1 State-Trait Separation / P2 Contradiction Preservation / P3 Reflective Restraint / P4 Multi-Session Validation / P5 Self-Correction & Agency / P6 Provenance, Confidence & Temporal Decay. +- **Interview reasoning engine refactor** — L0 transport idempotency fail-closed + L1 read-only TurnState + L2 `turn_reasoner.decide_and_reply()` + Guardrail + feature flag (`reasoning_turn_enabled`) whitelist rollout. +- **User-initiated persona archive export + download link** +- **M1 hard gate detectors (4 principles) + 115 contract tests**: + - P3 — `SkipStopReason` enum + Guardrail metadata + `reflection_note` no-leak + - P5 — `hedge_validator` (8 forbidden patterns / 12 hedge markers) + `unlike_me` regression + - P1 — `stability_gate.is_eligible_for_core_truths()` (STATE never enters Core Truths) + - P4 — `multi_session_validator.can_be_validated()` (single-session can never be validated) + +> M2 will wire detectors into `build_snapshot_bundle` / export pipeline. This release ships detectors + contract tests that lock the invariants. See `specs/11-constitution.md` §M2/M3. + [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![Python 3.11+](https://img.shields.io/badge/Python-3.11+-blue.svg)](https://www.python.org/) [![中文](https://img.shields.io/badge/Lang-中文-red.svg)](README.md) diff --git a/README.md b/README.md index 4f9405e..de10f6d 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,22 @@ > 把一個人**萃取**成 AI 代理人——用 8 週訪談,不用填表。 +**Current release: v1.1.0** — baseline interview + coverage tracking + persona markdown export 之上,補上 Constitution v1.1(六條 Stability & Restraint Principles)與對應的 M1 hard gates。 + +### v1.1.0 Highlights + +- **[Constitution v1.1](specs/11-constitution.md)** — 七位一體 council 2026-05-20 ratified;把先前散落於 `docs/TRUNK.md` / `specs/05` / milestone 的「謹慎、克制、有敬畏」立場 codify 為六條:P1 State-Trait Separation / P2 Contradiction Preservation / P3 Reflective Restraint / P4 Multi-Session Validation / P5 Self-Correction & Agency / P6 Provenance, Confidence & Temporal Decay +- **訪談 reasoning engine 重構** — L0 transport idempotency fail-closed + L1 TurnState 只讀狀態物件 + L2 `turn_reasoner.decide_and_reply()` + Guardrail + feature flag (`reasoning_turn_enabled`) whitelist rollout +- **使用者自助匯出人格檔 + 下載連結** +- **Production demo map** — [`docs/architecture-demo-flow.md`](docs/architecture-demo-flow.md) documents the deployed LINE Bot / VPS architecture and a short demo script. +- **M1 hard gate detectors(4 條)+ 115 contract tests**: + - P3 — `SkipStopReason` enum + Guardrail metadata + reflection_note no-leak + - P5 — `hedge_validator`(8 forbidden patterns / 12 hedge markers)+ unlike_me regression + - P1 — `stability_gate.is_eligible_for_core_truths()`(STATE 不進 Core Truths) + - P4 — `multi_session_validator.can_be_validated()`(single-session 不得 validated) + +> M2 將把 detector wire 進 build_snapshot_bundle / export pipeline;本版只交付 detector + contract test 鎖住 invariant。詳見 `specs/11-constitution.md` §M2/M3。 + [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![Python 3.11+](https://img.shields.io/badge/Python-3.11+-blue.svg)](https://www.python.org/) [![English](https://img.shields.io/badge/Lang-English-red.svg)](README.en.md) @@ -89,6 +105,11 @@ VirtualMe 把這個發現延伸成可上線的 pipeline: 匯出時會再次 scrub anchor 內容中的 PII;`interviewee_id`、資料夾名與 archive metadata 不會被改名,請不要用 email / 真名當 interviewee id。 +v1.0.0 的輸出包含兩種層級: + +- **Raw archive**:`python -m virtualme.export` 產生 8 個 dimension markdown、入口檔與 manifest。 +- **Review draft**:可依 anchors 人工整理成 `SOUL.md` / `VOICE.md` / `SKILL.md` / `PEOPLE.md` / `HISTORY.md` / `JOURNAL.md` / `BOUNDARIES.md` / `STATE.md` 八份可讀人格檔,用於「像 / 不像 / 缺例子」review。 + 加上一個可用的 agent endpoint,可以: - 起草給客戶 / 候選人 / 同事的訊息 - 用你的語氣回覆公開貼文 @@ -159,6 +180,15 @@ python scripts/init_db.py --path ./data/virtualme.db python -m virtualme.cli --interviewee yourself ``` +### LINE dogfood + +v1.0.0 的 LINE path 可用 feature flag 開啟 supervised dogfood。新路徑會在每輪訪談後寫入 anchors,讓「進度」查詢顯示真實 coverage。 + +```env +REASONING_TURN_ENABLED=true +REASONING_TEST_USER_IDS= +``` + ### 本機 demo flow 訪談累積資料後,可以先輸出 markdown archive,再準備一次手動 blind test: diff --git a/docs/TRUNK.md b/docs/TRUNK.md deleted file mode 100644 index 9bc6e30..0000000 --- a/docs/TRUNK.md +++ /dev/null @@ -1,427 +0,0 @@ -# VirtualMe — 主幹與路線圖(Trunk & Roadmap) - -> 用途:定義 VirtualMe 的**主幹路線**、各階段預期發展、以及一個可定期執行的「有沒有繞路」檢查。 -> 建立:2026-05-16 by Claude (Architect) -> 性質:活文件。每次階段轉換或月度 review 後更新 §1.4 與 §3 的「現況」。 -> Review 方式:見 §5。 -> 與其他文件關係:`specs/*` 是設計細節;本檔是上位的方向約束。衝突時,本檔的「主幹定義」優先於任何單一 spec 的深化。 - ---- - -## 0. 接續錨點(Handoff Anchor)— 新 session / 新 agent 從這裡開始 - -> 這格是 VirtualMe 所有工作的**單一接續點**。任何人或 agent 接手 VirtualMe, -> 先讀這格 → 再跑 §5.1 Trunk Check → 再依 §7 認領自己那一棒。 -> **規則**:每個動到 VirtualMe 的 session 結束前,必須更新這格 + 寫 memhall(`namespace: project`, -> 內容指回本檔)。這樣下一棒不論是哪個 agent、哪個 session,都從同一個事實接手。 - -**最後更新**:2026-05-16(晚間)by Codex (Engineer/Dissenter) - -**當前階段**:S4/S5 前置 — ⚠️ 端到端薄切片(onboard→驗證)**尚未打通過一次**(見 §2.1 / §3.0)。 -S2 訪談 plumbing 已改善;下一個主幹缺口不是更多題目,而是 Snapshot 的合成與驗證。 - -**當前焦點**:① 打通 Snapshot 薄切片(訪談資料 → SOUL-lite hypothesis → mini blind test)② -把 STG-036 的 altitude / decision-target 設計排進 v2,但不在 Snapshot 前擴大題庫。 - -**進行中**: - -| 項目 | 負責 | 狀態 | 檔案 | -|---|---|---|---| -| 5 個 plumbing bug 修正 | Codex | ✅ 已修並 commit(`a726cd8` / `fbd7ca6`),訪談 2 驗證生效 | — | -| 重新定位 dissent | Codex | ✅ 已完成:reposition 可作 north star,但近期路標改成 Snapshot / SOUL-lite / mini blind test 優先 | 本檔 §8 | -| STG-036 萃取深度重構 | Claude → Max → Claude | 已 amend(決策 vs 格言判準 + 矛盾狩獵),待 Maki ratify | `~/.claude/mem/staging.md` STG-036 | -| 生命週期 3 批調查 | ChatGPT / Max / SuperGrok | 問題清單已備 | 本檔 §6 | - -**下一步(單一最重要)**:先做 Snapshot 薄切片的工程端:SOUL-lite synthesis + mini blind test。 -STG-036 進 v2 設計與後續 feature flag,不阻塞 Snapshot 第一版。 - -**Blockers / 已確認**:S4/S6 不是空白,但都只是骨架: -S4 有 `export/markdown.py` 將 anchors 匯出成 dimension markdown,**沒有**真正的 SOUL-lite / persona synthesis; -S6 有 `responder/core.py` + `responder/persona.py` responder skeleton,但目前偏 HR/HRBP demo,**不是**通用 persona runtime。 - ---- - -## 1. 來龍去脈 - -### 1.1 市場起點 - -市面上有一批「打造你的 AI 分身」課程,賣 $3,000–5,000 USD。它們其實賣三件事: - -1. 教你填 SOUL.md / SKILL.md 之類的人格檔案 -2. 教你串 Claude / OpenAI API -3. 同儕網絡 + 督促 + 教練回饋 - -技術上,第 1、2 件不值 $4,000。第 3 件(人的督促)才是這些課程真正的留存機制。 - -### 1.2 VirtualMe 的賭注 - -VirtualMe = 第 1、2 件事的開源版本,並把第 1 件從「自己填表」換成「**被訪談**」—— -8 週訪談把一個人萃取成 AI 代理人。 - -兩個核心差異化: - -- **非黑盒**:產出是使用者自己擁有、看得到、改得回的 markdown(SOUL / VOICE / BOUNDARIES), - 不是某天會 silently update 的雲端產品。 -- **訪談 > 填表**:人不擅長自我描述。訪談 + 追問能挖出填表挖不到的東西。 - -⚠️ **賭注的隱藏成本**:VirtualMe 刻意不做課程的第 3 件事(教練/同儕督促)。 -那正是課程的留存引擎。VirtualMe 等於拿掉了留存引擎——這是 S0/S1 最大的結構性風險(見 §3)。 - -### 1.3 已經建好的(2026-05-16) - -| 層 | 狀態 | -|---|---| -| 設計 spec | `specs/00`–`10` 齊全:overview / 訪談引擎 / 題庫 / blind-test / tech-stack / PII / 三方案 / related-work / 記憶架構 / 引擎 v2 draft / personality infrastructure strategy | -| 訪談引擎 v1 | production,跑在 VPS LINE bot(`149.28.17.35`)| -| 訪談引擎 v2 | draft:`question-pool-v2.yaml`(5 intake + 64 泛用)、`domain-packs-v2.yaml`(8 領域,Codex 剛完成機械轉換 + 測試)| -| 萃取 | `anchor_extractor.py` / `triples.py` / `depth_evaluator.py` 存在 | -| 驗證 | `blind_test.py` + `specs/03` blind-test protocol | -| 合成 | `export/markdown.py` 只輸出 anchors,不是 synthesis;缺 SOUL-lite 合成 | -| Runtime | `responder/` 有 demo skeleton,但非通用 persona runtime | -| 維護 | 「重頭開始萃取」指令(commit `ce2e857`)、`week_progression.py` | - -### 1.4 現在站在哪(每次 review 更新這格) - -**2026-05-16**:跑了一場真實訪談(約 40 回合),暴露兩件事: - -1. **5 個編排層 plumbing bug** → 已開 Codex briefing 修正(`agent-council/20260516-virtualme-interview-fixes/`)。 -2. **一個設計問題**:引擎在收場面話——40 回合只萃到 1 個有辨識度的錨點。 - `stop_condition` 太鬆、追問只有一招 → 已開 **STG-036**。 - -**判斷**:VirtualMe 目前把力氣集中在 **S2(訪談)的內容深化**(8 領域、64 題、v2 題庫), -但 **S4(合成)→ S5(驗證)的端到端迴路從未被打通驗證過**。這是典型的繞路前兆—— -見 §2。 - ---- - -## 2. 主幹定義(The Trunk)— 本檔最重要的一節 - -### 2.1 一句話主幹 - -> **VirtualMe 的主幹是:一個人走完「onboard → 訪談 → 萃取 → 合成 persona → 自己驗證『像不像我』」 -> 的完整迴路,並且他能在最後說出『這像我』。** - -不是「題庫多完整」、不是「訪談多順」、不是「8 領域多齊」。 -是**那個人最後有沒有認出自己**。 - -### 2.2 主幹原則:薄切片優先(breadth-first thin slice) - -VirtualMe 有 8 個使用者階段(S0–S7,見 §3)。兩種推進方式: - -- ❌ **深度優先**:把 S2 訪談做到完美,再做 S3,再做 S4…… - → 風險:在 S4/S5 才發現「萃取出來的東西根本合不成一個人」,前面全部白做。 -- ✅ **薄切片優先**:先用**最小可行**的每個階段,打通一條 onboard→驗證的細線, - 讓「像不像我」這個訊號**真的跑出來一次**。然後才回頭加深。 - -**主幹 = 那條最細的端到端線。** 任何在某個階段「加深」但該階段的薄切片還沒打通的工作, -預設視為繞路,要先停下來問「這有沒有讓端到端迴路更接近跑通」。 - -### 2.3 什麼算「在主幹上」 / 什麼算「繞路」 - -| 在主幹上 ✅ | 繞路 ⚠️ | -|---|---| -| 讓「像不像我」訊號第一次端到端跑出來 | 在迴路跑通前,把題庫從 64 題擴到 120 題 | -| 修阻斷迴路的 bug(如模板洩漏) | 修不影響迴路的體驗細節 | -| STG-036:讓萃取真的萃到「人」 | 把 8 領域加到 16 領域 | -| 打通 S4 合成、S5 blind-test | 為還沒驗證的引擎做 dashboard / 多語系 / router | - ---- - -## 3. 各階段:現況 / 預期發展 / 主要風險 / 完成判準 - -> 「預期發展」是 Claude 的**預測**,不是承諾——用來在 review 時對照「實際是否偏離」。 -> 「完成判準」= 該階段薄切片打通的定義(Definition of Done)。 - -### 3.0 里程碑階梯(薄切片的時間軸具體化) - -§2.2 的「薄切片優先」落到時間軸 = 下面這條階梯。每一階是一條**更完整的端到端線**, -不是「把某個階段做深」。借鑑外部策略報告,經主幹原則篩選後採用。 - -| 里程碑 | 時間 | 產出 | 打通哪些階段 | -|---|---|---|---| -| **Snapshot** | 30 分鐘 | SOUL-lite(極簡人格初稿) | S0→S1→S2(極短)→S3(極簡)→S4(lite) — **第一條端到端細線** | -| v0.1 | 1 週 | 初步人格檔 | 上述各階段加厚一層 | -| v0.3 | 3 週 | 加入反例與衝突情境 | S2 加 decision probe / 矛盾狩獵 | -| v0.5 | 5 週 | **第一次 blind test** | S5 首次端到端跑出「像不像我」訊號 ← 主幹終點首達 | -| v1.0 | 8 週 | 完整 persona archive | 全階段加深 | - -**讀法**:Snapshot 就是 §2.1 主幹的最小實現——30 分鐘內讓一個人走完整條線拿到 SOUL-lite。 -但 SOUL-lite 是 **hypothesis / draft**,不是「30 分鐘就準確萃取出你」的承諾。第一版成功的判準是: - -1. 從現有訪談資料產出一份明確標示信心與 provenance 的 SOUL-lite。 -2. 本人能逐條標記「像 / 不像 / 不確定」。 -3. 產出一組 mini blind test 材料,讓「像不像我」訊號第一次端到端跑出來。 - -在 Snapshot 跑通前,任何「加深單一階段」的工作預設是繞路。 -S0–S7 的「完成判準」不是一次達成,而是隨階梯每一階逐步加厚。 - -### S0 — 取得與承諾(Acquisition & Commitment) - -- **現況**:開源 repo + README 是入口。使用者自行 clone 自架,或用 Maki 託管的 bot。 -- **預期發展**:自架門檻高 → 多數人會用託管 bot → 「非黑盒、資料自己擁有」的賣點在託管模式下被稀釋(資料在 Maki 的 VPS)。需要一套清楚的「自架 vs 託管」信任說明。 -- **主要風險**:① 8 週承諾 + 無教練督促(§1.2)→ 完成率可能極低 ② 託管模式稀釋核心賣點。 -- **完成判準**:一個新使用者能在 5 分鐘內理解「這要 8 週、產出是什麼、資料在哪」並啟動。 - -### S1 — Onboarding & Intake - -- **現況**:5 題 intake → 選 1 個 domain pack(8 選 1)。 -- **預期發展**:單一 domain pack 指派會撞牆——真實的人是混合的(會帶人的工程師、會賣的創辦人)。預期需要演化成「多 pack 加權」或「主+副 pack」。 -- **主要風險**:① intake 誤判 → 錯的題組跑滿 8 週 ② 期待落差(以為是聊天機器人,結果是訪談)。 -- **完成判準**:intake 後使用者拿到的題組,他自己認同「這些問題問對方向」。 - -### S2 — 訪談 / Elicitation - -- **現況**:v1 production、v2 draft。剛驗證出「收場面話」問題(STG-036)。 -- **預期發展**:STG-036 重構後,預期能萃到更具辨識度的回答;但 8 週長訪談的疲勞、心情依賴、自我美化(espoused vs enacted)會是下一層問題。 -- **主要風險**:① 收場面話(STG-036 處理中)② 自我report 偏誤——人描述理想自我不是真實行為 ③ 8 週疲勞與 disengagement。 -- **完成判準**:一場訪談結束後,逐字稿裡可辨識錨點密度 ≥ 某門檻(非「對話順不順」)。 -- **註**:訪談**技術**層面的調查已在 STG-036 的 `max-questions.md`,本檔不重複。 - -### S3 — 萃取 / Anchoring - -- **現況**:`anchor_extractor.py` / `triples.py` / `depth_evaluator.py` 存在。 -- **預期發展**:跨週矛盾(第 1 週說 X、第 5 週說 ¬X)會浮現——人本來就不一致,把矛盾壓平等於弄丟這個人。預期需要「保留矛盾」而非「解決矛盾」的設計。 -- **主要風險**:① LLM 萃取幻覺/過度推論 ② 矛盾被壓平 ③ 錨點過度擬合單一軼事。 -- **完成判準**:萃取出的錨點,使用者能逐條判斷「是/不是我」,且「是」的比例可量。 - -### S4 — Persona 合成(SOUL / VOICE / BOUNDARIES) - -- **現況**:已確認只有 archive export skeleton。`src/virtualme/export/markdown.py` 會把 anchors 依 dimension 輸出成 markdown,但它不是合成模組;缺少「anchors → SOUL-lite / VOICE-lite / BOUNDARIES-lite」的摘要、取捨、信心標記與反饋入口。 -- **預期發展**:最大難關。① LLM 拿到 persona prompt 會「回歸通用助理基線」,把獨特性磨平 ② VOICE(怎麼講話)比 SOUL(想什麼)難太多 ③ 一串錨點 ≠ 一個連貫的人。 -- **主要風險**:① 回歸通用基線 ② VOICE 無法靠 markdown 重現 ③ 可編輯性悖論——使用者編輯自己的 twin = 虛榮編輯 → twin 偏離真實(透明性賣點同時是準確性風險)。 -- **完成判準**:合成出的三個 markdown 餵給 LLM,能跑出一段「使用者認得出是自己」的對話。 - -### S5 — 驗證 / Blind Test - -- **現況**:`specs/03` + `blind_test.py`。「不像我」反饋路由是 handoff 列的 TODO。 -- **預期發展**:「像不像我」缺乏乾淨 ground truth——本人評分有偏誤、他人評分需要夠熟的他人。「不像我」是低解析度訊號,使用者說得出「怪」說不出「哪個維度怪」。 -- **主要風險**:① 無 ground truth ② blind test 過關 ≠ 實際使用準確 ③ 反饋→8 維度的訊號歸因很難。 -- **完成判準**:「像不像我」這個訊號**端到端跑出來過至少一次**(這就是 §2.1 主幹的終點)。 - -### S6 — 部署 / 當代理人用 - -- **現況**:已確認有 responder skeleton(`src/virtualme/responder/`),可讀 persona markdown 並生成回覆;但 prompt 目前偏 HR/HRBP demo,不是通用 VirtualMe agent runtime。 -- **預期發展**:persona 一旦對外行動 → 究責、揭露(對方知不知道在跟 twin 講話)、BOUNDARIES.md 當安全層夠不夠、長對話中 agent 漂離 persona。 -- **主要風險**:① twin 講出本人不認同的話 → 名譽/關係 ② BOUNDARIES 是一份 markdown,擋不擋得住越界行為 ③ persona 可攜 → 任何人拿到檔案就能跑「你」。 -- **完成判準**:先定義清楚「twin 拿來幹嘛」。在這之前 S6 不展開(見 §4 繞路陷阱)。 - -### S7 — 維護 / 漂移 / 生命週期 - -- **現況**:「重頭開始萃取」指令(全清重來)、`week_progression.py`。 -- **預期發展**:人會變,twin 會過期。「全清重來」浪費 8 週有效資料;「增量更新」會累積錯誤。預期需要版本化 persona + 漂移偵測。 -- **主要風險**:① 無漂移訊號,除非本人察覺 ② 全 reset vs 增量更新的兩難 ③ 重大生命事件(失業/離婚/重病)讓人不連續改變,twin 看不到。 -- **完成判準**:twin 有版本、有「該重訪了」的觸發條件。 - -### X — 橫切:隱私 / 安全 / 身分倫理 / 法務 / 心理 / IP - -- **現況**:`specs/05` 已涵蓋 PII / boundaries。 -- **主要風險**: - - **第三方資料**:訪談會點名沒同意的第三人(這次訪談就出現恭一/哈路基/Mimi)。 - - **法務**:台灣 PDPA / 歐盟 GDPR 對「一個人的人格模型」的處置、被遺忘權。 - - **開源雙用**:別人 fork 來建「非自願對象」的 twin(前任、老闆)。 - - **心理**:面對自己的 twin 的恐怖谷;grief-tech 鄰接(有人會想用此法建逝者 twin)。 -- **完成判準**:橫切不是一個「階段」,是每個階段都要過的篩。S0–S7 任一階段的設計都要回答對應的 X 風險。 - ---- - -## 4. 繞路陷阱清單(看到就停,回 §2 對照) - -1. **在端到端迴路跑通前加深題庫** — 64 題 → 120 題、8 領域 → 16 領域。最高頻陷阱,§1.4 已在邊緣。 -2. **為未驗證的引擎做基礎建設** — dashboard、多語系、router、效能優化。引擎還沒證明能萃到人,這些都是早做。 -3. **完美主義訪談體驗** — 修不阻斷迴路的體驗細節(措辭、emoji、招呼語氣),當成進度。 -4. **S6 在「twin 拿來幹嘛」沒定義前展開** — 沒有目標就設計部署 = 一定繞路。 -5. **scout 調查自我膨脹** — §6 的調查若變成「研究 30 篇論文」而不是「回答阻擋主幹的具體問題」。 -6. **把 plumbing bug 修復當主幹進度** — bug 要修(它阻斷迴路),但修完是「回到起跑線」不是「往前」。 -7. **過度治理** — 為小決策跑完整 Council。medium 以下直接做。 -8. **單模型保真未證明就做 cross-model 適配** — 「同一份 persona 在 Claude/GPT/Gemini/Grok - 各自走鐘」是真問題,但屬 S4/S6 後期。在「persona 連一個模型都還沒證明像本人」前去蓋 - Model Adapter Layer / Fidelity Benchmark System = 為未驗證的引擎蓋中介架構(陷阱 2 的變種, - 且反四層北極星「不做炫技 Router」)。外部策略報告把它捧成「大魔王/護城河」——正因為它 - 聽起來夠大,最容易被拿來正當化離開主幹。 - ---- - -## 5. 定期 Review 機制(L4 友善,不做 dashboard) - -### 5.1 每個 VirtualMe session 開場:Trunk Check(30 秒) - -Claude 在每個動到 VirtualMe 的 session 開場,輸出三行: - -``` -TRUNK CHECK -1. 上一段工作推進了哪個階段的「完成判準」?還是 §4 的繞路? -2. 我們現在宣稱在哪個階段——那個階段的薄切片打通了嗎? -3. 此刻有沒有正在踩 §4 任何一條陷阱? -``` - -Maki 掃一眼即可,不對就喊停。 - -### 5.2 階段轉換時:深 review - -當宣稱某階段「完成判準」達成、要進下一階段前,停下來確認: -判準是不是真的端到端驗證過,還是只是「程式碼寫完了」。 - -### 5.3 月度:更新本檔 - -重讀 §1.4「現在站在哪」,更新它;重排 §4 繞路陷阱的當前風險; -檢查 §3 各階段「現況」與「預期發展」的落差——落差大代表預測錯了,要修正預期。 - -> 可選:用 `/loop` 或排程做月度提醒。但**不要**做需要盯的儀表板(反 L4)。 - ---- - -## 6. 待調查清單 → 分批給 ChatGPT / Perplexity Max / SuperGrok - -這些是「阻擋主幹推進的具體未知」。每個 scout 依守備範圍分批。 -**原則**:調查是為了解鎖主幹,不是為了寫研究報告(§4 陷阱 5)。 - -scout 守備範圍: -- **Perplexity Max** — 有引用的既定知識:學術方法論、心理計量、法規。 -- **ChatGPT** — 結構化推理 + 設計空間比較 + 競品拆解 + 評估框架設計。 -- **SuperGrok** — 即時 X/社群:正在發生的競品、輿論、近期事件。 - ---- - -### ── 以下整段貼給 ChatGPT ── - -**背景**:VirtualMe 是開源專案,用 8 週訪談把一個人萃取成 AI 代理人,產出使用者自己擁有的 -markdown 人格檔(SOUL / VOICE / BOUNDARIES)。我需要結構化推理與設計空間比較,請逐題回答。 - -1. **留存設計**:自我導向、長達 8 週、無教練督促的自我提升型程式,完成率通常多低? - 有哪些「不靠真人督促」的留存機制被驗證有效?(VirtualMe 刻意拿掉了課程的教練層。) -2. **persona 表徵的設計空間**:要讓 LLM 重現一個特定的人,「markdown 人格 prompt」 vs - 「RAG over 錨點」 vs「fine-tune」 vs 混合,各自在**保真度 / 成本 / 漂移 / 可攜性**上的取捨? -3. **回歸通用基線**:LLM 拿到 persona prompt 後傾向回到「通用助理」語氣、磨平獨特性。 - 有哪些已知的對抗技巧? -4. **無 ground truth 的驗證**:在沒有乾淨 ground truth 的前提下,怎麼設計一套 - 「twin 像不像本人」的評估框架? -5. **competitor 拆解**:Delphi、Personal AI、character/clone-yourself 類產品, - 它們的 onboarding、persona 建構方式、商業模式各是什麼?哪裡做得好、哪裡踩雷? -6. **S6 失效模式**:當一個 persona agent 開始「代表本人」對外行動,列出主要失效模式 - (究責、揭露、邊界執行),以及對應的設計緩解。 - ---- - -### ── 以下整段貼給 Perplexity Max ── - -**背景**:同上。我需要**有學術/專業文獻引用**的答案,請逐題附來源。 - -1. **自我報告效度**:人描述「自己會怎麼做」與「實際行為」的落差(espoused vs enacted), - 心理學文獻怎麼量化?訪談設計上有哪些已驗證的修正手法? -2. **矛盾與一致性**:人格研究如何處理「同一個人在不同時間/情境給出矛盾自述」? - 建模時該「解決矛盾」還是「保留矛盾」? -3. **persona 保真度的量測**:學術上怎麼測「一個 AI 模仿特定個人」的保真度 / 建構效度? - 有沒有既成的 LLM-persona 評估方法或 benchmark? -4. **法規**:台灣 PDPA 與歐盟 GDPR,如何處置「一個個人的人格/行為模型」這類衍生資料? - 被遺忘權是否及於「模型」?訪談中出現的**第三人**(未同意)資料如何合規處理? -5. **心理影響**:「與自己的數位分身互動」「為逝者建分身(grief tech)」的已知心理影響研究? -6. **[偏見查證]** 質性訪談方法上,如何區分受訪者「逃進哲學/宿命論來迴避問題」與「他的 - 哲學觀本身就是真實答案」?有沒有可操作的判準?(直接影響 STG-036 的「高度偵測器」會 - 不會誤殺真實特質——目前 Claude 判定「命運自有安排」是 deflection,此判定待查證。) - -> 註:訪談**技術**(追問、停題、具體記憶 elicitation)已在 STG-036 的 `max-questions.md`,此處不重複。 - ---- - -### ── 以下整段貼給 SuperGrok ── - -**背景**:同上。我需要**即時的 X / 社群情報**——正在發生的事,不是定型的知識。 - -1. 「打造你的 AI 分身 / clone yourself」這類 $3–5k 課程,X 上現在誰在賣? - 學員的真實評價、有沒有 backlash? -2. 近 3–6 個月,digital twin / personal AI / AI persona 類產品有哪些發布、倒閉、 - 爆紅或翻車事件? -3. 一般人對「做一個自己的 AI 分身」的情緒光譜——興奮 vs 毛骨悚然?grief-tech(逝者分身) - 的討論氛圍如何? -4. X / GitHub 上現在正在紅的開源「self twin / persona」專案有哪些?(潛在競品或可借鑑) -5. 近期有沒有 persona-AI 講出有害內容、或「非自願 twin」的爭議事件? -6. **[偏見查證]** 現有 digital twin / persona AI 產品,是把「跨模型可攜性 / cross-model - fidelity」當**早期**核心去設計,還是**後期**才補?有沒有人把它當賣點/護城河、真的做出來? - (VirtualMe 內部有一派——由 Claude 提出——認為它是後期問題,想用市場實況檢驗這個判斷。) - ---- - -## 7. Agent ↔ 階段路由(誰在哪一棒,跨 agent / 跨 session 無縫接軌) - -> 目的:接手時不用重新決定「這該誰做」。路由依據是**守備範圍**(`rules/agent-routing-default.md`), -> 不是性格、不是感覺。 - -| 工作類型 | 主責 agent | 主要出現在 | -|---|---|---| -| 架構 / 設計 / 品質裁決 / 方向把關 | Claude(Architect/Judge) | 全階段,尤其 S4 / S5 | -| 寫 code / 修 bug / 重構 / 測試 | Codex(Engineer) | S1–S7 實作 | -| 跨檔 code 分析 / 多方案技術比較 | Gemini(Analyst) | S3 / S4 / S6 | -| 學術方法論 / 心理計量 / 法規(要引用) | Perplexity Max(Scout-1) | S2 / S3 / S5 / X | -| 設計空間推理 / 競品拆解 / 評估框架 | ChatGPT(Scout,推理型) | S1 / S4 / S5 / S6 | -| 即時競品 / 輿論 / 近期事件 | SuperGrok(Scout-2) | S0 / X / 競品掃描 | -| 隱私資料批次 / 逐字稿本地預處理 | gemma4(Local Brain) | X / 訪談逐字稿前處理 | -| ratify / 風險裁量 / 定義「twin 拿來幹嘛」 | Maki(Chair) | 每個階段關卡 | - -### 接手 SOP(任何 agent / 任何新 session) - -1. **讀 §0 接續錨點** — 知道現在在哪個階段、誰在做什麼、下一步是什麼。 -2. **跑 §5.1 Trunk Check** — 確認接下來要做的事在主幹上,不是 §4 繞路。 -3. **依上表認領那一棒** — 多 agent 協作由 Maki 人類中繼,採 copy/paste 問答,不要求其他 - agent 讀寫本機檔案,也不使用 CLI File I/O 來協調 agent。若已有外部 briefing markdown, - Codex/Claude 可讀作參考,但新的委派預設走對話中繼。 -4. **session 結束前** — 回填 §0、必要時更新 §3 現況 / §1.4,並寫 memhall。 - -### 三個接續基座(無縫接軌靠這三個,不靠記憶) - -| 基座 | 角色 | -|---|---| -| 本檔 `docs/TRUNK.md` §0 | 「現在在哪、下一步」的單一事實來源 | -| Maki copy/paste 中繼 | 跨 agent 的 briefing / answer 交接;避免 agent 自行透過 CLI/File I/O 協調 | -| memhall(`project` namespace) | 跨 session 長期記憶,entry 內容指回本檔 | - -> 對話階段(分析 → briefing → 委派 → 驗證 → ratify)的接軌: -> repo 事實落在本檔與 specs;跨 agent 意見由 Maki 貼入目前對話,由主線 agent 收斂。 -> 不再把 agent-to-agent file handoff 當成必要流程。 - -## 8. Codex Dissent 結論 → 接下來實作路標 - -2026-05-16 Codex 重新讀 `docs/TRUNK.md`、`specs/09`、`specs/10` 與現有 code 後,給出以下工程路標。 - -### 8.1 Reposition 的採納邊界 - -- 採納:VirtualMe 的 north star 是「像我做選擇」,不是只像我講話。 -- 限制:`Personality Infrastructure` 是長期定位,不是近期 build scope。 -- 近期產品承諾應維持為:用訪談產出可驗證、可修正、可攜帶的 persona archive。 - -### 8.2 下一個實作順序 - -1. **Snapshot 薄切片** - - 從現有 anchors / triples 產出 SOUL-lite hypothesis。 - - 每條結論要附 provenance、confidence、以及「需要再訪談」標記。 - - 不能宣稱 30 分鐘就準,只能宣稱產出可驗證草稿。 -2. **Mini Blind Test** - - 用 SOUL-lite / VOICE-lite 產出少量候選回覆。 - - 讓本人做「哪個像我」或「這不像我」標記。 - - 把結果回寫成下一輪訪談 TODO,而不是直接當最終評分。 -3. **不像我 Feedback Routing** - - 使用者說「這不像我」時,要映射到 dimension / anchor / decision target。 - - 這是主幹,不是 UX 附屬功能。 -4. **v2 Altitude + Decision Targets** - - 在 v2 schema / selector 中補 `decision_targets` 與 answer altitude。 - - aphorism altitude 不能直接成為 anchor,但也不能直接丟棄;要標成 candidate 待驗證。 -5. **Feature-flagged v2** - - `VIRTUALME_INTERVIEW_ENGINE=v1|v2`。 - - v1 保持 production default;v2 只在 Snapshot / staging dogfood 成功後切。 - -### 8.3 明確暫緩 - -- 不做 cross-model adapter layer。 -- 不做 fidelity benchmark system 的完整版本。 -- 不擴題庫到 120 題。 -- 不把 Decision Style / Contradiction 做成獨立大子系統;先作為現有訪談與萃取 pipeline 的 metadata / rule refinement。 - -### 8.4 工程現況確認 - -- S4:有 markdown export,沒有 synthesis。 -- S6:有 responder skeleton,沒有通用 runtime。 -- v2 schema:足夠當第一步資料層,但需補 `decision_targets`、altitude、correction routing metadata。 -- STG-036:可在現有 `depth_evaluator.py` / `follow_up.py` / `bot.py` 增量實作;風險主要是 UX,不是工程不可行。 - ---- - -> 本檔是活文件。scout 答覆回來後,將結論回填到對應階段的「現況/風險」,並更新 §1.4 與 §0。 diff --git a/docs/architecture-demo-flow.md b/docs/architecture-demo-flow.md new file mode 100644 index 0000000..da53851 --- /dev/null +++ b/docs/architecture-demo-flow.md @@ -0,0 +1,91 @@ +# VirtualMe Architecture and Demo Flow + +This document is the short operator-facing map for explaining what is already running on `vm.2ch.tw` and what the demo proves. + +## Production Architecture + +```mermaid +flowchart LR + User[LINE user] -->|message| LINE[LINE Messaging API] + LINE -->|POST /webhook/line| Nginx[Nginx TLS host\nvm.2ch.tw] + Nginx -->|proxy_pass| App[FastAPI app\nuvicorn 127.0.0.1:8000\nsystemd virtualme.service] + + App --> Consent[Consent and BYOK gate] + Consent --> Reasoner[Interview reasoner\nquestion selector + follow-up rules] + Reasoner --> Guardrail[Guardrail\nrefusal, fatigue, probe cap] + Guardrail --> Store[(SQLite\nsessions, turns, anchors,\ntransport events)] + + Store --> Export[Persona archive export\n8 dimension Markdown files] + Store --> Snapshot[Snapshot and review draft\nbehavior profile + blind test material] + Export --> Download[Tokenized download URL\n/download/persona/{token}] + Download --> User + + App -. health .-> Health[/GET /healthz/] +``` + +## What The Current Demo Shows + +1. A real LINE user can talk to the deployed bot at `vm.2ch.tw`. +2. The app persists interview state locally in SQLite: sessions, turns, anchors, and transport idempotency events. +3. The interview engine does more than batch prompting: it tracks the current question, chooses follow-ups, evaluates depth, and stores source-backed anchors. +4. Export paths can generate human-editable persona files and review artifacts instead of locking the user into a platform. +5. v1.1 adds Constitution detector gates for state/trait separation, reflective restraint, self-correction, and multi-session validation. M2 will wire those detectors into runtime promotion/export decisions. + +## Demo Script + +### 1. Health Check + +```bash +curl https://vm.2ch.tw/healthz +``` + +Expected shape: + +```json +{"ok":"true","version":"1.1.0"} +``` + +### 2. LINE Interview + +Send a normal answer to the LINE bot. The expected behavior is: + +- the bot treats the message as an interview turn, not a one-shot prompt; +- it either asks a grounded follow-up or advances to the next selected question; +- it records the turn and any extracted anchors in SQLite; +- explicit refusal, fatigue, or probe-cap cases are routed through `Guardrail`. + +### 3. Persona Export Request + +Ask in LINE for a profile export, using a phrase such as: + +```text +請匯出人格檔 +``` + +Expected behavior: + +- the bot checks consent, BYOK, and maturity conditions; +- it reports progress before export; +- it generates an 8-dimension persona archive; +- it returns a tokenized download URL rather than exposing raw server paths. + +### 4. Review / Blind-Test Loop + +Use the generated snapshot or blind-test material to answer the real product question: + +```text +這個輸出像不像本人?哪一句不像?缺哪個反例? +``` + +The important signal is not whether the model sounds polished. The important signal is whether the pipeline can preserve evidence, uncertainty, correction routes, and user ownership while improving fidelity over multiple sessions. + +## Operator Notes + +- Production service: `virtualme.service` +- Production repo: `/home/virtualme/VirtualMe` +- Public host: `https://vm.2ch.tw` +- App port: `127.0.0.1:8000` behind nginx +- Health endpoint: `/healthz` +- LINE webhook endpoint: `/webhook/line` +- Runtime data: SQLite under the configured `DATABASE_URL` +- Before deployment, back up the SQLite database and verify migrations are idempotent. diff --git a/docs/engineering-review-2026-05-24.md b/docs/engineering-review-2026-05-24.md new file mode 100644 index 0000000..cab65db --- /dev/null +++ b/docs/engineering-review-2026-05-24.md @@ -0,0 +1,474 @@ +# VirtualMe 工程建議 Review + +Date: 2026-05-24 +Reviewer: Codex +Scope: repo engineering quality, production dogfood readiness, safety/runtime completeness, deployment and maintenance maturity. + +## Executive Summary + +VirtualMe 目前不是「批次 prompt 腳本」,而是一個已具備 production dogfood 形態的訪談式人格萃取系統。它已經包含 LINE Bot 入口、FastAPI runtime、SQLite 狀態層、訪談 turn 狀態管理、question selector、follow-up / depth evaluator、PII scrub、anchors、persona export、download token、snapshot / blind-test artifact、BYOK / consent gate,以及一組可觀的 contract tests。 + +工程成熟度我會評為: + +> L2.5 / L5: 可 production dogfood,有真實部署與測試,但還不是成熟產品級開源專案。 + +最主要的缺口不是「能不能跑」,而是: + +1. safety / governance rules 還有一部分停在 detector + contract test,尚未全部 wire 到 runtime export / promotion path; +2. deployment 與 rollback 還偏手動; +3. observability / operator tooling 還薄; +4. mypy strict 設定與實際型別健康不一致; +5. docs 還需要更誠實地標示 Implemented / Dogfood / Roadmap。 + +如果目標是把 repo 完善成一個站得住的開源專案,下一階段不應該急著加大功能,而應該把 runtime invariants、部署可重複性、資料安全、觀測、dogfood 評估閉環補齊。 + +## Current State + +### 已具備的工程基礎 + +- Production endpoint: `https://vm.2ch.tw` +- Deployment: VPS `149.28.17.35`, nginx + systemd `virtualme.service` + uvicorn on `127.0.0.1:8000` +- Runtime version: v1.1.0 deployed at commit `e69af45` +- Health endpoint: `/healthz` +- Main transport: LINE webhook `/webhook/line` +- Persistence: SQLite, including sessions, turns, anchors, transport events, persona download tokens/logs +- Test baseline: `pytest` 336 passed at time of review +- Lint baseline: `ruff check .` clean at time of review +- Real dogfood data exists: one production SQLite DB has accumulated turns and anchors + +### 已完成的 product / platform pieces + +- LINE Bot interview path +- BYOK and consent gate +- Current question tracking +- Follow-up logic and depth evaluator +- Transport idempotency +- PII scrubbing at export boundary +- Persona markdown export +- Tokenized download URL +- Snapshot and behavior profile renderer +- Blind-test preparation path +- Constitution v1.1 detector gates: + - P1 State-Trait Separation + - P3 Reflective Restraint + - P4 Multi-Session Validation + - P5 Self-Correction & Agency + +### 目前最重要的事實 + +README 已經誠實標示:v1.1.0 的 hard gates 是 detectors + contract tests,M2 才會 wire into `build_snapshot_bundle` / export pipeline。這是好的,但它也代表 production runtime 還不能宣稱完整落實 Constitution。 + +## Maturity Model + +### L1: Prototype + +只要能透過 CLI / script 產生初步 persona artifact 即可。 + +VirtualMe 已超過此階段。 + +### L2: Functional PoC + +具備一條端到端流程: + +- 使用者輸入 +- LLM 訪談 +- 資料儲存 +- 初步 persona export +- 基本測試 + +VirtualMe 已達成。 + +### L3: Production Dogfood + +具備真實部署、真實資料、可重啟服務、基本 health check、基本回歸測試、手動維運流程。 + +VirtualMe 目前大致在此階段,但 deployment / smoke / rollback 還需要文件化和 script 化。 + +### L4: Trustworthy Open-Source Project + +這是我建議的下一個目標。 + +需要補齊: + +- docs 誠實呈現成熟度與限制 +- runtime safety gates 真正接入核心流程 +- CI gate 穩定 +- deployment 可重複 +- migration / backup / rollback 有流程 +- production observability 可用 +- dogfood review 有閉環 + +### L5: Small-Scale Product + +可以讓非熟人使用,並承擔基本資料安全、錯誤恢復、多使用者邊界、支援與升級責任。 + +VirtualMe 還沒到這裡,也不需要短期追這個目標。 + +## Engineering Strengths + +### 1. 不是一次性 prompt wrapper + +系統已經有以下長期狀態: + +- sessions +- turns +- anchors +- question state +- transport events +- export tokens + +這代表它不是把一段 prompt 丟給 LLM 的批次工具,而是有訪談流程、狀態機、資料層與 export boundary 的 pipeline。 + +### 2. 測試密度相對 PoC 來說很好 + +336 個 pytest 通過,覆蓋範圍包含: + +- LINE transport +- BYOK +- commands +- session lifecycle +- question selector +- PII scrub +- export +- download tokens +- schema migrations +- Constitution M1 detector tests + +這對個人開源 PoC 來說已經相當扎實。 + +### 3. 有 production dogfood + +系統已部署在 VPS,且有真實 turns / anchors。這點非常重要,因為很多 AI agent repo 停在 notebook / demo script;VirtualMe 已經跨過「只在本機跑」的門檻。 + +### 4. 治理問題被正面處理 + +Constitution v1.1 把 personality extraction 的核心風險寫進規格: + +- transient state 不可當 stable trait +- single-session 不可 validated +- fatigue / refusal / trauma context 需要 restraint +- unlike_me feedback 需要阻止 promotion +- export wording 需要 hedge + +方向是對的。下一步是 runtime enforcement。 + +## Engineering Weaknesses + +### 1. Runtime safety 尚未完整落地 + +目前 P1/P3/P4/P5 的一部分仍是 detector helpers 與 contract tests。 + +最明顯的缺口: + +- `export/markdown.py` 的 Core Truths 還主要依賴 `anchor.triangulated` +- `DB.save_anchor` 仍以 unique `question_id >= 3` 標記 triangulated +- P4 multi-session validation helper 尚未完整接入 promotion/export +- hedge validator 尚未作為 export/snapshot blocking gate + +這會導致文件層已經要求「single-session 不得 validated」,但 runtime 仍可能把同 session 多題的結果呈現得太穩定。 + +### 2. `triangulated` 和 `validated` 概念仍混雜 + +目前系統裡 `triangulated=True` 比較像「跨 question 重複出現」,不是「跨 session 被驗證」。這兩者需要拆開。 + +建議未來明確建模: + +- `observed`: 單一 turn / 單一情境 +- `recurring`: 多 question 或多情境重複 +- `cross_session`: 跨 session 出現 +- `validated`: 達成 validation policy,可進穩定 persona surface + +### 3. Deployment 仍偏手動 + +這次 production upgrade 是手動流程: + +- SSH +- copy DB backup +- git fetch/pull +- pip install +- init/migrate DB +- systemctl restart +- curl health check + +流程正確,但尚未 script 化。下一次如果由不同 agent 或未來的自己操作,風險仍偏高。 + +### 4. Observability 不足 + +目前主要依靠: + +- `/healthz` +- `journalctl` +- nginx access log + +缺少: + +- structured logs +- request correlation id +- LLM latency +- export failure rate +- webhook success/failure counters +- DB locked / migration / token download event visibility + +### 5. mypy strict 與實際狀態不一致 + +`pyproject.toml` 設定 mypy strict,但全 repo 實際存在大量 type errors。這會降低工程訊號可信度。 + +建議不要假裝全 repo strict。短期應改為 scoped mypy,例如只檢查新模組或核心 modules,再逐步擴張。 + +### 6. Docs 的成熟度標示還可以更清楚 + +README 很有敘事力,但對開源 reviewer 來說,還需要更清楚的狀態矩陣: + +- 已完成 +- production dogfood 中 +- detector-only +- roadmap +- known limitations + +這不是降低氣勢,而是提高可信度。 + +## Priority Roadmap + +## P0 - Repo Hygiene and Truthfulness + +Goal: 讓 repo 狀態與 README / docs / production 一致。 + +Checklist: + +- [ ] 補 `docs/maturity.md` +- [ ] README 加上 `Implemented / Dogfood / Roadmap / Known limitations` +- [ ] `docs/architecture-demo-flow.md` 補 `Current vs Planned` +- [ ] 統一 version source,避免 `pyproject.toml`、`__version__`、`/healthz` 分裂 +- [ ] 清理或忽略本機未追蹤 `.tmp-audit-bundle.txt` +- [ ] 確認 diagrams / docs 都可在 GitHub 上正常閱讀 + +Acceptance criteria: + +- 新讀者 5 分鐘內可以知道哪些已經可跑,哪些只是 roadmap。 +- `git status` clean,沒有奇怪未追蹤 artifact。 +- `/healthz` 顯示 version 與 repo tag / commit 可追溯。 + +## P1 - Runtime Constitution Gates + +Goal: 把 Constitution 從 detector 變成 runtime invariant。 + +Checklist: + +- [ ] Core Truths export 不再只看 `triangulated=True` +- [ ] `single-session` evidence 不得進 validated / stable trait surface +- [ ] `Dimension.STATE` 不得 render 到 SOUL/VOICE/SKILL/BOUNDARIES Core Truths +- [ ] export / snapshot 產物跑 hedge validator +- [ ] hedge violation 可 fail export,或至少降級 confidence 並標示 warning +- [ ] `unlike_me` review 寫回 correction / block promotion path +- [ ] 每個 claim 附 provenance + confidence tier +- [ ] 新增 regression fixture: + - 最近很累 + - 同 session 三題重複 + - explicit refusal + - fatigue + - unlike_me + - high-risk / trauma context + +Acceptance criteria: + +- 單一 session 即使有三個 question ids,也不會被 render 為 validated Core Truth。 +- export 裡不會出現 unhedged stable-trait assertion。 +- unlike_me 後同一 claim 不會再次被 promotion。 + +## P2 - Deployment and Rollback + +Goal: production upgrade 可重複、可回滾、可交接。 + +Checklist: + +- [ ] 新增 `scripts/deploy_vps.sh` 或 `docs/deploy-vps.md` +- [ ] deploy flow 固化: + - preflight git status + - DB backup + - fetch / fast-forward only + - pip install + - migration + - restart + - smoke test + - log tail +- [ ] 新增 rollback runbook +- [ ] DB backup 命名含 commit + timestamp +- [ ] restore drill 至少跑一次 +- [ ] deploy 後自動確認 `/healthz` version / commit + +Acceptance criteria: + +- 任何 agent 按文件可以在 10 分鐘內安全部署。 +- 部署失敗時知道如何回到上一版 code + DB backup。 + +## P3 - CI and Quality Gates + +Goal: GitHub 上的品質訊號可靠。 + +Checklist: + +- [ ] GitHub Actions 跑 `ruff check .` +- [ ] GitHub Actions 跑 `pytest` +- [ ] migration tests 納入 CI +- [ ] mypy 改為 scoped gate,不再宣稱全 repo strict +- [ ] 加 production smoke test script,但不在 public CI 打 real production +- [ ] 加最小 e2e fixture: + - create DB + - simulate 3-5 turns + - generate anchors + - export persona archive + - create download token + - resolve token + +Acceptance criteria: + +- PR 上能看到 lint/test/migration 狀態。 +- main branch 不會在 ruff 或 pytest 紅的情況下繼續發展。 + +## P4 - Observability and Operator Tooling + +Goal: 問題發生時能快速知道是 LINE、LLM、DB、export、download token 還是 user state。 + +Checklist: + +- [ ] structured log schema +- [ ] 不記 raw API key / secrets / PII +- [ ] event types: + - webhook_received + - signature_invalid + - turn_started + - turn_completed + - llm_failed + - anchor_saved + - export_requested + - export_denied + - export_completed + - token_created + - token_downloaded +- [ ] operator CLI: + - `virtualme status ` + - `virtualme latest-errors` + - `virtualme export ` + - `virtualme tokens ` +- [ ] `/healthz` 增強: + - version + - commit + - db reachable + - schema tables present + - uptime or started_at + +Acceptance criteria: + +- 看到一次使用者說「bot 沒回」,可以在 5 分鐘內定位在哪個 layer。 + +## P5 - Dogfood Evaluation Loop + +Goal: repo 的核心問題不是「能不能產生文字」,而是「像不像本人,且能不能被修正」。 + +Checklist: + +- [ ] 設計 5 人 dogfood review template +- [ ] 每人至少一次 export + review +- [ ] 收集: + - like_me claims + - unlike_me claims + - missing context + - too strong wording + - privacy discomfort + - useful surprise +- [ ] unlike_me / missing_context 能回寫 correction layer +- [ ] Week 5 / Week 8 blind test protocol 至少各跑一次 +- [ ] 形成 `docs/dogfood-results-template.md` + +Acceptance criteria: + +- 至少 5 個 dogfood case 能產生具體修正,而不是只收「好像不錯」。 +- 可以描述 VirtualMe 最常錯在哪裡。 + +## P6 - Data Safety and Threat Model + +Goal: 因為 VirtualMe 處理的是個人訪談、人格檔、BYOK,資料安全要比一般 demo 更嚴格。 + +Checklist: + +- [ ] threat model +- [ ] BYOK key storage audit +- [ ] download token security review +- [ ] hard delete runbook +- [ ] export archive PII review +- [ ] logs secret/PII scan +- [ ] LINE user id boundary review +- [ ] rate limit / abuse handling +- [ ] backup encryption policy + +Acceptance criteria: + +- 可以清楚回答: + - 使用者資料在哪裡? + - 如何刪除? + - key 如何撤銷? + - download URL 洩漏時風險多大? + - logs 會不會含敏感內容? + +## Suggested Next 10 Tasks + +1. Create `docs/maturity.md`. +2. Add commit SHA and DB schema status to `/healthz`. +3. Write `docs/deploy-vps.md` from the deployment performed on 2026-05-24. +4. Add production smoke script: healthz, invalid LINE signature, DB table presence. +5. Implement M2 runtime gate for Core Truths export. +6. Add hedge validator to export/snapshot output checks. +7. Split `triangulated` vs `validated` semantics. +8. Add scoped GitHub Actions: ruff + pytest. +9. Add dogfood review template. +10. Decide whether `.tmp-audit-bundle.txt` should be removed, ignored, or moved outside repo. + +## Technical Completeness Scorecard + +| Area | Score | Notes | +|---|---:|---| +| Core interview flow | 7/10 | Real LINE dogfood exists; flow has state and extraction. | +| Persistence model | 7/10 | SQLite is appropriate for dogfood; schema migrations exist. | +| Persona export | 7/10 | Useful archive + download token exists; runtime safety gate needs M2. | +| Safety/governance | 6/10 | Strong specs and detector tests; runtime enforcement incomplete. | +| Test coverage | 8/10 | 336 tests is strong for this phase. | +| Type health | 3/10 | mypy strict config does not match actual state. | +| Deployment | 5/10 | Production exists; deploy is still manual. | +| Observability | 4/10 | healthz + journalctl only; structured app telemetry missing. | +| Documentation | 6/10 | Strong narrative and specs; needs maturity/status matrix. | +| Open-source readiness | 6/10 | Usable and interesting; needs CI, runbooks, clearer limitations. | + +Overall: 6.2/10 for an open-source production dogfood project. The project is well past a toy demo, but the next work should be mostly engineering hardening rather than feature expansion. + +## Recommended Positioning + +The most accurate technical positioning is: + +> VirtualMe is a local-first, interview-driven persona extraction pipeline with a production dogfood LINE Bot. It is designed to generate user-owned persona artifacts from multi-session interviews, with privacy, provenance, correction, and validation as first-class engineering concerns. + +Avoid claiming: + +- mature SaaS +- fully validated AI twin +- complete runtime safety enforcement +- production-grade multi-user platform + +Safe claims: + +- production dogfood +- open-source pipeline +- LINE Bot + FastAPI + SQLite +- persona archive export +- safety detector gates +- blind-test oriented workflow +- user-owned markdown artifacts + +## Closing Recommendation + +Do not rush toward more visible features until the following three are done: + +1. Runtime Constitution gates. +2. Deploy / rollback / smoke-test runbook. +3. Maturity docs and CI. + +These three will make the repo much harder to dismiss and, more importantly, much easier for future Maki / Claude / Codex / Gemini sessions to improve without accumulating hidden risk. diff --git a/docs/research/virtualme-domain-pack-8-fields.md b/docs/research/virtualme-domain-pack-8-fields.md deleted file mode 100644 index 8679de9..0000000 --- a/docs/research/virtualme-domain-pack-8-fields.md +++ /dev/null @@ -1,1571 +0,0 @@ -# VirtualMe Domain Pack v1.0 - -> **定位說明**:本文件是 VirtualMe 泛用八大維度(SOUL / VOICE / SKILL / PEOPLE / HISTORY / JOURNAL / BOUNDARIES / STATE)的**領域化補充包**,不重複基礎題庫。 -> 每個 Domain Pack 只針對 SKILL、PEOPLE、VOICE、BOUNDARIES 四個維度做領域語境強化,並補充「反感問法警示」與「高價值 persona anchor 範例」。 - -*** - -## 使用規範 - -- Domain Pack 在基礎訪談完成後疊加使用,或依職涯背景優先注入對應包 -- `persona_anchor` 欄位的句子可直接寫入 persona 儲存層 -- YAML 轉換時,每題結構保持 `id / q / purpose / anchor / follow_up_limit / stop_condition / aversion_risk` -- VOICE roleplay 場景請搭配 LLM 角色扮演模式,讓受訪者「打一段訊息」而非描述 - -*** - -## 1. Engineer / AI Builder - -### domain_meta - -```yaml -domain_role: - - 軟體工程師、AI 工程師、ML 工程師、系統架構師、Full-Stack 開發者 -core_task: - - 需求拆解與技術選型 - - 設計與實作系統架構 - - Debug、效能調優、技術債取捨 - - 評估 AI/模型方案可行性 -primary_counterparty: - - PM、設計師、QA、其他工程師 -decision_partner: - - Tech Lead、架構師、CTO、資深工程師 -``` - -*** - -### SKILL 領域化問題(8 題) - -#### Q1 — 技術選型判斷 -**問題**:「你最近一次需要在兩個以上技術方案中做選擇,你當時怎麼比較?最後是什麼讓你拍板?」 -- **目的**:萃取決策框架——是靠直覺、經驗、文件驗證還是小 PoC;是否考量團隊能力與長期維護成本 -- **期待 anchor**:「遇到不確定時偏好先跑 spike 而非靠直覺」;「決策時把遷移成本排在前三考量」 -- **追問上限**:2 次(追問「當時你怎麼知道這個選型是對的?」) -- **停止條件**:受訪者說出具體的取捨標準或決策觸發點後停止 -- **反感風險**:低——工程師習慣講技術邏輯,此題通常能打開話匣子 - -#### Q2 — Debug 行為模式 -**問題**:「描述一個讓你卡最久的 bug,你從什麼地方開始找起?」 -- **目的**:觀察系統思維深度——是否分層假設、是否會先縮小 scope、遇到死路如何轉向 -- **期待 anchor**:「傾向先寫復現腳本而不是直接翻 code」;「習慣先排除環境因素再看邏輯」 -- **追問上限**:2 次(「你當時有沒有想過問別人,是什麼讓你決定繼續自己找?」) -- **停止條件**:取得「何時放棄 solo 轉向求助」的行為邊界 -- **反感風險**:極低 - -#### Q3 — 技術債取捨 -**問題**:「你有沒有做過一個決定,明知道現在這樣寫是欠債,但還是先這樣?你怎麼跟自己交代?」 -- **目的**:萃取對品質與速度的真實排序;是否能坦然承認取捨 -- **期待 anchor**:「只要有明確的還債時間點,技術債可以接受」;「不記錄的技術債比技術債本身更危險」 -- **追問上限**:1 次(「後來這筆債還了嗎?還是就留著了?」) -- **停止條件**:受訪者說出自己的「可接受技術債條件」 -- **反感風險**:中——若語氣帶有評判感易引起防衛,問法需中性 - -#### Q4 — AI/模型落地判斷 -**問題**:「你評估過一個 AI 方案不適合在現在的產品裡用,你當時的理由是什麼?」 -- **目的**:測試是否只追新技術,還是能判斷 AI 落地條件(資料、延遲、解釋性、維護成本) -- **期待 anchor**:「AI 方案上線前必須有可觀測的回退機制」;「eval 不過不上 production」 -- **追問上限**:2 次 -- **停止條件**:取得具體否決條件 -- **反感風險**:低 - -#### Q5 — Code Review 風格 -**問題**:「你給別人 code review 的時候,你最在意的是什麼?有沒有你幾乎每次都會提的點?」 -- **目的**:萃取工程價值觀——偏重可讀性、正確性、效能、一致性還是安全 -- **期待 anchor**:「review 時最先看錯誤處理,而不是算法是否最優」;「不接受沒有測試的新邏輯進主幹」 -- **追問上限**:1 次 -- **停止條件**:取得至少一條明確的 review 原則 -- **反感風險**:極低 - -#### Q6 — 文件與溝通習慣 -**問題**:「你最近一次需要讓非工程師理解一個技術問題,你用什麼方式說明的?」 -- **目的**:測試跨功能溝通能力;是否有意識地調整抽象層次 -- **期待 anchor**:「遇到非技術對象習慣先問他們最在意的是什麼,再決定從哪個角度講」 -- **追問上限**:1 次(「對方理解了嗎?你怎麼確認的?」) -- **停止條件**:取得受訪者的溝通轉換策略 -- **反感風險**:低 - -#### Q7 — 自主學習邊界 -**問題**:「你是怎麼決定一個技術值不值得你花時間深入學?你有沒有放棄過某個你原本很想學的東西?」 -- **目的**:萃取學習投資邏輯——是否以實戰觸發學習,還是廣泛探索;是否能放棄 -- **期待 anchor**:「沒有真實使用場景的技術,學到能看懂就停」;「偏好先動手做壞,再回頭補理論」 -- **追問上限**:1 次 -- **停止條件**:取得「值得深學 vs 淺嚐」的判斷標準 -- **反感風險**:低 - -#### Q8 — 上線與風險容忍 -**問題**:「你有沒有在不是百分之百確定的情況下 deploy 過?那次你怎麼評估風險?」 -- **目的**:萃取對不確定性的耐受度與風險管理習慣(canary、rollback plan、feature flag) -- **期待 anchor**:「沒有 rollback 計畫不上線」;「小流量灰度是預設,不是奢侈品」 -- **追問上限**:2 次 -- **停止條件**:取得具體的上線前檢查清單或決策標準 -- **反感風險**:低 - -*** - -### PEOPLE 領域化問題(5 題) - -#### P1 — 技術影響力 -**問題**:「你有沒有推過一個大家原本不想接受的技術方向,最後成功了?你怎麼讓人信服的?」 -- **信號**:影響策略——靠數據、PoC、一對一說服還是公開展示 - -#### P2 — 技術衝突 -**問題**:「你和某個工程師對實作方式有根本分歧,兩個人都覺得對方的方案有問題,最後怎麼收的?」 -- **信號**:衝突解法——技術中立評估、訴諸 lead、各退一步、硬幹看誰對 - -#### P3 — 協作信任邊界 -**問題**:「你有沒有不太願意和某種工程師合作?是什麼行為讓你有這種感覺?」 -- **信號**:合作底線——是否容忍 blame、不寫文件、只管自己的 scope - -#### P4 — 非工程師協作 -**問題**:「PM 或設計師提出你認為技術上不可行或不值得做的需求,你通常怎麼反應?」 -- **信號**:跨職能邊界管理;是否會包含理由、是否願意找替代方案 - -#### P5 — 帶新人/傳承 -**問題**:「你帶過新人 onboard 嗎?你覺得讓新人最快進入狀況的方法是什麼?」 -- **信號**:知識傳遞偏好——靠文件、靠 pairing、靠任務下水;是否享受帶人 - -*** - -### VOICE Roleplay 場景(5 題) - -> 指示語:「請直接打一段你會傳給對方的訊息,不用解釋你在幹嘛。」 - -#### V1 — 正式技術同步 -**場景**:你需要向 PM 說明下週 sprint 有一個功能來不及,但你已有解法。請打一段 Slack 訊息。 -- **萃取目標**:主動程度、是否帶替代方案、是否預先管理期望 - -#### V2 — 拒絕不合理需求 -**場景**:PM 要你在沒有任何設計稿的情況下,明天 demo 前先做一個「先做 UI 再說」版本。 -- **萃取目標**:拒絕方式、是否帶條件、語氣是否有邊界感 - -#### V3 — 推進停滯的 PR -**場景**:你的 PR 已開了五天,負責 review 的人一直沒空,但你需要這個 merge 才能繼續。 -- **萃取目標**:催促策略——直接問、找主管、升級還是等;主動性程度 - -#### V4 — 技術意見衝突 -**場景**:你提議用 event-driven 架構,Tech Lead 在群組裡說「我不覺得現在有必要搞這麼複雜」。 -- **萃取目標**:在公開場合維護技術立場的方式;是否有能力不情緒化地辯護 - -#### V5 — 線上事故修復後 -**場景**:你造成了一個生產環境事故,影響了 30 分鐘,剛剛修好。請打一段給 stakeholder 的更新。 -- **萃取目標**:責任感語氣、是否主動說明根因、是否帶後續防範措施 - -*** - -### BOUNDARIES 領域化問題(5 題) - -#### B1 — 技術風險授權邊界 -**問題**:「什麼程度的技術風險你可以自己拍板,超過什麼程度你一定要拉人進來討論?」 -- **信號**:自主決策的邊界感——是否基於影響範圍、是否有明確的升級觸發點 - -#### B2 — 不可接受的工作方式 -**問題**:「有沒有一種工作環境或合作模式,讓你覺得你的產出會變得很差?是什麼樣的條件?」 -- **信號**:高效能工作的必要條件;是否有自我認知 - -#### B3 — on-call 與邊界 -**問題**:「你怎麼看待 on-call 或半夜被叫起來 debug?你有沒有自己的底線?」 -- **信號**:可持續性邊界;是否能清楚說出不可接受條件 - -#### B4 — 責任範圍認知 -**問題**:「有沒有一種情況,你明明可以動手修,但你選擇不動,讓負責的人去處理?為什麼?」 -- **信號**:責任邊界——是否尊重 ownership;是否有「越界幫忙」的傾向或相反 - -#### B5 — 拒絕交付條件 -**問題**:「有沒有一種需求,你接了之後做到一半發現你不應該繼續做,你後來怎麼處理?」 -- **信號**:中途停止的勇氣與判斷——是否能在承諾後重新談判 - -*** - -### 反感問法警示(工程師) - -| 反感問法 | 為什麼反感 | 替代問法 | -|----------|-----------|----------| -| 「你覺得自己的技術能力在團隊裡算幾分?」 | 自我評分缺乏錨點,且有表演成分 | 「你有沒有一個技術領域,覺得自己懂得比多數人深一些?是怎麼建立起來的?」 | -| 「你喜歡寫文件嗎?」 | 封閉題,幾乎所有人都說「還好」 | 「你最近有沒有寫過一份你覺得寫得不錯的文件?它解決了什麼問題?」 | -| 「你覺得 AI 會取代工程師嗎?」 | 太抽象,答案偏向場面話 | 「你在工作中有沒有把 AI 工具整合進你的開發流程?哪個環節效果最明顯?」 | -| 「你有多少年的 X 經驗?」 | 年資不等於能力,易觸發工程師防衛 | 「你是什麼情況下第一次深入用 X?你現在怎麼評價當時的理解?」 | -| 「你是 T 型人才還是 I 型人才?」 | 要求自我貼標籤,且是老套分類 | 「除了你主力的技術棧,你有沒有另一個你意外地懂得不少的領域?是怎麼發展出來的?」 | - -*** - -### 高價值 Persona Anchors(Engineer / AI Builder) - -1. 遇到技術選型時,偏好先跑小規模 PoC 驗証,而不是靠文件或直覺決定。 -2. 對沒有 rollback 計畫的 production deploy 有強烈阻力,會主動要求補上。 -3. Code review 時最先看錯誤處理與邊界條件,而不是算法效率。 -4. 技術債只要有明確的還債時間點就能接受,但不記錄的技術債是無法接受的。 -5. 喜歡透過動手做壞來學習,而不是先把理論讀完再開始。 -6. 跟非技術對象溝通時,習慣先問對方最在意的結果是什麼,再決定從哪個抽象層次解釋。 -7. 對 AI 落地判斷嚴謹,不上沒有可觀測回退機制的方案。 -8. 評估技術值不值得深入學的標準是:有沒有真實的使用場景。 -9. 在技術意見衝突時,偏好用數據或實驗結果說話,而不是靠職位或資歷壓制。 -10. 對「先做再說」的上線哲學有抵制,但在有灰度流量控制的前提下可以接受快速驗證。 -11. 寫任何系統設計前會先確認「誰來維護這段 code 五年後」。 -12. 習慣在 PR 描述裡主動說明「這個選擇的替代方案是什麼,為什麼沒選」。 - -*** - -## 2. Sales / BD - -### domain_meta - -```yaml -domain_role: - - 業務代表、客戶主管、商業開發、通路經理、Key Account Manager -core_task: - - 發掘潛在客戶與建立管道 - - 提案、談判、關單 - - 客戶關係維護與擴展 - - 跨部門協調(技術、法務、財務)以推進交易 -primary_counterparty: - - 客戶(採購、使用者、C-level)、合作夥伴 -decision_partner: - - 業務主管、售前工程師、法務、PM -``` - -*** - -### SKILL 領域化問題(8 題) - -#### Q1 — 客戶資格判斷 -**問題**:「你怎麼判斷一個新進來的潛在客戶值不值得花時間?你通常問哪幾個問題就能判斷?」 -- **目的**:萃取 qualification 邏輯——是否有明確的 BANT 或自有框架;是否容易被熱情客戶說服 -- **期待 anchor**:「一定要先確認預算擁有者在不在這個對話裡」;「三通電話之後沒有進展的機會就冷掉」 -- **追問上限**:2 次 -- **停止條件**:取得至少三個 qualification 指標 -- **反感風險**:低 - -#### Q2 — 提案客製化程度 -**問題**:「你最近一份讓自己滿意的提案,你花最多時間在哪個部分?」 -- **目的**:萃取準備深度——是否真的做客戶研究,還是套模板;投入程度是否與機會大小成比例 -- **期待 anchor**:「每份提案都要有一句只有這個客戶才聽懂的話」 -- **追問上限**:1 次 -- **停止條件**:取得具體準備步驟 -- **反感風險**:極低 - -#### Q3 — 談判策略 -**問題**:「客戶要求降價但你不想動,你通常怎麼回應?」 -- **目的**:萃取談判風格——讓步優先 vs. 守價值;是否有換牌桌策略 -- **期待 anchor**:「不直接讓價,而是調整 scope」;「先問客戶哪個部分讓他覺得貴,再針對那個點解構」 -- **追問上限**:2 次 -- **停止條件**:取得具體的談判策略或一句常說的話 -- **反感風險**:低 - -#### Q4 — 失單分析 -**問題**:「你最近輸掉的一個案子,你事後怎麼看?你覺得是哪個環節可以不一樣?」 -- **目的**:萃取自我反思能力——是否會歸因到自身行為,還是都推給外部因素 -- **期待 anchor**:「輸單後一定要打一通電話問對方為什麼選了競品」 -- **追問上限**:1 次 -- **停止條件**:取得至少一個自我歸因的點 -- **反感風險**:中(觸碰挫折,需語氣中立) - -#### Q5 — 管道管理紀律 -**問題**:「你怎麼管你的 pipeline?你多久更新一次,你用什麼判斷哪個機會該推進、哪個該放棄?」 -- **目的**:萃取銷售紀律——是否系統化;是否有放棄標準(否則會出現殭屍 pipeline) -- **期待 anchor**:「超過 X 週沒有進展的機會自動降低優先序」;「每週五花 30 分鐘更新 CRM 是不可省的儀式」 -- **追問上限**:1 次 -- **停止條件**:取得明確的管理節奏 -- **反感風險**:低 - -#### Q6 — 跨部門協調能力 -**問題**:「你有沒有遇過因為內部問題(技術評估太慢、法務卡住合約)而差點讓客戶跑掉的情況?你怎麼處理?」 -- **目的**:萃取內部協調風格——是否能有效調動資源;是否善於用客戶壓力推進內部 -- **期待 anchor**:「遇到內部卡關,我習慣先給對方一個明確的時間線,再內部去扛那個承諾」 -- **追問上限**:2 次 -- **停止條件**:取得一個具體的協調策略 -- **反感風險**:低 - -#### Q7 — 客戶關係維護 -**問題**:「你有沒有一個客戶關係,是在沒有活躍機會的時候還是維持得很好的?你靠什麼維持?」 -- **目的**:萃取長期關係投資策略——是否有意識地建立非交易性連結 -- **期待 anchor**:「每季至少一次不帶目的的 check-in」;「記住客戶的業務挑戰比記住他的生日更重要」 -- **追問上限**:1 次 -- **停止條件**:取得維護策略 -- **反感風險**:極低 - -#### Q8 — 競品處理 -**問題**:「客戶說他也在評估你的競品,你通常怎麼應對?你會主動提競品嗎?」 -- **目的**:萃取競爭策略——是否能主動駕馭競品比較,還是迴避;是否了解自身差異化 -- **期待 anchor**:「主動提競品,用自己的語言框定比較維度」;「先問客戶目前用競品哪個地方讓他不滿意」 -- **追問上限**:2 次 -- **停止條件**:取得具體的競品對話策略 -- **反感風險**:低 - -*** - -### PEOPLE 領域化問題(5 題) - -#### P1 — 客戶信任建立 -**問題**:「你覺得一個客戶從把你當業務變成把你當顧問,是什麼時候發生轉折的?有沒有具體的例子?」 -- **信號**:信任建立策略——是靠專業、靠個人連結,還是靠關鍵時刻展現的誠信 - -#### P2 — 內部夥伴協作 -**問題**:「跟售前或技術顧問一起跑案子,你們之間的分工通常是怎麼劃的?有沒有摩擦過的時候?」 -- **信號**:是否尊重各自角色;衝突處理是否成熟 - -#### P3 — 影響力使用 -**問題**:「你有沒有在沒有直接決定權的情況下推動了一個決策?你用了什麼方法?」 -- **信號**:影響力來源——靠數據、靠關係、靠製造緊迫感、靠說故事 - -#### P4 — 難搞客戶 -**問題**:「你有沒有一個讓你壓力很大的客戶,但你最後還是完成了這個案子?那個客戶讓你壓力大的原因是什麼?」 -- **信號**:情緒調節能力;是否能拆解壓力來源並逐一應對 - -#### P5 — 離開的客戶 -**問題**:「有沒有一個客戶你沒辦法服務好、或者主動放棄了?當時是什麼情況?」 -- **信號**:客戶邊界管理;是否有能力辨識「不對的客戶」 - -*** - -### VOICE Roleplay 場景(5 題) - -#### V1 — 正式提案跟進 -**場景**:你上週做了一場提案,客戶說會討論後回覆,但已經過了五天還沒消息。請打一封跟進信。 -- **萃取目標**:主動程度;是否帶新的價值點;是否製造下一步行動 - -#### V2 — 拒絕不合理折扣 -**場景**:客戶說競品便宜 20%,要你 match,但你的產品不能跌到那個價。請打一段回應。 -- **萃取目標**:守價策略;是否能用價值而非情緒回應 - -#### V3 — 推進停滯中的案子 -**場景**:一個案子已經在「評估中」三個月了,你需要讓它重新動起來。請打一段訊息給對口聯絡人。 -- **萃取目標**:重啟策略;是否有製造行動錨點的能力 - -#### V4 — 內部升級衝突 -**場景**:你的對口聯絡人說他很支持,但他的老闆突然說「現在不是好時機」。請打一段給聯絡人的訊息。 -- **萃取目標**:是否能協助內部倡議者;是否有穿透到決策層的策略 - -#### V5 — 客戶抱怨修復 -**場景**:客戶剛上線,但遇到了一個你事前沒預警的問題,他傳訊息說「這不是我當初買的東西」。 -- **萃取目標**:危機處理語氣;是否先承認問題再解決;是否能穩住關係 - -*** - -### BOUNDARIES 領域化問題(5 題) - -#### B1 — 承諾邊界 -**問題**:「你有沒有過答應了客戶一個你自己沒辦法控制的事情?後來怎麼收?」 -- **信號**:是否有過度承諾傾向;是否能事後誠實修正承諾 - -#### B2 — 不接受的客戶類型 -**問題**:「你有沒有一種客戶,你接了之後每次開會都覺得很消耗?你怎麼描述那種客戶?」 -- **信號**:客戶邊界感;是否能清楚說出不對的客戶特徵 - -#### B3 — 道德邊界 -**問題**:「有沒有一種銷售方式或說話方式,你覺得你不會做,即使它可能很有效?」 -- **信號**:倫理邊界——是否有超過業績的底線 - -#### B4 — 內部資源使用邊界 -**問題**:「你怎麼判斷一個案子值得動用多少內部資源(技術評估、客製化、法務)?你有沒有拒絕過申請資源?」 -- **信號**:成本意識;是否有機會 ROI 的概念 - -#### B5 — 放棄標準 -**問題**:「你在什麼情況下會告訴自己:這個機會已經沒戲了,我要停止投入?」 -- **信號**:損失厭惡程度;是否有清楚的撤退標準而非一直耗著 - -*** - -### 反感問法警示(Sales / BD) - -| 反感問法 | 為什麼反感 | 替代問法 | -|----------|-----------|----------| -| 「你的業績達成率是多少?」 | 太直接切數字,像是在審查 | 「你最近一個季度,你覺得跑得最順的案子,是因為做對了什麼?」 | -| 「你喜歡被拒絕嗎?」 | 老套銷售面試題,受訪者會給罐頭答案 | 「你最近一次被客戶明確拒絕,你當時第一個反應是什麼?後來怎麼處理?」 | -| 「你有沒有 hunter 心態?」 | 要求自我貼標籤,且是老掉牙的分類 | 「你開發一個完全陌生的客戶,通常從哪裡開始?」 | -| 「你是關係型業務還是方案型業務?」 | 二元分類,迫使受訪者選一個 | 「你有沒有一個靠產品說服的客戶,和一個靠關係維繫的客戶,這兩種你更喜歡哪種互動方式?」 | -| 「你怎麼超越你的配額?」 | 預設他有達到,且讓人覺得在炫技 | 「你怎麼判斷一個月哪些機會應該全力衝,哪些先放著?」 | - -*** - -### 高價值 Persona Anchors(Sales / BD) - -1. 評估新機會時,會先確認預算擁有者是否在對話中,否則不進入深度提案模式。 -2. 跟進停滯中的案子時,習慣帶一個新的資訊或價值點,而不是純粹問「有什麼進展」。 -3. 面對降價壓力,偏好調整 scope 而不是直接讓價。 -4. 每份提案都會嵌入一句只有這個客戶才聽懂的話,以展示真正做過功課。 -5. 失單後會主動打一通電話給客戶,問清楚他為什麼選了競品。 -6. 認為記住客戶的業務挑戰比記住他的生日更重要。 -7. 傾向主動提起競品,用自己的語言框定比較維度,而不是等客戶提。 -8. 對「超過三週沒有進展的 pipeline 機會」有自動降優先序的機制。 -9. 危機處理時的第一反應是先承認問題的存在,而不是先解釋原因。 -10. 對「答應了自己控制不了的事」有強烈的後悔感,會試圖在對話當下就設定保護條款。 -11. 對消耗型客戶有明確的辨識指標,且願意主動放棄這類機會。 -12. 把把客戶當作顧問信任的關係視為最高成就,而不只是完成交易。 - -*** - -## 3. PM / TPM - -### domain_meta - -```yaml -domain_role: - - 產品經理、技術專案經理、程式產品經理、平台 PM -core_task: - - 需求收集與優先序定義 - - Roadmap 制定與溝通 - - 跨部門協調(工程、設計、業務、法務) - - 風險追蹤、進度管理、上線決策 -primary_counterparty: - - 工程師、設計師、業務、客戶代表 -decision_partner: - - 工程 Lead、設計 Lead、業務主管、資料分析師 -``` - -*** - -### SKILL 領域化問題(8 題) - -#### Q1 — 優先序決策 -**問題**:「你有沒有一次,你明知道某個需求技術難度低、業務很想要,但你還是決定不做或延後?你怎麼解釋那個決定?」 -- **目的**:萃取優先序框架——是否能抵抗壓力;是否有清楚的取捨邏輯 -- **期待 anchor**:「不在當期策略目標上的需求,即使容易做,也不進 roadmap」 -- **追問上限**:2 次 -- **停止條件**:取得具體的否決標準 -- **反感風險**:低 - -#### Q2 — 需求模糊處理 -**問題**:「你最近一次拿到的需求,你認為問題定義本身就是錯的,你怎麼處理?」 -- **目的**:萃取問題重構能力;是否能在不得罪對方的前提下改變方向 -- **期待 anchor**:「先把對方描述的症狀和他想要的解法分開,再重新問問題」 -- **追問上限**:2 次 -- **停止條件**:取得具體的重構步驟 -- **反感風險**:低 - -#### Q3 — 資源不足下的交付 -**問題**:「你有沒有在資源(人力、時間、技術)明顯不足的情況下,還是交付了一個可以接受的結果?你做了什麼取捨?」 -- **目的**:萃取 MVP 思維與資源運用能力 -- **期待 anchor**:「先定義 non-negotiable,其他的都可以討論縮範圍」 -- **追問上限**:1 次 -- **停止條件**:取得具體的取捨範例 -- **反感風險**:低 - -#### Q4 — 數據驅動決策 -**問題**:「你有沒有一個決定,是你原本有直覺,但數據告訴你不一樣,你最後選擇相信哪一個?」 -- **目的**:萃取直覺 vs. 數據的實際排序;是否能說出「相信數據的條件」 -- **期待 anchor**:「數據樣本太小時我不相信數據,我會先擴充樣本再決定」 -- **追問上限**:2 次 -- **停止條件**:取得受訪者的數據信任標準 -- **反感風險**:低 - -#### Q5 — 上線決策 -**問題**:「你有沒有在不確定情況下做過上線決定?你當時的邏輯是什麼?」 -- **目的**:萃取風險耐受度與回退計畫思維 -- **期待 anchor**:「上線前必須定義成功指標和失敗指標,才能知道要不要 rollback」 -- **追問上限**:2 次 -- **停止條件**:取得上線決策的評估框架 -- **反感風險**:低 - -#### Q6 — 跨部門衝突協調 -**問題**:「你有沒有遇過工程說做不到、業務說一定要做、你夾在中間的情況?你怎麼處理?」 -- **目的**:萃取三方衝突的協調風格——是否能區隔技術限制與意願問題;是否能創造雙方都接受的方案 -- **期待 anchor**:「把「不能做」和「現在不做」分開,再去找時間線的解法」 -- **追問上限**:2 次 -- **停止條件**:取得具體的協調策略 -- **反感風險**:低 - -#### Q7 — Roadmap 溝通 -**問題**:「你有沒有一次向 stakeholder 更新 roadmap 的結果,讓你覺得說得不夠清楚或沒達到效果?你後來改了什麼?」 -- **目的**:萃取溝通自我修正能力;是否有意識調整受眾框架 -- **期待 anchor**:「對 C-level 說 roadmap 要說商業影響,對工程說要說技術選型邏輯,是兩份不同的材料」 -- **追問上限**:1 次 -- **停止條件**:取得至少一個溝通策略改變 -- **反感風險**:低 - -#### Q8 — 失敗的 feature -**問題**:「你做過一個功能,上線後反應不好,你事後怎麼分析為什麼沒有成?」 -- **目的**:萃取反思深度——是否歸因到自己的驗證流程,還是推給市場;是否能說出「早期信號」 -- **期待 anchor**:「那個功能最大的問題是我在 build 之前沒有真的去問用戶,我問的是我們的業務」 -- **追問上限**:2 次 -- **停止條件**:取得自我歸因的反思 -- **反感風險**:低 - -*** - -### PEOPLE 領域化問題(5 題) - -#### P1 — 工程師關係 -**問題**:「你有沒有跟工程師建立過一種關係,讓他們願意在需求不清楚的時候主動來問你,而不是自己猜?你怎麼建立的?」 -- **信號**:信任建立策略;是否有意識地降低工程師溝通成本 - -#### P2 — 向上管理 -**問題**:「你有沒有一個決定,你的老闆不同意,但你最後還是堅持了?你用什麼說服他,或你用什麼理由接受了他的否決?」 -- **信號**:向上溝通能力;是否有能力分辨「可辯護的分歧」和「應該服從的決策」 - -#### P3 — 利害關係人管理 -**問題**:「你有沒有一個 stakeholder,他很難搞但你沒有辦法繞過他,你怎麼跟他合作?」 -- **信號**:關係管理複雜度;是否有針對不同型態 stakeholder 的應對策略 - -#### P4 — 壞消息傳遞 -**問題**:「你有沒有需要告訴老闆或客戶一個他不會高興聽的消息?你怎麼準備那次對話?」 -- **信號**:難對話準備方式;是否有早期預警習慣 - -#### P5 — 設計師協作 -**問題**:「你和設計師意見不一致的時候,你怎麼決定聽誰的?」 -- **信號**:專業邊界的尊重度;是否能分辨「PM 的決定」vs「設計師的決定」 - -*** - -### VOICE Roleplay 場景(5 題) - -#### V1 — 正式狀態更新 -**場景**:一個原定這週上線的 feature,工程說還需要兩週。你需要更新你的業務老闆。請打一封訊息。 -- **萃取目標**:主動承擔 vs. 推責;是否帶替代方案;是否預先管理期望 - -#### V2 — 拒絕新需求 -**場景**:業務 VP 突然傳訊息說「我需要你在這個 sprint 加一個 feature,客戶急用」。你已排滿。 -- **萃取目標**:拒絕策略;是否帶條件;是否提出優先序選擇讓對方決定 - -#### V3 — 推進停滯中的設計評審 -**場景**:一個設計稿已等待設計主管 review 超過一週,影響了 sprint 進行。請打一段訊息。 -- **萃取目標**:催促方式;是否能不得罪人地製造壓迫感 - -#### V4 — 跨部門衝突調解 -**場景**:工程 Lead 在群組裡公開說「這個需求根本就是拍腦袋的,我不知道做這個的目的是什麼」。 -- **萃取目標**:公開衝突處理方式;是否私下介入;是否有能力重建對話框架 - -#### V5 — 上線後道歉/修復 -**場景**:你昨天上線的功能有個明顯的 UX 問題,用戶開始抱怨。你需要告知你的老闆和相關團隊。 -- **萃取目標**:責任語氣;是否帶後續計畫;是否在 36 小時內提出修復方向 - -*** - -### BOUNDARIES 領域化問題(5 題) - -#### B1 — 決策權邊界 -**問題**:「有哪些決定你覺得是你應該拍板的,哪些你覺得不是你的決定?」 -- **信號**:授權邊界清晰度;是否清楚「我的決定」vs.「需要對齊的決定」 - -#### B2 — 範疇蔓延處理 -**問題**:「你有沒有遇過一個案子,做到一半發現需求比原來大很多?你怎麼判斷要不要收回來?」 -- **信號**:scope creep 對策;是否有明確的觸發點讓他重新談判 - -#### B3 — 不可妥協的事項 -**問題**:「有沒有一種壓力或要求,你絕對不會因為主管或客戶要求就讓步的?」 -- **信號**:核心原則——品質底線、用戶安全、資料隱私等 - -#### B4 — 個人工作邊界 -**問題**:「PM 的工作很容易沒有邊界,你有沒有一個時期你覺得邊界消失了?後來是怎麼找回來的?」 -- **信號**:自我保護機制;是否對可持續工作方式有反思 - -#### B5 — 放棄一個功能 -**問題**:「你有沒有做到一半決定放棄一個功能的?那個決定是怎麼做的、是誰做的?」 -- **信號**:殺死自己的 baby 的能力;是否能說出 kill 決策的觸發條件 - -*** - -### 反感問法警示(PM / TPM) - -| 反感問法 | 為什麼反感 | 替代問法 | -|----------|-----------|----------| -| 「你怎麼平衡技術和商業需求?」 | 太抽象、太面試,PM 都背得出罐頭答案 | 「你最近一次在技術成本和商業價值之間做取捨,結果是什麼?」 | -| 「你覺得 PM 最重要的能力是什麼?」 | 問自我定義,答案永遠是「溝通」加「同理心」 | 「你做過最難的一個跨部門協調,讓你覺得學到最多的是什麼?」 | -| 「你怎麼跟工程師相處?」 | 太廣、易流於表面(「尊重技術判斷」) | 「工程師覺得你的需求描述不清楚,你怎麼發現的?你改了什麼?」 | -| 「你用過什麼 PM 工具?」 | 工具是手段,不是信號;列工具不代表能力 | 「你怎麼追蹤一個複雜功能的進度?你通常在哪個環節最容易漏掉東西?」 | -| 「你的 OKR 完成了多少?」 | 數字脫離脈絡沒有意義,且容易防衛 | 「上一個目標週期,你最後悔沒做的一件事是什麼?為什麼當時沒做?」 | - -*** - -### 高價值 Persona Anchors(PM / TPM) - -1. 對不在當期策略目標上的需求,即使技術容易,也傾向不進 roadmap,且能說得出理由。 -2. 拿到需求時習慣把「對方描述的症狀」和「他要的解法」分開,再回去問問題。 -3. 把「做不到」和「現在不做」視為本質不同的兩件事,且會在對話中主動區分。 -4. 上線前必定定義失敗指標,否則不啟動上線流程。 -5. 對 C-level 說 roadmap 說商業影響,對工程說技術選型邏輯,是兩份不同材料。 -6. 習慣在遇到壞消息時先想「我有沒有早期信號錯過了」,而不只是思考怎麼報告。 -7. 認為 PM 最大的風險是「問了業務就以為問了用戶」。 -8. 資源不足時先定義 non-negotiable,其他的範圍都可以談。 -9. 對 feature 被殺死沒有情緒障礙,認為比繼續做錯的東西代價小。 -10. 向上管理時,能清楚分辨「可以辯護的意見分歧」和「應該服從的決定」。 -11. 遇到 scope creep 有明確的重談觸發點,不會默默擴大做。 -12. 把讓工程師「不清楚就主動來問」視為工作成功的重要指標之一。 - -*** - -## 4. Consultant - -### domain_meta - -```yaml -domain_role: - - 管理顧問、策略顧問、IT 顧問、轉型顧問、獨立顧問 -core_task: - - 問題診斷與結構化分析 - - 報告與建議書製作 - - 客戶訪談與工作坊引導 - - 執行建議與變革管理 -primary_counterparty: - - 客戶中高層主管、專案對接窗口 -decision_partner: - - 顧問主管、顧問同儕、客戶關鍵 stakeholder -``` - -*** - -### SKILL 領域化問題(8 題) - -#### Q1 — 問題結構化 -**問題**:「你接過一個客戶的問題,一開始你以為是 A 問題,後來發現根本是 B 問題?你是怎麼發現的?」 -- **目的**:萃取診斷能力——是否有「不過早收斂問題定義」的習慣 -- **期待 anchor**:「前兩週做的是挑戰假設,而不是驗證假設」 -- **追問上限**:2 次 -- **停止條件**:取得具體的「重新定義問題」的方法 -- **反感風險**:低 - -#### Q2 — 資料不足下的判斷 -**問題**:「你有沒有在客戶資料不完整的情況下,還是要給建議的經驗?你的做法是什麼?」 -- **目的**:萃取在不確定性下的溝通方式——是否能清楚標注假設前提 -- **期待 anchor**:「把建議的前提條件寫清楚,讓客戶知道他接受的是哪個假設下的答案」 -- **追問上限**:1 次 -- **停止條件**:取得「假設標注」的習慣 -- **反感風險**:低 - -#### Q3 — 建議書框架 -**問題**:「你最近一份建議書,你最花心思的部分是哪裡?為什麼那個部分最難?」 -- **目的**:萃取思考偏好——邏輯結構、數據驗證、執行可行性、簡報視覺哪個最優先 -- **期待 anchor**:「最難的是讓客戶接受的不是「最好的答案」,而是「他做得到的答案」」 -- **追問上限**:1 次 -- **停止條件**:取得製作邏輯 -- **反感風險**:極低 - -#### Q4 — 客戶推拒處理 -**問題**:「你給過一個建議,客戶當場說他們做不到或不想做,你怎麼應對?」 -- **目的**:萃取說服策略——是否理解阻力根源;是否能重新框架建議 -- **期待 anchor**:「先問是哪個部分讓他覺得做不到,是資源問題、政治問題還是他根本不相信這個答案」 -- **追問上限**:2 次 -- **停止條件**:取得具體的應對策略 -- **反感風險**:低 - -#### Q5 — 工作坊引導 -**問題**:「你有沒有一個工作坊或訪談,氣氛不太對,你當下做了什麼調整?」 -- **目的**:萃取現場應變能力;是否有閱讀房間的技能 -- **期待 anchor**:「發現討論卡住通常是議題太大,習慣立刻把問題拆小再問一次」 -- **追問上限**:2 次 -- **停止條件**:取得具體的現場調整行為 -- **反感風險**:低 - -#### Q6 — 報告呈現風格 -**問題**:「你有沒有一份報告,你覺得邏輯很好,但客戶反應很冷淡?你後來的解讀是什麼?」 -- **目的**:萃取溝通自我反思——是否意識到「邏輯好」和「客戶接受」的差距 -- **期待 anchor**:「分析做完要問的問題不是『這個答案對不對』,而是『客戶願不願意行動』」 -- **追問上限**:1 次 -- **停止條件**:取得受訪者對自身報告風格的反思 -- **反感風險**:低 - -#### Q7 — 超出範疇的問題 -**問題**:「客戶問了一個你的合約範疇之外的問題,你怎麼決定要不要回答?」 -- **目的**:萃取邊界管理風格——是否傾向擴範疇換信任;是否有清楚的職業邊界 -- **期待 anchor**:「先快速給一個方向,然後說清楚這不在這次的 scope,若要深入需要另外討論」 -- **追問上限**:1 次 -- **停止條件**:取得範疇邊界的處理策略 -- **反感風險**:低 - -#### Q8 — 競品差異化 -**問題**:「你有沒有一次你明確知道,客戶為什麼選你而不是另外一家顧問?那個差異點是什麼?」 -- **目的**:萃取自我差異化認知——是否清楚自己的獨特價值 -- **期待 anchor**:「客戶說他選我是因為我在提案時就已經定義了他自己沒想到的問題」 -- **追問上限**:1 次 -- **停止條件**:取得受訪者的自我差異化敘事 -- **反感風險**:低 - -*** - -### PEOPLE 領域化問題(5 題) - -#### P1 — 客戶信任深度 -**問題**:「你有沒有一個客戶,在專案結束後還是持續找你的?你覺得那個關係是怎麼建立起來的?」 -- **信號**:長期顧問信任建立策略 - -#### P2 — 客戶內部政治 -**問題**:「你有沒有發現你的建議被客戶內部某個人擋住了,但那個人不是你的對口?你怎麼應對?」 -- **信號**:政治敏感度與迂迴影響力策略 - -#### P3 — 團隊內部協作 -**問題**:「你和顧問同事的工作分工,有沒有你覺得分得不好、或彼此有摩擦的時候?通常是什麼原因?」 -- **信號**:團隊信任與責任劃分偏好 - -#### P4 — 壞消息處理 -**問題**:「你有沒有分析結果顯示客戶的策略根本就是錯的,你怎麼讓他們聽進去?」 -- **信號**:衝突性建議的呈現方式;是否能讓客戶接受不舒服的真相 - -#### P5 — 影響力邊界 -**問題**:「你有沒有一次,你覺得你的建議是對的,但最後客戶還是做了不一樣的事?你後來怎麼看這件事?」 -- **信號**:影響力邊界認知;是否能放下控制感 - -*** - -### VOICE Roleplay 場景(5 題) - -#### V1 — 正式進度更新 -**場景**:你的第一階段分析顯示問題比原來預估的複雜,需要延後兩週交付最終報告。請打一封給客戶窗口的信。 -- **萃取目標**:誠信溝通風格;是否帶新的時間計畫 - -#### V2 — 拒絕範疇擴張 -**場景**:客戶說「你們既然在分析這塊,能不能順便也看一下我們的業務端的狀況?」但這不在合約裡。 -- **萃取目標**:範疇邊界管理語氣;是否能不得罪對方地說「不」 - -#### V3 — 推進卡關的會議 -**場景**:你已寄出三封信要約一個關鍵主管受訪,但他都沒回。你需要透過客戶窗口推動。 -- **萃取目標**:升級策略;是否能不顯得強硬但有效 - -#### V4 — 意見衝突 -**場景**:在客戶工作坊中,一位高層主管說「你們顧問都這樣說,但我們行業不一樣」並否定你的分析框架。 -- **萃取目標**:公開挑戰的應對策略;是否能維持立場而不對立 - -#### V5 — 關係修復 -**場景**:你的分析報告有一個數字算錯,客戶在簡報中當場發現了。請打一封後續的信。 -- **萃取目標**:錯誤承擔語氣;是否主動說明修正計畫 - -*** - -### BOUNDARIES 領域化問題(5 題) - -#### B1 — 不可妥協的建議原則 -**問題**:「有沒有一種情況,客戶要你修改你的結論,但你不願意?你是怎麼處理的?」 -- **信號**:職業誠信邊界;是否有「結論不可出售」的核心原則 - -#### B2 — 範疇蔓延底線 -**問題**:「你什麼時候會對客戶說「這個超出我們這次的範圍」,而不是默默多做?」 -- **信號**:時間與資源的邊界管理 - -#### B3 — 利益衝突處理 -**問題**:「你有沒有發現你給的建議可能對你的公司有商業利益,你怎麼處理這個衝突?」 -- **信號**:職業倫理意識 - -#### B4 — 不接受的委託條件 -**問題**:「有沒有一種案子,你接了之後覺得你不應該接,是什麼讓你有這種感覺?」 -- **信號**:案子評估邊界;是否有「不對的案子」的辨識能力 - -#### B5 — 執行失敗的責任邊界 -**問題**:「顧問給了建議,客戶執行了但失敗了,你覺得責任在哪裡?」 -- **信號**:顧問責任範圍認知;是否過度承擔或過度推卸 - -*** - -### 反感問法警示(Consultant) - -| 反感問法 | 為什麼反感 | 替代問法 | -|----------|-----------|----------| -| 「你最厲害的一個案子是什麼?」 | NDA 和保密義務讓受訪者很難回答,且易變成炫耀 | 「你做過一個你認為最有學習價值的案子,不用說是哪個客戶,你從那個案子學到了什麼?」 | -| 「你覺得顧問最重要的能力是什麼?」 | 標準答案:「結構化思維和溝通能力」,毫無人格信號 | 「你認識過一個你覺得真的很強的顧問,讓你覺得強的原因是什麼?」 | -| 「你用什麼分析框架?」 | 易流於工具展示;答案不代表實際使用方式 | 「你最近一次用一個框架沒有用,你後來是怎麼換方向的?」 | -| 「你喜歡顧問工作嗎?」 | 情感詢問對理性型受訪者無效,且問不到真實面 | 「顧問工作哪個部分你覺得你特別有能量?哪個部分你覺得每次都在消耗你?」 | -| 「你有沒有讓客戶「成功轉型」的案例?」 | 「轉型成功」定義模糊,且顧問無法對執行結果負責 | 「你做過一個案子,你覺得建議被認真執行了,那個案子有哪些條件是對的?」 | - -*** - -### 高價值 Persona Anchors(Consultant) - -1. 接到問題的前兩週習慣做「挑戰假設」而不是「驗證假設」,避免過早收斂問題定義。 -2. 在資料不足時仍能給建議,但會把前提假設明確寫出來,讓客戶知道他接受的是哪個假設下的答案。 -3. 認為分析結束後最重要的問題不是「這個答案對不對」,而是「客戶願不願意採取行動」。 -4. 傾向給「客戶做得到的答案」而不是「理論上最好的答案」。 -5. 面對客戶的反對,習慣先問「是資源問題、政治問題,還是他根本不相信這個答案」,再決定如何回應。 -6. 工作坊卡住時,第一反應是把問題拆小而不是換題,認為「議題太大」是討論失速的主因。 -7. 對超出合約範疇的請求,習慣給一個方向再說清楚需要另外討論,而不是直接拒絕。 -8. 把結論的獨立性視為職業誠信的核心,不接受「修改結論讓客戶好看」的要求。 -9. 認為建立長期顧問信任的關鍵在於「在沒有商機的時候還是出現」。 -10. 面對公開挑戰時,習慣先承認對方的脈絡再重新定位自己的論點,而不是防禦性反擊。 -11. 對範疇蔓延有明確的觸發點,不會因為「順便」的請求而默默擴大工作量。 -12. 自我評估案子成功的標準是「客戶是否改變了他自己的決策框架」,而不只是「報告是否被接受」。 - -*** - -## 5. Manager / People Lead - -### domain_meta - -```yaml -domain_role: - - 工程主管、部門主管、Team Lead、Director、VP of Engineering -core_task: - - 人員招募、評估、培育、績效管理 - - 設定團隊目標並追蹤 - - 跨部門對齊與資源協調 - - 文化建立與團隊氛圍管理 -primary_counterparty: - - 直屬下屬、同層主管、HR -decision_partner: - - 老闆/VP、HR BP、其他主管 -``` - -*** - -### SKILL 領域化問題(8 題) - -#### Q1 — 績效對話 -**問題**:「你有沒有跟一個表現不如預期的人談過績效?你當時怎麼開口的?」 -- **目的**:萃取難對話能力——是否能直接但不傷人 -- **期待 anchor**:「會先說清楚我看到的具體行為,而不是貼標籤說他『態度有問題』」 -- **追問上限**:2 次 -- **停止條件**:取得具體的開場策略 -- **反感風險**:中——觸碰管理敏感點,需建立安全感後問 - -#### Q2 — 授權範圍 -**問題**:「你通常什麼樣的決定你會讓你的人自己做,什麼樣的你會介入?」 -- **目的**:萃取授權哲學——是否有清楚的邊界而非「看情況」 -- **期待 anchor**:「可逆的決定放手,不可逆的決定一定要過我」 -- **追問上限**:1 次 -- **停止條件**:取得明確的授權標準 -- **反感風險**:低 - -#### Q3 — 招募判斷 -**問題**:「你在面試時,通常你最後是靠什麼判斷這個人可以還是不行?」 -- **目的**:萃取人才判斷直覺——是否有超過履歷的評估維度 -- **期待 anchor**:「我看他在不確定的情況下怎麼思考,比看他有沒有經驗更重要」 -- **追問上限**:1 次 -- **停止條件**:取得判斷標準或 dealbreaker -- **反感風險**:低 - -#### Q4 — 人員離職處理 -**問題**:「你有沒有一個人提離職讓你意外的?你後來怎麼理解為什麼他要走?」 -- **目的**:萃取對人員信號的敏感度——是否有能力回望早期指標 -- **期待 anchor**:「事後想想他在三個月前就有一些訊號,我沒有接到」 -- **追問上限**:2 次 -- **停止條件**:取得對「早期信號」的反思 -- **反感風險**:中(需要受訪者承認自己錯過某些東西) - -#### Q5 — 文化建立 -**問題**:「你有沒有刻意做過某件事,讓你的團隊形成某種你想要的行為模式?那件事是什麼?」 -- **目的**:萃取文化塑造的具體手段——是否靠身教、靠制度,還是靠儀式 -- **期待 anchor**:「我在 retro 裡面公開說過我自己的失誤,從那次開始大家才開始敢說」 -- **追問上限**:2 次 -- **停止條件**:取得具體的文化行為範例 -- **反感風險**:低 - -#### Q6 — 弱者保護 vs. 高標準 -**問題**:「你有沒有一個人,你知道他已經在盡力了,但他的輸出就是跟不上?你怎麼處理那個張力?」 -- **目的**:萃取對「努力但不夠好」的處理方式——是否能同時維持公平與人性 -- **期待 anchor**:「努力和結果是兩件事,我會給他正面的努力反饋,但也要讓他知道我對輸出的期待沒有降低」 -- **追問上限**:2 次 -- **停止條件**:取得受訪者對這個張力的具體解法 -- **反感風險**:中 - -#### Q7 — 向上溝通 -**問題**:「你有沒有一個你的老闆的決定,你覺得對你的團隊有負面影響,你怎麼反映?」 -- **目的**:萃取向上影響力——是否有能力為團隊發聲但不讓老闆覺得被攻擊 -- **期待 anchor**:「我習慣帶數據,說清楚這個決定對團隊 delivery 能力的具體影響,而不是說『大家覺得很不公平』」 -- **追問上限**:1 次 -- **停止條件**:取得向上溝通的具體策略 -- **反感風險**:低 - -#### Q8 — 自己的成長盲點 -**問題**:「你當主管之後,什麼事是你花了最久才學會的?」 -- **目的**:萃取管理自我成長軌跡——是否有具體的學習時刻 -- **期待 anchor**:「最久才學會的是:我的焦慮不要傳給我的人」 -- **追問上限**:1 次 -- **停止條件**:取得具體的學習事件 -- **反感風險**:低(反而是最能打開話匣子的題) - -*** - -### PEOPLE 領域化問題(5 題) - -#### P1 — 一對一節奏 -**問題**:「你的 1-on-1 通常是誰主導、怎麼進行的?你覺得一個好的 1-on-1 結束時應該要發生什麼?」 -- **信號**:傾聽 vs. 指導偏好;是否把 1-on-1 當成工具 - -#### P2 — 衝突調解 -**問題**:「你有沒有兩個下屬之間有衝突,需要你介入?你什麼時候決定介入,什麼時候讓他們自己解決?」 -- **信號**:干預邊界;是否能讓團隊自己成長解決問題 - -#### P3 — 高潛力員工培育 -**問題**:「你有沒有一個你覺得很有潛力的人,你為他做了什麼讓他加速成長?」 -- **信號**:培育哲學——給任務、給資源、給曝光、給反饋;是否有意識地設計成長路徑 - -#### P4 — 不適任員工 -**問題**:「你有沒有最後決定讓一個人離開的經驗?你怎麼走到那個決定的?」 -- **信號**:對困難人事決策的心理準備與執行方式 - -#### P5 — 心理安全感 -**問題**:「你覺得你的團隊會不會主動跟你說壞消息?你怎麼知道?」 -- **信號**:心理安全感的自我評估;是否有主動確認的機制 - -*** - -### VOICE Roleplay 場景(5 題) - -#### V1 — 目標設定溝通 -**場景**:你需要向你的團隊說明下一季 OKR 有一個很有挑戰性的目標,你知道他們會覺得太高。請打一段訊息或開場白。 -- **萃取目標**:動機語言風格;是否帶理由;是否邀請參與 - -#### V2 — 拒絕不合理要求 -**場景**:你的老闆說希望你的團隊這季多承擔一個跨部門的任務,但你認為你們已經滿載了。 -- **萃取目標**:向上推拒的策略;是否帶資源解析;是否請老闆做優先序選擇 - -#### V3 — 推進績效改善 -**場景**:你和一個下屬已談過一次績效問題,但過了三週,改善幅度很有限。你需要再開一次對話。 -- **萃取目標**:second conversation 策略;是否更直接;是否帶明確的期望和後果 - -#### V4 — 衝突後的關係修復 -**場景**:你在一次公開 review 裡批評了某個人的工作,事後你覺得語氣太重了。 -- **萃取目標**:道歉方式;是否能在不喪失管理威信的情況下修復關係 - -#### V5 — 好消息公告 -**場景**:你的一個下屬拿到了一個內部晉升,你想在 team 群組宣布。 -- **萃取目標**:讚揚方式;是否具體說明他為什麼值得;是否帶團隊文化訊號 - -*** - -### BOUNDARIES 領域化問題(5 題) - -#### B1 — 授權下限 -**問題**:「你有沒有一種決定,你是真的希望下屬自己決定,但你最後還是介入了?你後來覺得那個介入是對的嗎?」 -- **信號**:授權後的克制能力;是否容易落入微管理 - -#### B2 — 個人邊界 -**問題**:「當下屬的個人問題(家庭、身心狀況)開始影響工作,你介入的邊界在哪裡?」 -- **信號**:主管角色邊界——是否能兼顧關懷與職業邊界 - -#### B3 — 不可接受的管理要求 -**問題**:「有沒有一種上面交代下來的任務,你覺得你沒辦法、也不應該照辦?」 -- **信號**:對「執行違背自己價值觀的指令」的處理方式 - -#### B4 — 資訊透明邊界 -**問題**:「有沒有公司的決定或資訊,你覺得應該跟你的人說清楚,但組織不讓你說?你怎麼處理那個張力?」 -- **信號**:資訊透明對主管的壓力;是否能在誠信和服從之間找到路 - -#### B5 — 與前下屬的邊界 -**問題**:「有沒有一個前下屬,在他離開後還是持續找你諮詢或聊工作的事?你怎麼定義那個關係?」 -- **信號**:角色邊界在關係延伸後的維持能力 - -*** - -### 反感問法警示(Manager / People Lead) - -| 反感問法 | 為什麼反感 | 替代問法 | -|----------|-----------|----------| -| 「你的管理風格是什麼?」 | 罐頭答案:「我是 servant leader,也是數據驅動的...」 | 「你最近一次幫一個人在你的團隊成長,你做了什麼具體的事?」 | -| 「你怎麼激勵你的團隊?」 | 太廣、太抽象,答案永遠是老套的三個面向 | 「你有沒有一個人,你找到了一個特別有效的方式讓他更有動力?那個方式是什麼?」 | -| 「你怎麼平衡人情和績效?」 | 這個問題有「陷阱感」,受訪者會給政治正確的答案 | 「你有沒有一個案例,你對一個人的情感和你對他的績效要求有明顯的張力?你當時怎麼做的?」 | -| 「你覺得你是一個好主管嗎?」 | 自我評分無意義,且大多數人不敢說「不」 | 「你有沒有一個前下屬,你現在回想起來覺得你沒有帶好他?是什麼情況?」 | -| 「你怎麼建立心理安全感?」 | 問法太 buzzword,易引發理論性回答 | 「你有沒有做過一件事,之後你發現你的人開始更敢說實話了?那件事是什麼?」 | - -*** - -### 高價值 Persona Anchors(Manager / People Lead) - -1. 績效對話開場習慣先描述看到的具體行為,而不是用性格標籤描述對方的問題。 -2. 授權標準是「可逆的決定放手,不可逆的決定介入」,而不是靠感覺判斷。 -3. 評估人才時,最看重的是「在不確定情況下他怎麼思考」,而不是過往的成功經歷。 -4. 對人員離職感到意外後,習慣回頭找早期三個月的訊號,用來改善下次的敏感度。 -5. 文化塑造偏好靠自己公開承認失誤來建立心理安全感,而不是靠政策。 -6. 能同時給努力的正面反饋和輸出不足的誠實反饋,不讓兩者互相覆蓋。 -7. 向上溝通時習慣帶具體數據,說明決定對 delivery 能力的影響,而不是說「大家覺得不公平」。 -8. 認為最難學會的管理技能是「不把自己的焦慮傳給團隊」。 -9. 衝突介入的原則是先讓當事人自己解決,介入的觸發點是「問題開始影響到其他人」。 -10. 好的 1-on-1 對他而言應以下屬的議題為主,主管的議題是例外而非預設。 -11. 對「組織不讓說但自己覺得應該說」的資訊張力,有明確的處理哲學,不靠情緒決定。 -12. 讚揚下屬時習慣說具體的行為和影響,而不是說「你做得很好」。 - -*** - -## 6. Creator / Writer - -### domain_meta - -```yaml -domain_role: - - 內容創作者、作家、自媒體、編輯、UGC 創作者、品牌內容策略師 -core_task: - - 內容概念發想與企劃 - - 撰寫、剪輯、發布 - - 受眾研究與回饋整合 - - 個人品牌維護 -primary_counterparty: - - 讀者/觀眾、編輯、品牌方 -decision_partner: - - 合作夥伴、設計師、平台演算法(隱性) -``` - -*** - -### SKILL 領域化問題(8 題) - -#### Q1 — 靈感來源與捕捉 -**問題**:「你上一個讓你很興奮的題目是怎麼來的?你通常在什麼狀態下靈感最多?」 -- **目的**:萃取創作觸發機制——是主動尋找還是被動接收;是否有捕捉的系統 -- **期待 anchor**:「靈感來了不記就不見,我用 X(Notes/實體本)做靈感捕捉,每週看一次」 -- **追問上限**:1 次 -- **停止條件**:取得具體的靈感來源模式 -- **反感風險**:極低 - -#### Q2 — 創作流程結構 -**問題**:「你從有一個題目到發布,通常要經過哪些步驟?有沒有哪個步驟你最常卡住?」 -- **目的**:萃取工作流程化程度——是否有穩定的流程,還是每次都不同 -- **期待 anchor**:「我的草稿到最終稿之間一定要放至少一天,冷了再看才能發現問題」 -- **追問上限**:1 次 -- **停止條件**:取得創作流程的關鍵節點 -- **反感風險**:低 - -#### Q3 — 受眾反饋整合 -**問題**:「你有沒有因為受眾的回饋改變了你的創作方式?還是你傾向不太被外部意見影響?」 -- **目的**:萃取受眾導向 vs. 創作自主的平衡點 -- **期待 anchor**:「留言告訴我沒懂,我會調整切入角度;但留言說他不喜歡,我不一定理他」 -- **追問上限**:1 次 -- **停止條件**:取得受訪者的受眾整合策略 -- **反感風險**:低 - -#### Q4 — 品質標準 -**問題**:「你有沒有一個你知道不夠好但還是發了的作品?你當時為什麼還是發?」 -- **目的**:萃取品質 vs. 發布節奏的取捨策略 -- **期待 anchor**:「發布的勇氣比完美主義更重要,但有一個不會妥協的底線」 -- **追問上限**:1 次 -- **停止條件**:取得「可以接受的不完美」的標準 -- **反感風險**:低 - -#### Q5 — 創作低潮處理 -**問題**:「你有沒有一段時間完全寫不出來,或做不出東西?你那段時間做了什麼?」 -- **目的**:萃取對創作枯竭的應對策略——是強迫、是休息、是換形式 -- **期待 anchor**:「那段時間我大量輸入,停止輸出壓力,等到有什麼東西要說的時候自然就回來了」 -- **追問上限**:2 次 -- **停止條件**:取得低潮的應對策略 -- **反感風險**:低 - -#### Q6 — 商業創作 vs. 本心創作 -**問題**:「你有沒有一個合作或案子,做完之後覺得不太是你想做的東西?你當時怎麼決定接?」 -- **目的**:萃取商業邊界——是否有清楚的「接案標準」 -- **期待 anchor**:「接了一個品牌案,但產品不是我真的推薦的,那是我接的最後一個那種案」 -- **追問上限**:1 次 -- **停止條件**:取得接案的邊界條件 -- **反感風險**:低 - -#### Q7 — 跨平台策略 -**問題**:「你在不同平台上的風格或主題一樣嗎?你怎麼決定什麼內容放在哪裡?」 -- **目的**:萃取受眾分眾意識與平台差異理解 -- **期待 anchor**:「長文放部落格,碎片化想法放短影片,但核心觀點不因平台改變」 -- **追問上限**:1 次 -- **停止條件**:取得平台差異化策略 -- **反感風險**:低 - -#### Q8 — 個人品牌定位 -**問題**:「你怎麼描述你的創作跟別人不一樣的地方?你最希望讀者/觀眾記住你的什麼?」 -- **目的**:萃取自我差異化意識——是否能清楚說出 POV -- **期待 anchor**:「我希望大家記得的是:他說的事情我都能自己驗證,不是空話」 -- **追問上限**:1 次 -- **停止條件**:取得自我定位敘事 -- **反感風險**:低 - -*** - -### PEOPLE 領域化問題(5 題) - -#### P1 — 讀者/觀眾關係 -**問題**:「有沒有一個讀者或觀眾,他的回應讓你改變了你做某件事的方式?是什麼事?」 -- **信號**:受眾連結的深度;是否把觀眾視為對話夥伴還是輸出對象 - -#### P2 — 合作者信任 -**問題**:「你有沒有找人合作過一個內容項目,合作過程最不舒服的是什麼?」 -- **信號**:合作邊界與溝通偏好 - -#### P3 — 批評處理 -**問題**:「你有沒有收過一個讓你不舒服的批評,但後來你承認他是對的?那個批評是什麼?」 -- **信號**:接受批評的機制;是否能分辨「有用的批評」和「情緒攻擊」 - -#### P4 — 品牌方關係 -**問題**:「你跟品牌合作的時候,你最常需要重申的事情是什麼?」 -- **信號**:創作自主性的維護策略 - -#### P5 — 社群互動邊界 -**問題**:「你怎麼管理自己跟社群之間的距離?有沒有你覺得太近或太遠的時候?」 -- **信號**:公眾關係中的個人邊界感 - -*** - -### VOICE Roleplay 場景(5 題) - -#### V1 — 正式合作提案 -**場景**:一個品牌的 marketing 主管問你有沒有興趣合作,你需要先問清楚條件再決定要不要進一步談。請打一段回覆。 -- **萃取目標**:談判開場策略;是否主動定義合作條件 - -#### V2 — 拒絕不合適的合作 -**場景**:一個你不認同的品牌出了一個高於市場行情的報價。請打一封婉拒信。 -- **萃取目標**:拒絕方式;是否說出理由;語氣是否保持關係 - -#### V3 — 推進卡住的合作流程 -**場景**:你已交出內容初稿三週,品牌那邊一直說「在審核中」,你的發布計畫被卡住了。 -- **萃取目標**:催促策略;是否設定截止日期 - -#### V4 — 回應負面評論 -**場景**:你發了一篇文章,有人留言說「這個觀點根本就是錯的,你根本沒研究清楚」。 -- **萃取目標**:公開回應策略;是否能分辨內容批評和情緒攻擊 - -#### V5 — 創作延遲說明 -**場景**:你告訴訂閱者每週一篇,但這週因為個人狀況沒辦法發。請打一則公告。 -- **萃取目標**:對受眾的透明度;是否帶理由;是否帶下一步時間線 - -*** - -### BOUNDARIES 領域化問題(5 題) - -#### B1 — 創作主導權 -**問題**:「品牌合作中,你有沒有一個絕對不讓步的點?是什麼?」 -- **信號**:創作自主性的底線 - -#### B2 — 個人資訊邊界 -**問題**:「你決定在創作裡分享多少自己的私人生活?這個邊界是怎麼劃的?」 -- **信號**:公私邊界;是否有策略性地決定揭露範圍 - -#### B3 — 發布與生活平衡 -**問題**:「你有沒有一段時間覺得創作的量讓你喘不過氣?你是怎麼重新找到節奏的?」 -- **信號**:創作可持續性邊界;是否有節奏管理策略 - -#### B4 — 不接受的合作條件 -**問題**:「有沒有一種合作要求,你直接說「這個我沒辦法接受」?那個要求是什麼?」 -- **信號**:創作誠信邊界 - -#### B5 — 社群壓力邊界 -**問題**:「你有沒有因為受眾的期待,做了一個你其實不想做的內容?後來怎麼樣?」 -- **信號**:對受眾壓力的抵抗能力;是否能分辨「服務受眾」和「被受眾綁架」 - -*** - -### 反感問法警示(Creator / Writer) - -| 反感問法 | 為什麼反感 | 替代問法 | -|----------|-----------|----------| -| 「你每個月有多少流量/追蹤數?」 | 數字脫離脈絡,且易讓受訪者覺得被用數字評價 | 「你做過一個你覺得沒爆但你自己很在乎的作品,那個作品對你的意義是什麼?」 | -| 「你怎麼保持創意?」 | 太廣、易引出罐頭答案(「多出門多閱讀」) | 「你上一個最花心思的題目,從哪裡來的?」 | -| 「你有沒有 niche?」 | 英文術語對部分受訪者有距離感,且易引出自我標籤 | 「你覺得你的讀者來找你,最主要是因為你能給他們什麼?」 | -| 「你有沒有接過廣告?」 | 暗示評判商業化,易引起防衛 | 「你在商業合作和創作自由之間有沒有發生過衝突?那次是怎麼發生的?」 | -| 「你覺得你的寫作風格是什麼?」 | 自我定義偏向表演,而非真實信號 | 「有沒有一篇你覺得最接近你想說的話的文章?為什麼是那篇?」 | - -*** - -### 高價值 Persona Anchors(Creator / Writer) - -1. 靈感捕捉有固定系統,不依賴記憶,因為「靈感來了不記就不見」。 -2. 草稿完成後一定放至少一天才發布,冷掉再看才能找到問題。 -3. 受眾說「沒懂」會調整切入角度,但「不喜歡」不一定會改變。 -4. 接案標準包含「這是我真的推薦的東西嗎」,不只看報酬。 -5. 發布節奏比完美主義更重要,但對自己有一條不妥協的品質底線,能清楚說出那條線在哪。 -6. 對創作低潮的應對是大量輸入、停止輸出壓力,而不是強迫自己產出。 -7. 在不同平台上的核心觀點不因平台演算法改變,但形式和密度會調整。 -8. 把「讀者能自己驗證我說的事」視為核心品牌承諾,而不是「讀者喜歡我」。 -9. 公私邊界是策略性決定,而不是隨機透露——知道哪些事可以說、哪些事有代價。 -10. 對負面評論能快速分辨「有建設性的批評」和「情緒攻擊」,並用不同方式應對。 -11. 對社群壓力有清楚的底線——能說出一個他「不會因為受眾期待而做的事」。 -12. 創作停滯期不視為失敗,視為必要的重新充電,且有具體的啟動儀式來結束停滯。 - -*** - -## 7. Teacher / Coach - -### domain_meta - -```yaml -domain_role: - - 教師、培訓師、企業內訓師、教練(life/executive/career)、導師 -core_task: - - 評估學習者狀態與需求 - - 設計與執行學習/coaching 路徑 - - 給予有效反饋 - - 支持自我突破與行為改變 -primary_counterparty: - - 學生、學員、被 coaching 的人 -decision_partner: - - 課程設計夥伴、共同督導、機構主管 -``` - -*** - -### SKILL 領域化問題(8 題) - -#### Q1 — 診斷學習者狀態 -**問題**:「你第一次跟一個新的學員或學生接觸,你怎麼評估他現在的狀態和他真正需要什麼?」 -- **目的**:萃取初步診斷方法——是問問題、觀察行為,還是靠測試 -- **期待 anchor**:「我會先問他最想解決的問題,再問他嘗試過什麼,這樣就能大概知道他在哪個階段」 -- **追問上限**:2 次 -- **停止條件**:取得具體的評估流程 -- **反感風險**:低 - -#### Q2 — 課程/路徑設計 -**問題**:「你有沒有一個你很滿意的課程或 coaching 計畫設計?它和你之前的版本有什麼不同?」 -- **目的**:萃取設計邏輯——是以結果導向還是以內容導向 -- **期待 anchor**:「從「學了這個能做什麼」出發,而不是從「我想教什麼」出發」 -- **追問上限**:1 次 -- **停止條件**:取得設計哲學 -- **反感風險**:低 - -#### Q3 — 反饋給予風格 -**問題**:「你給過一個讓學員不舒服但後來他感謝你的反饋,那個反饋說了什麼?」 -- **目的**:萃取直接反饋的能力與方式 -- **期待 anchor**:「我傾向先說我看到的行為,再說那個行為在外部的影響,最後問他自己怎麼看」 -- **追問上限**:2 次 -- **停止條件**:取得具體的反饋結構 -- **反感風險**:低 - -#### Q4 — 學習卡關的處理 -**問題**:「你有沒有一個學員,他怎樣都跨不過某個關卡,你最後用什麼方式讓他突破的?」 -- **目的**:萃取卡關診斷——是能力問題、是信念問題,還是環境問題 -- **期待 anchor**:「通常卡關不是不會,而是有個他說不出口的擔心;我習慣先問他最壞的情況是什麼」 -- **追問上限**:2 次 -- **停止條件**:取得具體的突破策略 -- **反感風險**:低 - -#### Q5 — 對個別差異的適應 -**問題**:「你怎麼調整你的教學或 coaching 方式,讓不同類型的人都能接受?有沒有一個你花最多力氣調整的例子?」 -- **目的**:萃取個人化程度——是否有意識地切換 -- **期待 anchor**:「行動型的人我直接給任務,分析型的人我先給框架,用的語言完全不同」 -- **追問上限**:1 次 -- **停止條件**:取得至少兩種風格切換範例 -- **反感風險**:低 - -#### Q6 — 成效評估 -**問題**:「你怎麼知道你的教學或 coaching 有沒有效?你用什麼來判斷,而不只是靠感覺?」 -- **目的**:萃取成效評估的嚴謹度 -- **期待 anchor**:「每個 coaching 案例在開始前都要定義一個具體的行為指標,不然結束時很難評估」 -- **追問上限**:1 次 -- **停止條件**:取得具體的評估方式 -- **反感風險**:低 - -#### Q7 — 邊界衝突處理 -**問題**:「你有沒有一個學員或被 coaching 的人,開始依賴你超出了正常的範圍?你怎麼處理?」 -- **目的**:萃取對「依賴」的應對策略——是否能在關懷和邊界間平衡 -- **期待 anchor**:「我的目標是讓他不需要我,如果他越來越需要我,代表我可能做錯了什麼」 -- **追問上限**:2 次 -- **停止條件**:取得過度依賴的邊界應對 -- **反感風險**:低 - -#### Q8 — 自我更新 -**問題**:「你有沒有一個你以前教的東西,現在你的想法不一樣了?你怎麼更新自己的內容?」 -- **目的**:萃取知識更新意願——是否願意推翻舊有框架 -- **期待 anchor**:「如果我教的東西和我最新的實踐有落差,我寧可調整課程,不要讓它成為一套舊話術」 -- **追問上限**:1 次 -- **停止條件**:取得自我更新的觸發機制 -- **反感風險**:低 - -*** - -### PEOPLE 領域化問題(5 題) - -#### P1 — 最深刻的學員 -**問題**:「你教過或 coach 過的人裡面,有沒有一個讓你改變了你對某件事的看法?是什麼?」 -- **信號**:是否能從學員身上學習;關係是否是單向的 - -#### P2 — 衝突與抵抗 -**問題**:「你有沒有一個學員或學生,一直在抗拒你說的東西,但你覺得他才是對的那個?」 -- **信號**:對「正確抵抗」的尊重;是否能放下教者身分 - -#### P3 — 學員失敗的責任感 -**問題**:「你有沒有一個學員沒有達到他設定的目標,你覺得自己有責任嗎?那次你怎麼反思?」 -- **信號**:責任感邊界;是否會過度承擔學員的失敗 - -#### P4 — 同儕督導 -**問題**:「你有沒有一個你信任的人,你會把你的教學或 coaching 實踐拿給他看、讓他給你反饋?」 -- **信號**:自我精進機制;是否有能力接受針對自己專業的批評 - -#### P5 — 學員成功的歸因 -**問題**:「你帶過一個成果很好的學員,你覺得他的成功有多少是你的功勞?」 -- **信號**:成就歸因——是過度謙虛還是過度邀功;是否能清楚說出自己的貢獻 - -*** - -### VOICE Roleplay 場景(5 題) - -#### V1 — 正式學習啟動訊息 -**場景**:新的一對一 coaching 週期開始,你需要傳一封訊息給新學員,說明接下來的合作方式。 -- **萃取目標**:框架設定語氣;是否建立清楚的期待 - -#### V2 — 拒絕不適合的委託 -**場景**:有人問你能不能幫他「改變他不想上學的孩子的態度」,但這不在你的專業範圍。 -- **萃取目標**:轉介建議方式;是否能誠實說明能力邊界而不傷對方的期待 - -#### V3 — 推進停滯的 coaching 過程 -**場景**:你的學員已三週沒完成你們約定好的行動項目,但每次都說「最近太忙」。 -- **萃取目標**:面質策略;是否能在不批評的情況下讓對方正視模式 - -#### V4 — 學員反彈你的觀點 -**場景**:你在 coaching 中說了一個觀點,學員說「我不同意,我覺得你說的不適用在我這裡」。 -- **萃取目標**:是否有能力接住反彈;是否能把抵抗轉成探索材料 - -#### V5 — 結束 coaching 關係 -**場景**:你覺得你和某個學員已經到了這段 coaching 能給的極限,你需要告訴他這件事。 -- **萃取目標**:結束關係的語言;是否能帶著尊重說出「我們到了一個段落」 - -*** - -### BOUNDARIES 領域化問題(5 題) - -#### B1 — 可教範圍邊界 -**問題**:「有沒有一種學習需求,你會判斷這個人需要的不是我的 coaching,而是別的支持?你怎麼判斷?」 -- **信號**:轉介能力;是否清楚自己的專業邊界(例如心理健康) - -#### B2 — 關係邊界 -**問題**:「你和學員的關係,你怎麼讓它維持在「專業支持」而不是「私人情感依賴」?」 -- **信號**:角色邊界的維持機制 - -#### B3 — 不可接受的委託 -**問題**:「有沒有一種培訓或 coaching 委託,你接了會讓你對自己不舒服?是什麼條件讓你說不?」 -- **信號**:倫理邊界——例如不願意幫助「改造人」而不是「支持人成長」 - -#### B4 — 個人成本邊界 -**問題**:「教學或 coaching 很容易變成無限的情緒勞動,你有沒有一段時間覺得你付出超過了你能負荷的?你怎麼找回邊界?」 -- **信號**:可持續工作的自我保護機制 - -#### B5 — 成果承諾邊界 -**問題**:「你在跟學員簽約或開始合作前,你通常怎麼設定對成果的期待?你有沒有一個你不會保證的事?」 -- **信號**:成果責任的邊界——是否有「我負責過程,不負責結果」或其他明確的框架 - -*** - -### 反感問法警示(Teacher / Coach) - -| 反感問法 | 為什麼反感 | 替代問法 | -|----------|-----------|----------| -| 「你是什麼風格的老師/教練?」 | 邀請自我貼標籤,答案永遠是「以人為本」 | 「你教過的人,他們通常說你讓他們最有感的是什麼?」 | -| 「你覺得教學最重要的是什麼?」 | 太哲學,答案是老套的「啟發學習者動機」 | 「你做過一個決定,讓你的某個學員的學習開始不一樣,那個決定是什麼?」 | -| 「你怎麼對待不想學習的學生?」 | 太籠統,且暗示「不想學習」是可以解決的 | 「你有沒有一個學員,你後來判斷不管你做什麼,他現在就是沒準備好?你當時怎麼做的?」 | -| 「你有沒有執照或認證?」 | 第一個問題就問資格,易讓受訪者感到被審查 | 「你是什麼契機開始在這個領域教學/coaching 的?」 | -| 「你的學員成功率是多少?」 | 沒有可比標準,且暗示 coaching 成敗由教練決定 | 「你怎麼評估一段 coaching 關係的成功?你會用什麼訊號判斷?」 | - -*** - -### 高價值 Persona Anchors(Teacher / Coach) - -1. 初次接觸新學員時習慣先問「最想解決的問題」和「嘗試過的方法」,用來快速定位學習階段。 -2. 課程設計從「學了這個能做什麼」出發,而不是從「我想教什麼」出發。 -3. 給反饋的結構是:先說看到的行為,再說外部影響,最後問學員自己怎麼看。 -4. 認為學員卡關通常不是「不會」,而是有個說不出口的擔心——習慣用「最壞的情況是什麼」來開啟這個對話。 -5. 對行動型學員直接給任務,對分析型學員先給框架,這兩種語言是完全不同的系統。 -6. 每個 coaching 案在開始前定義一個具體的行為指標,而不是用「感覺有沒有進步」衡量。 -7. 把「學員越來越不需要我」視為 coaching 成功的指標,而不是「學員越來越依賴我」。 -8. 如果自己目前的實踐和教的內容有落差,會主動更新課程,不讓舊話術繼續流通。 -9. 對學員的失敗有清楚的責任邊界——負責過程和工具,不對學員的最終選擇負全責。 -10. 轉介能力是核心能力之一——能清楚說出「這個需求超出 coaching 的範疇,他需要的是 X」。 -11. 在沒有委託的情況下不給建議,讓學員自己說出方向是 coaching 的核心方法。 -12. 對「成果保證」有明確的語言邊界,不會說「我保證你一定能做到」,但能說「我承諾我的陪伴是全力以赴的」。 - -*** - -## 8. Founder / Operator - -### domain_meta - -```yaml -domain_role: - - 創辦人、共同創辦人、執行長、COO、早期員工 #1-5 -core_task: - - 策略制定與公司方向把舵 - - 資源(資金、人才)取得與分配 - - 產品/市場驗證 - - 組織建立與文化塑造 - - 危機管理與不確定性下的決策 -primary_counterparty: - - 投資人、共同創辦人、早期員工、客戶 -decision_partner: - - 共同創辦人、顧問、董事會、信任的外部人士 -``` - -*** - -### SKILL 領域化問題(8 題) - -#### Q1 — 核心決策框架 -**問題**:「你有沒有做過一個決定,事後回看你覺得是你做過最正確的決定之一?你當時怎麼想到這樣做?」 -- **目的**:萃取決策邏輯的結構——是資料驅動、直覺信任,還是討論收斂 -- **期待 anchor**:「那個決定我做了兩週的小規模測試,沒等到完整數據就決定了,因為我覺得等下去機會就沒了」 -- **追問上限**:2 次 -- **停止條件**:取得受訪者的決策觸發邏輯 -- **反感風險**:低 - -#### Q2 — 放棄某個方向 -**問題**:「你有沒有放棄過一個你花了很多時間和資源的方向?那個決定是怎麼做出來的?」 -- **目的**:萃取 pivot / kill 決策的邏輯——是否能快速認錯;是否會因沉沒成本卡住 -- **期待 anchor**:「我有一個『如果這是我今天才看到的機會,我還會做嗎?』的自問,一旦答案是不,我就開始認真想放棄」 -- **追問上限**:2 次 -- **停止條件**:取得放棄決策的觸發條件 -- **反感風險**:中——觸碰失敗,需語氣中立 - -#### Q3 — 資源極度有限下的優先序 -**問題**:「你在資源最緊張的時候,你怎麼決定錢或人力放在哪裡?有沒有一個你覺得這個取捨很痛,但你知道你做對了?」 -- **目的**:萃取資源分配邏輯——是否有清楚的生存優先排序 -- **期待 anchor**:「那個時期我的原則是:活下去的事情優先,增長的事情有餘力才做」 -- **追問上限**:2 次 -- **停止條件**:取得資源緊張時的優先序原則 -- **反感風險**:低 - -#### Q4 — 找人與選人 -**問題**:「你找過一個你後來覺得選錯了的人,你當時為什麼選他?你從那次學到了什麼?」 -- **目的**:萃取人才判斷的自我修正——是否能說出具體的教訓 -- **期待 anchor**:「那次我選了一個亮眼履歷但沒有在 chaos 環境工作過的人,我沒確認他對模糊和變動的耐受度」 -- **追問上限**:2 次 -- **停止條件**:取得選錯人的具體教訓 -- **反感風險**:低 - -#### Q5 — 危機應對模式 -**問題**:「你有沒有遇過一個你覺得公司可能活不下去的時刻?那個時候你做了什麼,你自己的狀態是什麼?」 -- **目的**:萃取危機行為模式——是否能在極端壓力下保持清晰 -- **期待 anchor**:「那段時間我的原則是:先穩住自己,再穩住團隊,問題的解法是第三步」 -- **追問上限**:2 次 -- **停止條件**:取得危機應對的行為模式 -- **反感風險**:低 - -#### Q6 — 投資人/董事會關係 -**問題**:「你有沒有一次你的判斷和投資人的想法不一樣,你怎麼處理?」 -- **目的**:萃取獨立決策能力——是否能在資金壓力下維持自己的方向判斷 -- **期待 anchor**:「我把我的邏輯完整說清楚,讓投資人知道我考慮了什麼,再告訴他我的決定是什麼」 -- **追問上限**:1 次 -- **停止條件**:取得與投資人的意見衝突處理策略 -- **反感風險**:低 - -#### Q7 — 文化塑造 -**問題**:「你有沒有一件事,你做了之後,你的團隊開始出現一個你想要的行為模式?那件事是什麼?」 -- **目的**:萃取文化設計的具體手法 -- **期待 anchor**:「我開始在每週全員會議上分享自己犯的錯,後來大家都開始在自己的範疇這樣做」 -- **追問上限**:1 次 -- **停止條件**:取得一個具體的文化行為設計範例 -- **反感風險**:低 - -#### Q8 — 創辦人孤獨感 -**問題**:「創辦人很多決定沒辦法跟別人討論,你怎麼在這種狀態下保持判斷力清晰?」 -- **目的**:萃取孤獨決策下的自我管理策略 -- **期待 anchor**:「我有一個固定跟三到四個我信任的人的月會,他們不一定懂我的行業,但他們能幫我看清楚我自己的狀態」 -- **追問上限**:1 次 -- **停止條件**:取得孤獨決策的應對方式 -- **反感風險**:低 - -*** - -### PEOPLE 領域化問題(5 題) - -#### P1 — 共同創辦人關係 -**問題**:「你和共同創辦人(或早期核心夥伴)有沒有過一次嚴重的意見衝突?那次是怎麼解的?」 -- **信號**:最重要的合夥關係處理;是否有健康的衝突解法 - -#### P2 — 放棄一個人 -**問題**:「你有沒有讓一個很早就加入的人離開,那個決定你怎麼走到的?」 -- **信號**:對創始團隊人員更替的處理;情感與組織需求的平衡 - -#### P3 — 投資人信任 -**問題**:「你怎麼判斷一個投資人是你想要的夥伴,而不只是想要他的錢?」 -- **信號**:對資金以外的投資人價值的評估能力 - -#### P4 — 員工離心 -**問題**:「你有沒有感覺到某一段時間,你的團隊對公司方向失去信心?你怎麼察覺、又怎麼應對?」 -- **信號**:組織信號敏感度;危機中的溝通策略 - -#### P5 — 創辦人與員工的邊界 -**問題**:「你和你的早期員工,邊界是清楚的嗎?你有沒有發現哪裡的邊界需要重新劃?」 -- **信號**:角色與關係邊界的管理——創辦人很容易與早期員工建立過度的情感依賴 - -*** - -### VOICE Roleplay 場景(5 題) - -#### V1 — 全員重要訊息 -**場景**:公司剛做了一個策略轉向,方向和你三個月前說的不一樣。你需要向全員說明。 -- **萃取目標**:說清楚轉向理由的能力;是否帶信心感但不迴避矛盾 - -#### V2 — 拒絕投資條款 -**場景**:一個投資人給了你資金 offer,但其中一個條款你接受了會讓你的控制權受損。請打一封給投資人的信。 -- **萃取目標**:談判策略;是否能在拒絕條款的同時維持關係 - -#### V3 — 推進停滯中的合作 -**場景**:你一個關鍵合作夥伴(可能是通路/供應商/技術夥伴)最近回覆越來越慢,你需要讓這個關係重新活起來。 -- **萃取目標**:關係重啟策略;是否帶價值點 - -#### V4 — 公司危機內部溝通 -**場景**:公司剛失去一個大客戶,這件事會影響這個季度的數字。你需要在 30 分鐘後的全員會議說明這件事。 -- **萃取目標**:危機中的透明度與穩定感;是否能同時誠實和提振信心 - -#### V5 — 修復與共同創辦人的關係 -**場景**:你和共同創辦人上週有一次很激烈的爭論,你說了一些話你後來覺得超出了邊界。 -- **萃取目標**:創辦人夥伴修復語言;是否能承認而不失去領導感 - -*** - -### BOUNDARIES 領域化問題(5 題) - -#### B1 — 不可談判的創業底線 -**問題**:「你有沒有一個條件,投資人或客戶提出來你直接說「不行,這個我不談」?那個條件是什麼?」 -- **信號**:核心控制點認知;是否清楚自己的 non-negotiable - -#### B2 — 創辦人個人邊界 -**問題**:「創業之後,你有沒有某段時間覺得「我」消失了,只剩下「公司」?你怎麼重新找回?」 -- **信號**:個人身份與公司身份的邊界;可持續性 - -#### B3 — 不接受的投資人行為 -**問題**:「投資人有沒有做過什麼讓你覺得越界的事?那個越界是什麼?」 -- **信號**:治理邊界;是否有能力在資金關係中維護決策主權 - -#### B4 — 放手授權邊界 -**問題**:「你有沒有一件事,你交給別人做,但到最後還是你親自接手?你後來的判斷是:那次授權是失敗的,還是你的干預是多餘的?」 -- **信號**:創辦人微管理傾向;授權能力與信任門檻 - -#### B5 — 公司存亡決策邊界 -**問題**:「你有沒有想過,如果某件事發生了,你會選擇結束這家公司?那個條件是什麼?」 -- **信號**:創辦人對「退出」的心理準備;是否能清楚說出放棄的邏輯而不只是情緒 - -*** - -### 反感問法警示(Founder / Operator) - -| 反感問法 | 為什麼反感 | 替代問法 | -|----------|-----------|----------| -| 「你的公司現在估值是多少?」 | 太直接、且可能 NDA 保護,易讓受訪者關閉 | 「這個階段你最在意的一個指標是什麼,為什麼是那個?」 | -| 「你有沒有想過放棄?」 | 太情緒化,且易引出英雄敘事式的假答案 | 「你有沒有一個時刻,你認真評估過要轉向或停止某個方向?當時你怎麼決定繼續的?」 | -| 「你怎麼平衡工作和生活?」 | 老套問題,答案通常是「其實沒辦法平衡」或是一個正向假裝 | 「你有沒有一段時間覺得公司讓你消耗到了一個讓你不舒服的程度?你是怎麼注意到的?」 | -| 「你覺得你的競爭優勢是什麼?」 | 易引發 pitch 模式而不是真實反思 | 「你有沒有遇過一個客戶,他在你和競品之間選了你,你後來才知道真正的理由是什麼?」 | -| 「你的創業動機是什麼?」 | 「創業動機」問題幾乎一定得到品牌化答案 | 「在你決定做這件事之前,你有沒有一個讓你最猶豫的風險?是什麼讓你最後還是跨出去了?」 | - -*** - -### 高價值 Persona Anchors(Founder / Operator) - -1. 做決定時有一個「如果這是我今天才看到的機會,我還會做嗎?」的自問,一旦答案是「不」就開始認真考慮放棄。 -2. 資源最緊張時的優先序原則是:先讓公司活下去,增長是有餘力才做的事。 -3. 面對危機的行動順序是:先穩住自己,再穩住團隊,解決問題是第三步。 -4. 和投資人意見不同時,習慣把自己的完整邏輯說清楚,再告訴對方自己的決定,而不是等對方核准。 -5. 文化塑造偏好靠「公開承認自己的錯誤」來示範,而不是靠訂規定。 -6. 有固定與 3-4 個信任者的月會,用來維持在孤獨決策中的判斷力清晰。 -7. 對選錯人有具體的教訓敘事,能說出「下次會多問什麼問題」。 -8. 認為共同創辦人關係需要像合夥契約一樣被維護,不因為熟就省略邊界對話。 -9. 對「不接受的投資條款」有清楚的語言——能說出哪個條件是 dealbreaker 而不只是「感覺不對」。 -10. 對公司和個人身份的邊界有意識,能說出一個具體的方式讓自己在創業高壓期還是保有「自己」的空間。 -11. 在全員溝通策略轉向時,習慣直接說為什麼之前的方向是錯的,而不是用「進化」或「升級」等術語迴避。 -12. 把「讓早期員工離開」視為最難但有時必要的決定,且能說出自己走到那個決定的邏輯。 - -*** - -## 附錄:Domain Pack 使用建議 - -### 疊加使用順序 - -``` -1. 泛用 SOUL / HISTORY / JOURNAL / STATE 骨架(已有) - ↓ -2. 注入對應的 Domain Pack(本文件) - ↓ -3. 使用 persona_anchor 範例作為人格資料庫的初始種子 - ↓ -4. 以 VOICE roleplay 場景萃取真實訊息範本 - ↓ -5. 寫入 VirtualMe persona 儲存層 -``` - -### YAML 轉換提示 - -每個 Domain Pack 的每一題遵循以下 schema,可直接轉換: - -```yaml -- id: "SKILL_ENG_Q1" - domain: "engineer" - dimension: "SKILL" - question: "你最近一次需要在兩個以上技術方案中做選擇,你當時怎麼比較?" - purpose: "萃取決策框架" - expected_anchor_type: "decision_criteria" - follow_up_limit: 2 - stop_condition: "受訪者說出具體的取捨標準或決策觸發點" - aversion_risk: "low" -``` - -### 反感問法總原則 - -跨所有領域,最容易觸發防衛的問法模式: -1. 要求自我貼標籤(「你是 X 型人還是 Y 型人?」) -2. 問分數或自我評分(「你覺得自己幾分?」) -3. 問假設性問題(「你如果遇到 X,你會怎麼做?」,應改為「你有沒有遇到過 X?」) -4. 用行業術語 buzzword 作為問題核心(「你有 X 精神嗎?」) -5. 直接問數字而不問脈絡(「你業績多少?」「你有幾個員工?」) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 0b70ef5..e3385ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "virtualme" -version = "0.1.0" +version = "1.1.0" description = "Interview-driven personal AI agent extraction pipeline" readme = "README.md" requires-python = ">=3.11" diff --git a/scripts/check_moat_hygiene.sh b/scripts/check_moat_hygiene.sh new file mode 100755 index 0000000..05e0e98 --- /dev/null +++ b/scripts/check_moat_hygiene.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +blocked_patterns=( + '(^|/)bait_reaction\.py$' + '(^|/)test_bait_reaction\.py$' + '(^|/)question-pool-v2\.yaml$' + '(^|/)domain-packs-v2\.yaml$' + '(^|/)v2_loader\.py$' + '(^|/)v2_schema\.py$' + '(^|/)TRUNK\.md$' + '(^|/)virtualme-domain-pack-8-fields\.md$' + '(^|/)GEMINI\.md$' + '(^|/)docs/design/' + '(^|/)exports/' + '(^|/)artifacts/' + '(^|/)transcripts/' + '(^|/)\.private/' +) + +tracked="$(git ls-files)" +failed=0 + +for pattern in "${blocked_patterns[@]}"; do + if printf '%s\n' "$tracked" | grep -E "$pattern" >/dev/null; then + printf 'Moat hygiene violation: tracked file matches %s\n' "$pattern" >&2 + printf '%s\n' "$tracked" | grep -E "$pattern" >&2 + failed=1 + fi +done + +if [[ "$failed" -ne 0 ]]; then + exit 1 +fi + +printf 'Moat hygiene check passed.\n' diff --git a/specs/09-interview-engine-v2.md b/specs/09-interview-engine-v2.md deleted file mode 100644 index 22d3f5f..0000000 --- a/specs/09-interview-engine-v2.md +++ /dev/null @@ -1,462 +0,0 @@ -# Interview Engine v2 Draft - -> Status: draft only. This does not replace the production question pool yet. - -## Goal - -Interview Engine v2 should produce usable persona anchors, not just long -conversation transcripts. Every question must know: - -- which dimension it is collecting -- what persona signal it expects -- when to probe -- when to stop -- how risky or tiring the question is -- how to explain its purpose to the interviewee - -The guiding product rule is: if the user asks "why are you asking this?", the -bot must have a short, honest answer. - -## Source Inputs - -This draft combines two scout outputs: - -- Perplexity Max: methodology-first question design, 57 core questions, - anchor type, stop condition, and risk notes. -- Perplexity Max domain pack: complete 8-field domain supplement archived at - `docs/research/virtualme-domain-pack-8-fields.md`. -- SuperGrok: product experience flow, progress-aware resume language, - user-control moments, and repair scripts. -- Strategy reports: - - `/Users/maki/Documents/VirtualMe_開發建議報告書.md` - - `/Users/maki/Documents/VirtualMe_深度策略報告書_v2.md` - - synthesized into [`10-personality-infrastructure.md`](10-personality-infrastructure.md) - -Perplexity is used as the question and method backbone. SuperGrok is used as -the conversation experience layer. - -The strategy reports add one product constraint to v2: the interview must -extract decision style and tradeoff behavior, not only voice or life-story -material. - -## Conversation Flow - -Default order: - -0. INTAKE: ask the interviewee's domain, role, core tasks, counterparties, and - expected VirtualMe use cases. -1. STATE: warm start from current reality. -2. HISTORY: build the timeline and turning points. -3. SOUL: infer values from choices, not labels. -4. PEOPLE: trust, collaboration, influence, and relationship boundaries. -5. SKILL: work method, decision process, craft habits. -6. JOURNAL: reflection, self-correction, and meaning-making. -7. BOUNDARIES: refusal, authorization, and hard limits. -8. VOICE: concrete message samples and roleplay for responder fidelity. - -This order is intentionally different from the old week-based pool. It starts -safe and concrete, then moves inward, then ends with reusable voice samples. -INTAKE is not counted as persona completion. It calibrates placeholders and -keeps the rest of the interview from sounding generic. - -## Intake Calibration - -Before asking persona questions, the bot should collect four setup facts: - -- `domain_role`: what kind of professional / role the person is. -- `core_task`: the main kind of work or decisions they repeatedly handle. -- `primary_counterparty`: who they most often interact with. -- `decision_partner`: who they negotiate priority, scope, budget, or tradeoffs with. - -Example: - -```yaml -domain_role: "AI PoC / TPM / software-adjacent operator" -core_task: "researching AI tools, deciding architecture, and driving PoC delivery" -primary_counterparty: "engineers, stakeholders, and agent collaborators" -decision_partner: "technical partners or business owners deciding scope" -``` - -If the user does not want to define this upfront, the bot should use broad -language and keep asking domain-agnostic questions until enough context is -observed. - -## V2 Question Schema - -```yaml -- id: state_01 - dimension: STATE - stage: warmup - text: "最近這陣子生活過得怎麼樣?有什麼事讓你特別有動力,或是有點壓力?" - purpose: "捕捉當前情緒、壓力源、能量來源、短期目標。" - user_explain: "我想先從你現在的狀態開始,這樣 VirtualMe 回覆時比較貼近你目前的感受。" - expected_anchor: fact - acceptable_answers: - - daily_story - - emotion - - concrete_example - follow_ups: - - "它最近大概佔了你多少心力?" - - "你通常怎麼處理這種狀態?" - follow_up_max: 2 - stop_condition: "取得 1-2 個具體事項與感受,或使用者想換話題。" - risk_level: low - optional: false -``` - -## Stop Condition: Altitude Criterion - -> Added per STG-036 (`~/.claude/mem/staging.md`). Dogfood interviews showed the -> engine stops as soon as the interviewee says anything that *sounds* concrete, -> so it collects worldview platitudes ("命運自有安排", "世事無常", "那是人性") as -> if they were anchors. - -A `stop_condition` is satisfied only when the answer reaches **incident -altitude**, not **aphorism altitude**: - -- **Incident altitude (stop is allowed)**: a specific time, place, person, - quoted line, or a choice made under a named constraint — a decision or - tradeoff with edges. -- **Aphorism altitude (do NOT stop)**: a worldview statement, proverb, or - fate / human-nature generalization. Treat it as a *deflection*, not an answer. - -When an answer is at aphorism altitude, the bot must re-narrow toward a concrete -incident instead of advancing (e.g. "不用是大道理——有沒有一件具體的事,當時你 -怎麼選的?"). This re-narrow does not count against `follow_up_max`. - -> Open design point: the interaction between this re-narrow and the -> disengagement rule ("two short answers in a row → stop probing") is not yet -> resolved. STG-036 implementation must reconcile them. -> -> Open methodology point: distinguishing "deflection into philosophy" from -> "philosophy that is genuinely this person's trait" is unresolved — routed to -> Scout investigation, see `docs/TRUNK.md` §6. - -## Selector Rules - -- Prefer staying inside the current dimension until it reaches the completion - threshold. -- Avoid asking two high-risk questions in a row. -- Do not ask more than two probes for the same question. -- If an answer is at aphorism altitude (see "Stop Condition: Altitude - Criterion"), do not count the question as satisfied; re-narrow toward a - concrete incident. -- If the user gives two short or defensive answers in a row, stop probing and - switch to a safer question. -- If the user asks for progress or purpose, answer that meta question before - continuing. -- If a dimension has enough fact anchors but weak principle anchors, select a - principle question in the same dimension. -- If a dimension has enough anchors but low voice fidelity, move to VOICE - roleplay. - -## Progress Model - -Engine v2 should not rely on anchor count alone. Use three scores: - -- `coverage_score`: active anchors per dimension. -- `scope_score`: how many dimensions have at least one usable anchor. -- `yield_score`: whether anchors include fact, pattern, and principle layers. -- `decision_score`: whether the archive includes explicit tradeoff, pressure, - refusal, and boundary-decision signals. - -Initial implementation can keep the existing weighted completeness score, but -the UI copy should expose only coarse ranges. - -Suggested ranges: - -- 0-20%: warm start, low pressure. -- 20-50%: enough context for early patterns. -- 50-80%: deeper values and boundaries. -- 80-95%: voice samples and final gaps. -- 95%+: recap, correction, and "what feels unlike you?" pass. - -## User Control - -Every 3-4 questions, the bot should offer control: - -- continue -- switch topic -- pause -- ask progress -- correct what feels wrong - -LINE quick replies can implement this later. For now, plain text commands are -enough. - -## Repair Scripts - -When the user says "這不像我": - -> 抱歉,是我理解錯了。你可以直接改我:哪一段不像你?我會把這段標成需要重訪談,不會拿它當定論。 - -When the user says "你問這幹嘛": - -> 這題是想補【{dimension}】裡的 {purpose_short}。如果你覺得不重要,我們可以跳過,完全沒問題。 - -When the user says "剛剛不是講過了": - -> 對,你前面有提到 {recap}。我原本是想從另一個角度確認;如果已經夠清楚,我們直接換下一題。 - -When the user shows fatigue: - -> 我們先停在這裡。你前面說的已經足夠保存,之後可以從這題接,不需要重來。 - -## Migration Plan - -1. Add `question-pool-v2.yaml` as draft data only. -2. Add intake command/state for domain calibration. -3. Add `domain-packs-v2.yaml` as optional domain-specific overlays. -4. Add a loader that accepts v2 metadata while keeping old `Question` usable. -5. Add tests for placeholder-free text, risk metadata, and anchor target fields. -6. Add selector v2 behind a feature flag. -7. Dogfood v2 with one interviewee before replacing production default. - -## Domain Packs - -Domain packs should not replace the eight-dimension persona backbone. They only -specialize the dimensions where professional context matters most: - -- SKILL -- PEOPLE -- VOICE -- BOUNDARIES - -The current draft packs are in `src/virtualme/data/domain-packs-v2.yaml`. -All 8 Perplexity domain packs have been normalized into structured YAML: - -- `engineer_ai_builder` -- `sales_bd` -- `pm_tpm` -- `consultant` -- `manager_people_lead` -- `creator_writer` -- `teacher_coach` -- `founder_operator` - -Each pack includes domain metadata, 8 SKILL questions, 5 PEOPLE questions, -5 VOICE roleplays, 5 BOUNDARIES questions, 5 bad-question alternatives, and -12 persona anchor examples. - -## Next Implementation Plan - -This is the proposed next sequence. The intent is to avoid breaking the current -LINE bot while making v2 concrete enough to dogfood. - -### Phase 1: Preserve And Normalize Research Data - -Status: done for the first structured draft. - -Inputs already saved: - -- `src/virtualme/data/question-pool-v2.yaml` -- `src/virtualme/data/domain-packs-v2.yaml` -- `docs/research/virtualme-domain-pack-8-fields.md` - -Completed work: - -1. Converted the complete Perplexity 8-field domain pack into - `domain-packs-v2.yaml`. -2. Preserved the raw Perplexity source in - `docs/research/virtualme-domain-pack-8-fields.md`. -3. Added YAML parse tests and content checks: - - all 8 domain packs exist - - each has 8 SKILL questions - - each has 5 PEOPLE questions - - each has 5 VOICE roleplays - - each has 5 BOUNDARIES questions - - each has 5 bad-question alternatives - - each has at least 10 persona anchor examples - - no placeholder such as `{decision_partner}` leaks to user-facing text - -Acceptance criteria: - -- `ruff check src tests` passes. -- tests prove both `question-pool-v2.yaml` and `domain-packs-v2.yaml` parse. -- production selector still uses the old pool unless a feature flag is enabled. - -### Phase 2: Add V2 Data Models And Loader - -Add typed models for v2 data without changing the runtime interview flow. - -Suggested files: - -- `src/virtualme/interview/v2_schema.py` -- `src/virtualme/interview/v2_loader.py` -- `tests/unit/test_interview_v2_loader.py` - -Models should include: - -- `V2Question` -- `V2DimensionConfig` -- `V2QuestionPool` -- `DomainPack` -- `DomainPackQuestion` -- `VoiceRoleplay` -- `BadQuestionAlternative` - -Loader responsibilities: - -- parse `question-pool-v2.yaml` -- parse `domain-packs-v2.yaml` -- validate required metadata -- merge domain pack overlays into the generic v2 pool for SKILL / PEOPLE / - VOICE / BOUNDARIES -- keep user-facing text placeholder-free - -Acceptance criteria: - -- loader can load generic v2 only -- loader can load generic v2 + `engineer_ai_builder` -- loaded questions retain `purpose`, `user_explain`, `expected_anchor`, - `follow_up_max`, `stop_condition`, and `risk_level` -- no production route imports v2 loader yet - -### Phase 3: Intake Calibration - -Before v2 persona extraction starts, the bot must collect domain context. - -Required captured fields: - -- `domain_role` -- `core_task` -- `primary_counterparty` -- `decision_partner` -- `virtualme_use_case` - -Storage options: - -- short term: add columns or JSON notes to `subjects` -- cleaner long term: new `subject_profile` / `subject_context` table - -Decision needed before implementation: - -- whether v2 intake is stored as structured columns, JSON, or anchors. - -Recommended first implementation: - -- store a JSON object in a new table keyed by `interviewee_id` -- do not count intake toward persona completeness -- let the user update it later with a command like `更新我的領域` - -Acceptance criteria: - -- a fresh v2 run asks intake before STATE -- if intake is already complete, the bot skips intake and starts STATE -- status reply can show the captured domain context - -### Phase 3.5: Decision And Tradeoff Extraction Metadata - -Before v2 is used in production, generic and domain questions should identify -whether they collect decision-style signal. - -Recommended metadata: - -```yaml -decision_targets: - - tradeoff_hierarchy - - pressure_response - - refusal_condition - - escalation_threshold - - stable_contradiction -``` - -Initial implementation can keep this metadata optional. The selector should -eventually prefer questions that fill missing decision targets when anchor -coverage is high but decision signal is weak. - -Acceptance criteria: - -- at least one question per relevant dimension collects a tradeoff or pressure - signal -- status/completeness can report when decision signal is still weak -- no v2 production switch until decision targets exist in the draft pool - -### Phase 4: V2 Selector Behind Feature Flag - -Add a feature flag such as: - -```env -VIRTUALME_INTERVIEW_ENGINE=v1|v2 -``` - -V2 selector rules: - -- stay within a dimension until it reaches its threshold -- avoid repeated purpose or repeated wording -- avoid two high-risk questions in a row -- respect `follow_up_max` -- after 3-4 questions, offer control and progress -- when a dimension completes, give a short recap and transition -- if the user asks "why", answer with `user_explain` -- if the user says "not me", mark that area for correction instead of arguing - -Acceptance criteria: - -- v1 remains the default -- v2 can be enabled locally by env var -- no existing v1 tests regress -- v2 has targeted tests for: - - first question is intake - - domain context changes SKILL / PEOPLE / VOICE / BOUNDARIES wording - - high-risk questions are not consecutive - - repeated questions are avoided - - decision-target gaps influence selection after basic coverage exists - -### Phase 5: Conversation Experience Layer - -Implement the parts that reduce boredom and increase ownership. - -Required behaviors: - -- answer after each user turn with a tiny recap before asking the next question -- explain the purpose when useful, but do not over-explain every turn -- show progress in coarse language -- provide control every 3-4 questions -- ask "does this sound like you?" after dimension recaps -- support correction commands: - - `這不像我` - - `你問這幹嘛` - - `剛剛不是講過了` - - `跳過` - - `今天先到這` - -Acceptance criteria: - -- transcript no longer feels like a questionnaire -- bot can state current dimension, purpose, and approximate progress -- bot stops probing when the user becomes defensive or gives two short answers -- dimension recap produces 2-4 candidate persona anchors, not a long summary - -### Phase 6: Dogfood And Switch - -Dogfood sequence: - -1. Run v2 locally or on a staging flag for one clean interview. -2. Ask status every few turns and verify: - - current dimension is correct - - progress is plausible - - purpose answer is clear - - no placeholder leaks -3. Export persona anchors and manually inspect: - - are anchors specific enough? - - can a responder use them? - - are there contradictions or generic labels? -4. Only then enable v2 on the VPS bot. - -Production switch criteria: - -- v2 produces better persona anchors than v1 from the same amount of user effort -- the user can explain why the bot is asking each question -- the user does not feel trapped in endless probing -- the bot can recover from correction without becoming defensive - -## Explicit Non-Goals For The Next Pass - -- Do not replace production immediately. -- Do not add a web UI. -- Do not implement LINE quick replies before text commands work. -- Do not generate synthetic persona summaries until anchors are good. -- Do not add all possible professions as separate full question pools. -- Do not let domain packs override SOUL / HISTORY / JOURNAL / STATE unless a - real dogfood transcript proves the generic questions fail. diff --git a/specs/10-personality-infrastructure.md b/specs/10-personality-infrastructure.md index e273de8..899852a 100644 --- a/specs/10-personality-infrastructure.md +++ b/specs/10-personality-infrastructure.md @@ -4,9 +4,9 @@ > change the production interview runtime, and nothing in it authorizes work on > its own. > -> Near-term priority and "what is on the trunk right now" are governed by -> `docs/TRUNK.md`. Any "Future Module" below stays unbuilt until it passes the -> Trunk Check in `docs/TRUNK.md` §5. On conflict, TRUNK.md wins. +> Near-term priority and "what is on the trunk right now" are governed by the +> project's internal roadmap. Any "Future Module" below stays unbuilt until it +> passes the internal roadmap's trunk check. On conflict, the roadmap wins. ## Thesis @@ -77,15 +77,15 @@ Recent dogfood produced some candidate raw material: Caveat: these are unverified candidates, not evidence the engine is working well. In the dogfood transcript several of them — especially the fatalistic framing — appeared *because the bot accepted a worldview platitude and stopped -probing*, i.e. they sit at aphorism altitude, not incident altitude (see -`09-interview-engine-v2.md` > Stop Condition: Altitude Criterion). Whether +probing*, i.e. they sit at aphorism altitude, not incident altitude (an +altitude criterion documented in internal methodology notes). Whether fatalism is a genuine trait of this interviewee or just a deflection cannot be decided from the current transcript. Each candidate must be re-drilled per STG-036 before it becomes persona material. [未定論] The general question — how to tell "deflection into philosophy" from "philosophy that is genuinely the person's trait" — is routed to Scout -investigation; see `docs/TRUNK.md` §6. +investigation (tracked in the internal roadmap). ## Main Product Risks @@ -126,13 +126,13 @@ The same persona archive will behave differently across models: This is a real challenge, but it is NOT a near-term risk and NOT in scope for interview engine v2. It cannot be meaningfully addressed before single-model -fidelity is proven (see Future Modules > Model Adapter Layer, and -`docs/TRUNK.md` §4 trap #8). +fidelity is proven (see Future Modules > Model Adapter Layer, and the +internal roadmap's detour-trap list). [未定論] Whether cross-model portability should be designed in early or retro- fitted late is a genuine open sequencing question. The judgement that it is "a later problem" comes from a Claude-authored analysis and therefore carries some -perspective bias. Routed to Scout investigation; see `docs/TRUNK.md` §6. +perspective bias. Routed to Scout investigation (tracked in the internal roadmap). ## Future Modules @@ -144,7 +144,7 @@ perspective bias. Routed to Scout investigation; see `docs/TRUNK.md` §6. > > The Model Adapter Layer and Fidelity Benchmark System are DEFERRED: they must > not be started until single-model persona fidelity has been demonstrated. -> Starting them earlier is a detour — see `docs/TRUNK.md` §4 trap #8. +> Starting them earlier is a detour (see the internal roadmap's detour-trap list). ### Decision Style Engine diff --git a/specs/11-constitution.md b/specs/11-constitution.md new file mode 100644 index 0000000..0886e5f --- /dev/null +++ b/specs/11-constitution.md @@ -0,0 +1,244 @@ +# VirtualMe Constitution v1.1 — Stability & Restraint Principles + +> **Status**: Ratified by Chair (Maki) 2026-05-20 +> **Date**: 2026-05-20 +> **Supersedes**: 散落於 `docs/TRUNK.md` / `specs/05-boundaries-and-pii.md` / milestone notes 的隱性 stance(無 v1.0 單一文件) +> **Council inputs**: Seven-agent council 2026-05-20(synthesis at `~/Documents/agent-council/virtualme/SYNTHESIS.md`) + +--- + +## 0. Preamble — 為什麼有這份文件 + +VirtualMe 在 v1.0.0 已具備功能基線:結構化訪談、多層 anchor 萃取、可生成詳細 persona representation。技術能力推進到此,**最大風險已從「不夠深」轉為「過度伸手」**——premature crystallization、identity overreach、erosion of human agency。 + +具體事故 anchor:**2026-05 pilot 中,訪談 bot 越過受訪者明說的情緒邊界,撬出痛苦的個人揭露**(記錄於 `.claude/agent-notes/milestone.md` #16)。這次事件確認:訪談能力本身不會自動避免傷害,必須有顯式的克制機制。 + +本文件把先前散落於各 spec 與 milestone 的「謹慎、克制、有敬畏」立場 codify 為**可被工程化執行**的六條原則。每條原則包含: +- **Principle Text**(規範文字) +- **Intent**(為什麼) +- **M1 Hard Gate**(v1.1 real-user deploy 前必過) +- **Iterative Scope**(M2/M3 推進) + +本憲法**不取代** `specs/05-boundaries-and-pii.md`(PII / consent / 7 條 informed consent);兩者互補——specs/05 管「不踩 PII / 不冒充治療」的底線;本文件管「萃取 / 合成 / 對外行動」過程中的人格克制。 + +--- + +## 1. The Six Principles + +### P1. State-Trait Separation + +**Principle**: +系統必須在 schema 與 runtime 層級維持「transient state」與「enduring trait」的嚴格區分。Single-session 或單一情境的觀察**不得**寫入穩定 persona representation 為 `validated trait`;只能標 `state` 或 `tentative hypothesis`,附 provenance 與待補 evidence。 + +**Intent**: +人格心理學共識是 trait 為 state 的密度分佈(Whole Trait Theory),跨情境簽名為其表現(CAPS model)。8 週訪談屬「low session count」情境;自陳 vs 行為觀察 convergent validity 僅 r=0.11–0.31。沒有強約束會把當下情緒寫成「這個人是怎樣」。 + +**M1 Hard Gate**(real-user prod 前必過): +- synthesis / export 階段,所有 `Dimension.STATE` 來源 anchor 不得 render 為 SOUL/VOICE/SKILL/BOUNDARIES Core Truths +- 任何 single-session 來源 anchor 必須標 `tentative` 或 `hypothesis`,不得標 `validated` +- contract test 覆蓋:給單一「最近很累」turn → 不得出現於 SOUL.md Core Truths + +**Iterative (M2+)**: +- 完整 state/trait promotion pipeline(TTL、cross-context score、ESM-style 情境多樣性 prompt) +- 與 SYNTHESIS-2026-05-18 E11 `target_layer + evidence` 整合 + +--- + +### P2. Contradiction Preservation + +**Principle**: +系統必須**主動保留**未解的 tension、internal contradiction、不一致 self-description;不得過早 reconcile / 合理化 / 平滑化。矛盾本身被視為人格的有意義組成。當使用者**主動要求**整合詮釋時,方可協助 collapse;系統 default 為並列(juxtaposition),而非合理化(rationalization)。 + +**Intent**: +McAdams narrative identity 框架認可矛盾為 narrative 演化的主動力;naive dialecticism(東亞傳統)視 contradiction tolerance 為自然認知風格。VirtualMe 是 representation tool,不是治療工具——DBT 的辯證 synthesis 是治療目標,與本文件 scope 不同。 + +**商業上會被質疑**:市場偏好「clarity / consistent personality」。本原則明知商業張力存在仍保留——這是 VirtualMe 的差異化主張之一(對應 `docs/TRUNK.md` §1.2「非黑盒、訪談 > 填表」),並以 P5 化解:使用者要 clarity 時可主動 collapse。 + +**M1 Hard Gate**: +- extractor / synthesis 發現新 anchor 與既有 anchor 語意對立時,**禁止覆寫、禁止 merge**;兩者皆保持 `active` +- synthesis renderer 若輸出 SOUL-lite,必須有 `## Unresolved Tensions` 區塊,**禁止**只產出單一調和結論 +- 「假裝有做」防線:並列兩句不點出 tension 視為違規;renderer 必須顯式 mark `(tension between W2 and W5)` + +**Iterative (M2+)**: +- 顯式 `contradictions` table + `conflict_group_id`(對齊 SYNTHESIS-2026-05-18 E14 Contradiction Buffer) +- contradiction lifecycle(unresolved / contextualized / withdrawn) + +--- + +### P3. Reflective Restraint + +**Principle**: +系統的 reflective / interpretive output 的**頻率、深度、framing**必須被 govern。 +- 預設**禁止 unsolicited reflection**(系統主動丟「我發現你其實……」類詮釋) +- 在 trauma-relevant context(疲憊 / 拒絕 / 高風險 / 哀傷 / 自殘 / 自殺意念)下**禁止自動加深**,即使使用者主動邀請 +- 在非 trauma context 且使用者**明確主動邀請**時,系統可提供 reflective output(permission-gated honor),但仍受 reflection budget 約束、必須 hedging、引用 subject 原話、禁止心理學標籤化 + +**Intent**: +SAMHSA TIP 57 trauma-informed care:unsolicited reflection 屬 contraindicated。Rogers 的 reflection 需 therapist congruence(真實存在),AI 無法複製。2026-05 #16 incident 是此原則的直接事故 anchor。Character.AI 2024-2026 連環訴訟(含未成年自殺案、Google 1 月和解)+ CA SB 243(2026-01 生效)+ APA 找 FTC 是監管動向背景。 + +**Counter-evidence acknowledged**: Woebot / Wysa 8 週 RCT 顯示 structured CBT chatbot 有效——但那是**結構化 CBT 介入**,非 open-ended persona reflection,不可直接類比。 + +**M1 Hard Gate**: +- reasoner output schema 增加 restraint assertion:在 `BoundaryStatus=blocked` / `EngagementState=fatigued|refusing` / `risk_level=high` 時,**禁止** interpretive reflection +- `reflection_note` field 預設 internal-only(audit log),不直接外送 +- contract test:trauma / fatigue / refusal fixture 下,reply 不得包含診斷、人格判定、成長敘事 +- probe cap 到達時必須 stop / advance 並記 reason metadata(對齊 E15) + +**Iterative (M2+)**: +- reflection budget per session(quota + 觸發條件量化) +- Interpretive Adjective Density (IAD) 監測指標 +- Post-session dashboarding:將「系統觀察」從對話流移至 snapshot review 階段 + +--- + +### P4. Multi-Session Pattern Validation + +**Principle**: +任何「stable personality pattern / trait」的宣稱,須由**多 session 跨情境**的 recurrence + consistency 驗證。 + +**M1 minimum (negative constraint)**: Single-session 來源的 anchor **不得**被標為 `validated`;只能存在為 `tentative` / `hypothesis` / `draft` 等顯式 unvalidated 狀態,並附 provenance 與 missing evidence 說明。 + +**M2 full gate (quorum)**: 完整 promotion threshold 於 M2 訂定(候選方案:≥2 sessions + ≥3 unique question_ids;或 ≥2 sessions + cross-context evidence)。 + +**Intent**: +NEO-PI-R 6-year retest r=0.63–0.91;longitudinal personality 研究通常 3–7 年觀察。LLM persona multi-session validation 方法論仍在建立中(TwinVoice、BehaviorChain 為早期 benchmark)。P4 屬「設計保守主義 + 超前業界」,這是優點而非弱點。BehaviorChain (ACL 2025) 顯示 LLM 長序列行為 fidelity 隨 session 下降——P4 本身是有效對沖。 + +**M1 Hard Gate**: +- `save_anchor` / synthesis 階段,single-session 來源 anchor 路徑禁止標 `validated` +- export wording:未達 quorum 的 anchor 必須以「目前觀察到 / tentative / 待驗證」等語氣呈現,禁止用「You are ...」「Your trait is ...」斷言式 +- contract test:3 個來自同一 session 的 anchor 即使 question_id 不同,不得進入 `validated` + +**Iterative (M2+)**: +- 完整 promotion pipeline(session count / time span / context diversity score) +- 與 P1 整合的 state→trait promotion gate +- 與 E11 `LayerGateResult` 整合 + +--- + +### P5. Self-Correction and Agency + +**Principle**: +Persona representation 必須**結構性、程序性**對 subject 開放:modification、denial、supplementation、active rebuttal。系統**不得**自我定位為 final / authoritative interpreter。Subject 是其 narrative identity 的**首要作者**。 + +**Operational requirements**: +- 每條 persona claim 須附 (a) stability tier(state / tentative / recurring / validated)(b) subject-controlled rebuttal slot (c) version history +- 任何 export / snapshot 須包含可操作的 correction affordance(不只是 markdown 留欄位) +- 系統用語 strictly hedge:「目前觀察到」「根據訪談 W2-W5」「待驗證」;禁止「You are」「Your true self is」式斷言 + +**Intent**: +- GDPR Art 16 (rectification) + Art 17 (erasure) + Art 22 (automated decision-making) 直接適用 derived persona model +- Taiwan PDPA 第 3 條:查閱 / 複製 / 補充 / 更正 / 停止 / 刪除請求權;2026 PDPC 委員會強化執法 +- McAdams:narrative identity 是 internalized and evolving;subject 為首要作者 +- Psychology Today 2024:「AI identity theft」心理危害 +- Frontiers in Psychology 2025:Algorithmic Self 形成 identity feedback loops +- 競品壓力:OpenHuman (Product Hunt #3 2026 Q2) 主打「memory 屬於使用者」——VirtualMe 必須 match or exceed + +**M1 Hard Gate**: +- snapshot / export 全程使用 hedge wording(不可出現 unhedged stable trait 斷言) +- `unlike_me` review 必須能 block downstream promotion(即使原本 multi-session validated) +- 至少一個可操作的 rebuttal 入口(CLI command / LINE 指令 / markdown comment 機制) +- 既有 `restart_interview` / `restart_dimension` / snapshot review (`like_me/unlike_me/unsure/missing_context`) 不得退化 + +**Iterative (M2+)**: +- `persona_corrections` table:targeted anchor-level rebuttal、status lifecycle +- **Versioning > overwrite**:修正留版本歷史(呼應 P2 矛盾保留;防 uninformed self-editing 引新偏誤) +- Export manifest 記錄 correction state + +--- + +### P6. Provenance, Confidence & Temporal Decay + +**Principle**: +每條 persona claim 必須攜帶三個 metadata 欄位: + +| 欄位 | 內容 | 對應問題 | +|---|---|---| +| **provenance** | source session_id / timestamp / raw quote ref | 這個 claim 從哪來? | +| **confidence_tier** | `state` / `tentative` / `recurring` / `validated` | 系統對它多有信心? | +| **observed_at + staleness_window** | 首次觀察時間 + 信心週期 | 它多舊了?是否需 review? | + +無此三欄位的 claim 不得 surface 到 SOUL / VOICE / BOUNDARIES export。Staleness window 超時的 trait **自動降級為「歷史紀錄」**而非「當前特質」,並觸發定期 review 邀請。 + +**Intent**: +- **Provenance**: P5 (agency) 的前提——使用者要 rebut 必須知道 claim 從哪來 +- **Confidence tier**: P1 / P4 的 surface 表現——使用者一眼能看出哪些是穩定的、哪些是 hypothesis +- **Temporal decay**: NEO-PI-R 6 年 retest A=0.63 顯示 trait 仍會漂移;narrative identity 是「evolving」;persona archive 不能當靜態事實 + +**M1 Hard Gate (deferred)**: P6 預設**屬於 Iterative scope(M2+)**。M1 不必落完整三欄位,但 export wording 已被 P5 hard gate 約束為 hedged,故不會出現未標來源的 unhedged 斷言。 + +**Iterative (M2+)**: +- schema 加 provenance / confidence_tier / observed_at / staleness_window 欄位 +- staleness auto-downgrade(cron 或 lazy check) +- review 邀請流程:staleness 超時 → 提示 subject「2 週前我們聊到 X,這還適用嗎?」 + +--- + +## 2. Cross-Cutting Requirements(跨原則) + +### 2.1 P5 cuts across all +P1 / P2 / P3 / P4 / P6 的任何 enforcement 必須**保留 subject override 路徑**。P5 是所有 promotion / classification / rendering 決策的最終 veto。 + +### 2.2 P3 vs P5 衝突解(permission-gated honor) +使用者明確主動要求 deep reflection 時: +1. 若處於 trauma-relevant context(疲憊 / 拒絕 / 高風險 / 哀傷 / 自殘 / 自殺意念)→ **不 honor**,系統回應「我們暫停一下」並依 `specs/05` §5 處理 +2. 若非 trauma context → **permission-gated honor**:可提供 reflective output,但 (a) hedging 語氣 (b) 引用 subject 原話 (c) 禁止心理學標籤化 (d) 仍受 reflection budget 約束 + +### 2.3 P4 vs P5 衝突解 +若系統 validation 過、使用者否認 → **P5 勝**。Validation 是預設、否認是 override。系統不得「辯護」、不得「但根據過去 8 週模式」式回應;應 honor 修正並可詢問「我想理解你說的 X 是指什麼?」(探索而非辯論)。 + +### 2.4 P2 vs P3 衝突解 +P3 restraint 不得讓 P2 contradiction 變隱形。Renderer 必須**顯式 mark tension**(如 `## Unresolved Tensions` 區塊),這不算違反 P3——標註存在性 ≠ 詮釋意義。 + +### 2.5 與 specs/05 的邊界 +- specs/05 管 PII / informed consent / 7 條 / crisis exit / tag-based filtering +- 本文件管 extraction / synthesis / export 中的人格克制 +- 衝突時:specs/05 的 crisis exit / informed consent 優先(最高底線) + +--- + +## 3. 治理(Governance) + +### 3.1 修訂程序 +本文件屬 Layer 5 治理層(對應 `~/.claude/CLAUDE.md` §3 civilization-stack)。任何修訂須走 Council Protocol: +- risk_level >= high → Council deliberation required +- Default Dissenter: Codex +- Chair: Maki +- Output: 帶 evidence ID 的 synthesis + ratify record + +### 3.2 與其他文件的關係 +- **`docs/TRUNK.md`** 是主幹與路線圖;本文件提供主幹之上的人格克制憲法 +- **`specs/05-boundaries-and-pii.md`** 是 PII / informed consent 底線;本文件補上「同意之後」的克制機制 +- **`.claude/agent-notes/milestone.md`** 記錄事故 anchor(如 #16);本文件回應這些 anchor + +### 3.3 Open Questions(v1.1 未決,留待後續 council) +1. P4 完整 quorum threshold 的數字(≥2 sessions + ≥3 question_ids 是初步候選,需 M1 dogfood 後再 ratify) +2. P2 commercial narrative:是否要在 README / 對外材料明說「我們刻意不做 clarity 收斂」作為差異化主張 +3. specs/05 vs BYOK CONSENT_REPLY 文案落差:v1.1 是否要硬定 specs/05 的 7 條為 single source of truth +4. P6 自動 staleness window:天數 / session 數 / 使用者主動觸發三者的優先序 + +--- + +## 4. Ratify Record + +| 角色 | Agent | 狀態 | 簽署日期 | +|---|---|---|---| +| Chair | Maki | ✅ Ratified | 2026-05-20 | +| Architect | Claude | ✅ 草案完成 | 2026-05-20 | +| Engineer / Default Dissenter | Codex | ✅ 工程審查 + 2 條 dissent 已納入 | 2026-05-20 | +| Analyst | Gemini | ✅ 設計空間分析 + P6 提案 | 2026-05-20 | +| Local Brain | gemma4 | ✅ 邊界 case 模擬 10 場 | 2026-05-20 | +| Scout-1 (Literature) | Perplexity Max | ✅ 67 學術引用 + P6 提案 | 2026-05-20 | +| Scout-2 (Real-time) | SuperGrok | ✅ 2026 監管 + 競品情報 | 2026-05-20 | + +Council 完整證據鏈:`~/Documents/agent-council/virtualme/SYNTHESIS.md` + +--- + +## 5. Version History + +- **v1.1** (2026-05-20): 首次 codify。Council ratified(7-agent)。將先前散落於 TRUNK / specs/05 / milestone 的人格克制立場明文化為六條 + 跨原則衝突解。 +- **v1.0** (前): 隱性 stance,散落於各 spec 與 milestone,無單一憲法文件。 + +--- + +End of constitution v1.1 DRAFT. diff --git a/src/virtualme/__init__.py b/src/virtualme/__init__.py index 3dc1f76..6849410 100644 --- a/src/virtualme/__init__.py +++ b/src/virtualme/__init__.py @@ -1 +1 @@ -__version__ = "0.1.0" +__version__ = "1.1.0" diff --git a/src/virtualme/config.py b/src/virtualme/config.py index ae1bf34..4740bc2 100644 --- a/src/virtualme/config.py +++ b/src/virtualme/config.py @@ -69,6 +69,22 @@ class Settings(BaseSettings): "./data/personas", validation_alias=AliasChoices("persona_export_dir", "VIRTUALME_PERSONA_EXPORT_DIR"), ) + reasoning_turn_enabled: bool = Field( + default=False, + validation_alias=AliasChoices("reasoning_turn_enabled", "REASONING_TURN_ENABLED"), + ) + reasoning_test_user_ids: str = Field( + default="", + validation_alias=AliasChoices("reasoning_test_user_ids", "REASONING_TEST_USER_IDS"), + ) + reasoner_model_name: str | None = Field( + default=None, + validation_alias=AliasChoices("reasoner_model_name", "REASONER_MODEL_NAME"), + ) + reasoner_prompt_file: str | None = Field( + default=None, + validation_alias=AliasChoices("reasoner_prompt_file", "REASONER_PROMPT_FILE"), + ) snapshot_export_dir: str = Field( "./exports", validation_alias=AliasChoices("snapshot_export_dir", "VIRTUALME_SNAPSHOT_EXPORT_DIR"), @@ -81,12 +97,26 @@ class Settings(BaseSettings): ), ) line_snapshot_export_user_ids: str = Field( - "", + default="", validation_alias=AliasChoices( "line_snapshot_export_user_ids", "VIRTUALME_LINE_SNAPSHOT_EXPORT_USER_IDS", ), ) + persona_download_base_url: str | None = Field( + default=None, + validation_alias=AliasChoices( + "persona_download_base_url", + "VIRTUALME_PERSONA_DOWNLOAD_BASE_URL", + ), + ) + persona_download_expiry_minutes: int = Field( + 60, + validation_alias=AliasChoices( + "persona_download_expiry_minutes", + "VIRTUALME_PERSONA_DOWNLOAD_EXPIRY_MINUTES", + ), + ) def sqlite_path(database_url: str) -> str: diff --git a/src/virtualme/data/domain-packs-v2.yaml b/src/virtualme/data/domain-packs-v2.yaml deleted file mode 100644 index 5a5ee66..0000000 --- a/src/virtualme/data/domain-packs-v2.yaml +++ /dev/null @@ -1,1374 +0,0 @@ -version: 2 -status: draft -production_enabled: false -source: docs/research/virtualme-domain-pack-8-fields.md -packs: - engineer_ai_builder: - name: Engineer / AI Builder - source: perplexity_max_domain_pack_v1 - domain_role: - - 軟體工程師、AI 工程師、ML 工程師、系統架構師、Full-Stack 開發者 - core_task: - - 需求拆解與技術選型 - - 設計與實作系統架構 - - Debug、效能調優、技術債取捨 - - 評估 AI/模型方案可行性 - primary_counterparty: - - PM、設計師、QA、其他工程師 - decision_partner: - - Tech Lead、架構師、CTO、資深工程師 - skill_questions: - - id: engineer_ai_builder_skill_01 - title: 技術選型判斷 - text: 你最近一次需要在兩個以上技術方案中做選擇,你當時怎麼比較?最後是什麼讓你拍板? - purpose: 萃取決策框架——是靠直覺、經驗、文件驗證還是小 PoC;是否考量團隊能力與長期維護成本 - expected_anchor: 「遇到不確定時偏好先跑 spike 而非靠直覺」;「決策時把遷移成本排在前三考量」 - follow_up_max: 2 - stop_condition: 受訪者說出具體的取捨標準或決策觸發點後停止 - risk_level: low - - id: engineer_ai_builder_skill_02 - title: Debug 行為模式 - text: 描述一個讓你卡最久的 bug,你從什麼地方開始找起? - purpose: 觀察系統思維深度——是否分層假設、是否會先縮小 scope、遇到死路如何轉向 - expected_anchor: 「傾向先寫復現腳本而不是直接翻 code」;「習慣先排除環境因素再看邏輯」 - follow_up_max: 2 - stop_condition: 取得「何時放棄 solo 轉向求助」的行為邊界 - risk_level: low - - id: engineer_ai_builder_skill_03 - title: 技術債取捨 - text: 你有沒有做過一個決定,明知道現在這樣寫是欠債,但還是先這樣?你怎麼跟自己交代? - purpose: 萃取對品質與速度的真實排序;是否能坦然承認取捨 - expected_anchor: 「只要有明確的還債時間點,技術債可以接受」;「不記錄的技術債比技術債本身更危險」 - follow_up_max: 1 - stop_condition: 受訪者說出自己的「可接受技術債條件」 - risk_level: medium - - id: engineer_ai_builder_skill_04 - title: AI/模型落地判斷 - text: 你評估過一個 AI 方案不適合在現在的產品裡用,你當時的理由是什麼? - purpose: 測試是否只追新技術,還是能判斷 AI 落地條件(資料、延遲、解釋性、維護成本) - expected_anchor: 「AI 方案上線前必須有可觀測的回退機制」;「eval 不過不上 production」 - follow_up_max: 2 - stop_condition: 取得具體否決條件 - risk_level: low - - id: engineer_ai_builder_skill_05 - title: Code Review 風格 - text: 你給別人 code review 的時候,你最在意的是什麼?有沒有你幾乎每次都會提的點? - purpose: 萃取工程價值觀——偏重可讀性、正確性、效能、一致性還是安全 - expected_anchor: 「review 時最先看錯誤處理,而不是算法是否最優」;「不接受沒有測試的新邏輯進主幹」 - follow_up_max: 1 - stop_condition: 取得至少一條明確的 review 原則 - risk_level: low - - id: engineer_ai_builder_skill_06 - title: 文件與溝通習慣 - text: 你最近一次需要讓非工程師理解一個技術問題,你用什麼方式說明的? - purpose: 測試跨功能溝通能力;是否有意識地調整抽象層次 - expected_anchor: 「遇到非技術對象習慣先問他們最在意的是什麼,再決定從哪個角度講」 - follow_up_max: 1 - stop_condition: 取得受訪者的溝通轉換策略 - risk_level: low - - id: engineer_ai_builder_skill_07 - title: 自主學習邊界 - text: 你是怎麼決定一個技術值不值得你花時間深入學?你有沒有放棄過某個你原本很想學的東西? - purpose: 萃取學習投資邏輯——是否以實戰觸發學習,還是廣泛探索;是否能放棄 - expected_anchor: 「沒有真實使用場景的技術,學到能看懂就停」;「偏好先動手做壞,再回頭補理論」 - follow_up_max: 1 - stop_condition: 取得「值得深學 vs 淺嚐」的判斷標準 - risk_level: low - - id: engineer_ai_builder_skill_08 - title: 上線與風險容忍 - text: 你有沒有在不是百分之百確定的情況下 deploy 過?那次你怎麼評估風險? - purpose: 萃取對不確定性的耐受度與風險管理習慣(canary、rollback plan、feature flag) - expected_anchor: 「沒有 rollback 計畫不上線」;「小流量灰度是預設,不是奢侈品」 - follow_up_max: 2 - stop_condition: 取得具體的上線前檢查清單或決策標準 - risk_level: low - people_questions: - - id: engineer_ai_builder_people_01 - title: 技術影響力 - text: 你有沒有推過一個大家原本不想接受的技術方向,最後成功了?你怎麼讓人信服的? - signal: 影響策略——靠數據、PoC、一對一說服還是公開展示 - - id: engineer_ai_builder_people_02 - title: 技術衝突 - text: 你和某個工程師對實作方式有根本分歧,兩個人都覺得對方的方案有問題,最後怎麼收的? - signal: 衝突解法——技術中立評估、訴諸 lead、各退一步、硬幹看誰對 - - id: engineer_ai_builder_people_03 - title: 協作信任邊界 - text: 你有沒有不太願意和某種工程師合作?是什麼行為讓你有這種感覺? - signal: 合作底線——是否容忍 blame、不寫文件、只管自己的 scope - - id: engineer_ai_builder_people_04 - title: 非工程師協作 - text: PM 或設計師提出你認為技術上不可行或不值得做的需求,你通常怎麼反應? - signal: 跨職能邊界管理;是否會包含理由、是否願意找替代方案 - - id: engineer_ai_builder_people_05 - title: 帶新人/傳承 - text: 你帶過新人 onboard 嗎?你覺得讓新人最快進入狀況的方法是什麼? - signal: 知識傳遞偏好——靠文件、靠 pairing、靠任務下水;是否享受帶人 - voice_roleplays: - - id: engineer_ai_builder_voice_01 - title: 正式技術同步 - text: 你需要向 PM 說明下週 sprint 有一個功能來不及,但你已有解法。請打一段 Slack 訊息。 - extraction_target: 主動程度、是否帶替代方案、是否預先管理期望 - - id: engineer_ai_builder_voice_02 - title: 拒絕不合理需求 - text: PM 要你在沒有任何設計稿的情況下,明天 demo 前先做一個「先做 UI 再說」版本。 - extraction_target: 拒絕方式、是否帶條件、語氣是否有邊界感 - - id: engineer_ai_builder_voice_03 - title: 推進停滯的 PR - text: 你的 PR 已開了五天,負責 review 的人一直沒空,但你需要這個 merge 才能繼續。 - extraction_target: 催促策略——直接問、找主管、升級還是等;主動性程度 - - id: engineer_ai_builder_voice_04 - title: 技術意見衝突 - text: 你提議用 event-driven 架構,Tech Lead 在群組裡說「我不覺得現在有必要搞這麼複雜」。 - extraction_target: 在公開場合維護技術立場的方式;是否有能力不情緒化地辯護 - - id: engineer_ai_builder_voice_05 - title: 線上事故修復後 - text: 你造成了一個生產環境事故,影響了 30 分鐘,剛剛修好。請打一段給 stakeholder 的更新。 - extraction_target: 責任感語氣、是否主動說明根因、是否帶後續防範措施 - boundaries_questions: - - id: engineer_ai_builder_boundaries_01 - title: 技術風險授權邊界 - text: 什麼程度的技術風險你可以自己拍板,超過什麼程度你一定要拉人進來討論? - signal: 自主決策的邊界感——是否基於影響範圍、是否有明確的升級觸發點 - - id: engineer_ai_builder_boundaries_02 - title: 不可接受的工作方式 - text: 有沒有一種工作環境或合作模式,讓你覺得你的產出會變得很差?是什麼樣的條件? - signal: 高效能工作的必要條件;是否有自我認知 - - id: engineer_ai_builder_boundaries_03 - title: on-call 與邊界 - text: 你怎麼看待 on-call 或半夜被叫起來 debug?你有沒有自己的底線? - signal: 可持續性邊界;是否能清楚說出不可接受條件 - - id: engineer_ai_builder_boundaries_04 - title: 責任範圍認知 - text: 有沒有一種情況,你明明可以動手修,但你選擇不動,讓負責的人去處理?為什麼? - signal: 責任邊界——是否尊重 ownership;是否有「越界幫忙」的傾向或相反 - - id: engineer_ai_builder_boundaries_05 - title: 拒絕交付條件 - text: 有沒有一種需求,你接了之後做到一半發現你不應該繼續做,你後來怎麼處理? - signal: 中途停止的勇氣與判斷——是否能在承諾後重新談判 - bad_questions: - - bad: 你覺得自己的技術能力在團隊裡算幾分? - why: 自我評分缺乏錨點,且有表演成分 - better: 你有沒有一個技術領域,覺得自己懂得比多數人深一些?是怎麼建立起來的? - - bad: 你喜歡寫文件嗎? - why: 封閉題,幾乎所有人都說「還好」 - better: 你最近有沒有寫過一份你覺得寫得不錯的文件?它解決了什麼問題? - - bad: 你覺得 AI 會取代工程師嗎? - why: 太抽象,答案偏向場面話 - better: 你在工作中有沒有把 AI 工具整合進你的開發流程?哪個環節效果最明顯? - - bad: 你有多少年的 X 經驗? - why: 年資不等於能力,易觸發工程師防衛 - better: 你是什麼情況下第一次深入用 X?你現在怎麼評價當時的理解? - - bad: 你是 T 型人才還是 I 型人才? - why: 要求自我貼標籤,且是老套分類 - better: 除了你主力的技術棧,你有沒有另一個你意外地懂得不少的領域?是怎麼發展出來的? - persona_anchor_examples: - - 遇到技術選型時,偏好先跑小規模 PoC 驗証,而不是靠文件或直覺決定。 - - 對沒有 rollback 計畫的 production deploy 有強烈阻力,會主動要求補上。 - - Code review 時最先看錯誤處理與邊界條件,而不是算法效率。 - - 技術債只要有明確的還債時間點就能接受,但不記錄的技術債是無法接受的。 - - 喜歡透過動手做壞來學習,而不是先把理論讀完再開始。 - - 跟非技術對象溝通時,習慣先問對方最在意的結果是什麼,再決定從哪個抽象層次解釋。 - - 對 AI 落地判斷嚴謹,不上沒有可觀測回退機制的方案。 - - 評估技術值不值得深入學的標準是:有沒有真實的使用場景。 - - 在技術意見衝突時,偏好用數據或實驗結果說話,而不是靠職位或資歷壓制。 - - 對「先做再說」的上線哲學有抵制,但在有灰度流量控制的前提下可以接受快速驗證。 - - 寫任何系統設計前會先確認「誰來維護這段 code 五年後」。 - - 習慣在 PR 描述裡主動說明「這個選擇的替代方案是什麼,為什麼沒選」。 - sales_bd: - name: Sales / BD - source: perplexity_max_domain_pack_v1 - domain_role: - - 業務代表、客戶主管、商業開發、通路經理、Key Account Manager - core_task: - - 發掘潛在客戶與建立管道 - - 提案、談判、關單 - - 客戶關係維護與擴展 - - 跨部門協調(技術、法務、財務)以推進交易 - primary_counterparty: - - 客戶(採購、使用者、C-level)、合作夥伴 - decision_partner: - - 業務主管、售前工程師、法務、PM - skill_questions: - - id: sales_bd_skill_01 - title: 客戶資格判斷 - text: 你怎麼判斷一個新進來的潛在客戶值不值得花時間?你通常問哪幾個問題就能判斷? - purpose: 萃取 qualification 邏輯——是否有明確的 BANT 或自有框架;是否容易被熱情客戶說服 - expected_anchor: 「一定要先確認預算擁有者在不在這個對話裡」;「三通電話之後沒有進展的機會就冷掉」 - follow_up_max: 2 - stop_condition: 取得至少三個 qualification 指標 - risk_level: low - - id: sales_bd_skill_02 - title: 提案客製化程度 - text: 你最近一份讓自己滿意的提案,你花最多時間在哪個部分? - purpose: 萃取準備深度——是否真的做客戶研究,還是套模板;投入程度是否與機會大小成比例 - expected_anchor: 「每份提案都要有一句只有這個客戶才聽懂的話」 - follow_up_max: 1 - stop_condition: 取得具體準備步驟 - risk_level: low - - id: sales_bd_skill_03 - title: 談判策略 - text: 客戶要求降價但你不想動,你通常怎麼回應? - purpose: 萃取談判風格——讓步優先 vs. 守價值;是否有換牌桌策略 - expected_anchor: 「不直接讓價,而是調整 scope」;「先問客戶哪個部分讓他覺得貴,再針對那個點解構」 - follow_up_max: 2 - stop_condition: 取得具體的談判策略或一句常說的話 - risk_level: low - - id: sales_bd_skill_04 - title: 失單分析 - text: 你最近輸掉的一個案子,你事後怎麼看?你覺得是哪個環節可以不一樣? - purpose: 萃取自我反思能力——是否會歸因到自身行為,還是都推給外部因素 - expected_anchor: 「輸單後一定要打一通電話問對方為什麼選了競品」 - follow_up_max: 1 - stop_condition: 取得至少一個自我歸因的點 - risk_level: medium - - id: sales_bd_skill_05 - title: 管道管理紀律 - text: 你怎麼管你的 pipeline?你多久更新一次,你用什麼判斷哪個機會該推進、哪個該放棄? - purpose: 萃取銷售紀律——是否系統化;是否有放棄標準(否則會出現殭屍 pipeline) - expected_anchor: 「超過 X 週沒有進展的機會自動降低優先序」;「每週五花 30 分鐘更新 CRM 是不可省的儀式」 - follow_up_max: 1 - stop_condition: 取得明確的管理節奏 - risk_level: low - - id: sales_bd_skill_06 - title: 跨部門協調能力 - text: 你有沒有遇過因為內部問題(技術評估太慢、法務卡住合約)而差點讓客戶跑掉的情況?你怎麼處理? - purpose: 萃取內部協調風格——是否能有效調動資源;是否善於用客戶壓力推進內部 - expected_anchor: 「遇到內部卡關,我習慣先給對方一個明確的時間線,再內部去扛那個承諾」 - follow_up_max: 2 - stop_condition: 取得一個具體的協調策略 - risk_level: low - - id: sales_bd_skill_07 - title: 客戶關係維護 - text: 你有沒有一個客戶關係,是在沒有活躍機會的時候還是維持得很好的?你靠什麼維持? - purpose: 萃取長期關係投資策略——是否有意識地建立非交易性連結 - expected_anchor: 「每季至少一次不帶目的的 check-in」;「記住客戶的業務挑戰比記住他的生日更重要」 - follow_up_max: 1 - stop_condition: 取得維護策略 - risk_level: low - - id: sales_bd_skill_08 - title: 競品處理 - text: 客戶說他也在評估你的競品,你通常怎麼應對?你會主動提競品嗎? - purpose: 萃取競爭策略——是否能主動駕馭競品比較,還是迴避;是否了解自身差異化 - expected_anchor: 「主動提競品,用自己的語言框定比較維度」;「先問客戶目前用競品哪個地方讓他不滿意」 - follow_up_max: 2 - stop_condition: 取得具體的競品對話策略 - risk_level: low - people_questions: - - id: sales_bd_people_01 - title: 客戶信任建立 - text: 你覺得一個客戶從把你當業務變成把你當顧問,是什麼時候發生轉折的?有沒有具體的例子? - signal: 信任建立策略——是靠專業、靠個人連結,還是靠關鍵時刻展現的誠信 - - id: sales_bd_people_02 - title: 內部夥伴協作 - text: 跟售前或技術顧問一起跑案子,你們之間的分工通常是怎麼劃的?有沒有摩擦過的時候? - signal: 是否尊重各自角色;衝突處理是否成熟 - - id: sales_bd_people_03 - title: 影響力使用 - text: 你有沒有在沒有直接決定權的情況下推動了一個決策?你用了什麼方法? - signal: 影響力來源——靠數據、靠關係、靠製造緊迫感、靠說故事 - - id: sales_bd_people_04 - title: 難搞客戶 - text: 你有沒有一個讓你壓力很大的客戶,但你最後還是完成了這個案子?那個客戶讓你壓力大的原因是什麼? - signal: 情緒調節能力;是否能拆解壓力來源並逐一應對 - - id: sales_bd_people_05 - title: 離開的客戶 - text: 有沒有一個客戶你沒辦法服務好、或者主動放棄了?當時是什麼情況? - signal: 客戶邊界管理;是否有能力辨識「不對的客戶」 - voice_roleplays: - - id: sales_bd_voice_01 - title: 正式提案跟進 - text: 你上週做了一場提案,客戶說會討論後回覆,但已經過了五天還沒消息。請打一封跟進信。 - extraction_target: 主動程度;是否帶新的價值點;是否製造下一步行動 - - id: sales_bd_voice_02 - title: 拒絕不合理折扣 - text: 客戶說競品便宜 20%,要你 match,但你的產品不能跌到那個價。請打一段回應。 - extraction_target: 守價策略;是否能用價值而非情緒回應 - - id: sales_bd_voice_03 - title: 推進停滯中的案子 - text: 一個案子已經在「評估中」三個月了,你需要讓它重新動起來。請打一段訊息給對口聯絡人。 - extraction_target: 重啟策略;是否有製造行動錨點的能力 - - id: sales_bd_voice_04 - title: 內部升級衝突 - text: 你的對口聯絡人說他很支持,但他的老闆突然說「現在不是好時機」。請打一段給聯絡人的訊息。 - extraction_target: 是否能協助內部倡議者;是否有穿透到決策層的策略 - - id: sales_bd_voice_05 - title: 客戶抱怨修復 - text: 客戶剛上線,但遇到了一個你事前沒預警的問題,他傳訊息說「這不是我當初買的東西」。 - extraction_target: 危機處理語氣;是否先承認問題再解決;是否能穩住關係 - boundaries_questions: - - id: sales_bd_boundaries_01 - title: 承諾邊界 - text: 你有沒有過答應了客戶一個你自己沒辦法控制的事情?後來怎麼收? - signal: 是否有過度承諾傾向;是否能事後誠實修正承諾 - - id: sales_bd_boundaries_02 - title: 不接受的客戶類型 - text: 你有沒有一種客戶,你接了之後每次開會都覺得很消耗?你怎麼描述那種客戶? - signal: 客戶邊界感;是否能清楚說出不對的客戶特徵 - - id: sales_bd_boundaries_03 - title: 道德邊界 - text: 有沒有一種銷售方式或說話方式,你覺得你不會做,即使它可能很有效? - signal: 倫理邊界——是否有超過業績的底線 - - id: sales_bd_boundaries_04 - title: 內部資源使用邊界 - text: 你怎麼判斷一個案子值得動用多少內部資源(技術評估、客製化、法務)?你有沒有拒絕過申請資源? - signal: 成本意識;是否有機會 ROI 的概念 - - id: sales_bd_boundaries_05 - title: 放棄標準 - text: 你在什麼情況下會告訴自己:這個機會已經沒戲了,我要停止投入? - signal: 損失厭惡程度;是否有清楚的撤退標準而非一直耗著 - bad_questions: - - bad: 你的業績達成率是多少? - why: 太直接切數字,像是在審查 - better: 你最近一個季度,你覺得跑得最順的案子,是因為做對了什麼? - - bad: 你喜歡被拒絕嗎? - why: 老套銷售面試題,受訪者會給罐頭答案 - better: 你最近一次被客戶明確拒絕,你當時第一個反應是什麼?後來怎麼處理? - - bad: 你有沒有 hunter 心態? - why: 要求自我貼標籤,且是老掉牙的分類 - better: 你開發一個完全陌生的客戶,通常從哪裡開始? - - bad: 你是關係型業務還是方案型業務? - why: 二元分類,迫使受訪者選一個 - better: 你有沒有一個靠產品說服的客戶,和一個靠關係維繫的客戶,這兩種你更喜歡哪種互動方式? - - bad: 你怎麼超越你的配額? - why: 預設他有達到,且讓人覺得在炫技 - better: 你怎麼判斷一個月哪些機會應該全力衝,哪些先放著? - persona_anchor_examples: - - 評估新機會時,會先確認預算擁有者是否在對話中,否則不進入深度提案模式。 - - 跟進停滯中的案子時,習慣帶一個新的資訊或價值點,而不是純粹問「有什麼進展」。 - - 面對降價壓力,偏好調整 scope 而不是直接讓價。 - - 每份提案都會嵌入一句只有這個客戶才聽懂的話,以展示真正做過功課。 - - 失單後會主動打一通電話給客戶,問清楚他為什麼選了競品。 - - 認為記住客戶的業務挑戰比記住他的生日更重要。 - - 傾向主動提起競品,用自己的語言框定比較維度,而不是等客戶提。 - - 對「超過三週沒有進展的 pipeline 機會」有自動降優先序的機制。 - - 危機處理時的第一反應是先承認問題的存在,而不是先解釋原因。 - - 對「答應了自己控制不了的事」有強烈的後悔感,會試圖在對話當下就設定保護條款。 - - 對消耗型客戶有明確的辨識指標,且願意主動放棄這類機會。 - - 把把客戶當作顧問信任的關係視為最高成就,而不只是完成交易。 - pm_tpm: - name: PM / TPM - source: perplexity_max_domain_pack_v1 - domain_role: - - 產品經理、技術專案經理、程式產品經理、平台 PM - core_task: - - 需求收集與優先序定義 - - Roadmap 制定與溝通 - - 跨部門協調(工程、設計、業務、法務) - - 風險追蹤、進度管理、上線決策 - primary_counterparty: - - 工程師、設計師、業務、客戶代表 - decision_partner: - - 工程 Lead、設計 Lead、業務主管、資料分析師 - skill_questions: - - id: pm_tpm_skill_01 - title: 優先序決策 - text: 你有沒有一次,你明知道某個需求技術難度低、業務很想要,但你還是決定不做或延後?你怎麼解釋那個決定? - purpose: 萃取優先序框架——是否能抵抗壓力;是否有清楚的取捨邏輯 - expected_anchor: 「不在當期策略目標上的需求,即使容易做,也不進 roadmap」 - follow_up_max: 2 - stop_condition: 取得具體的否決標準 - risk_level: low - - id: pm_tpm_skill_02 - title: 需求模糊處理 - text: 你最近一次拿到的需求,你認為問題定義本身就是錯的,你怎麼處理? - purpose: 萃取問題重構能力;是否能在不得罪對方的前提下改變方向 - expected_anchor: 「先把對方描述的症狀和他想要的解法分開,再重新問問題」 - follow_up_max: 2 - stop_condition: 取得具體的重構步驟 - risk_level: low - - id: pm_tpm_skill_03 - title: 資源不足下的交付 - text: 你有沒有在資源(人力、時間、技術)明顯不足的情況下,還是交付了一個可以接受的結果?你做了什麼取捨? - purpose: 萃取 MVP 思維與資源運用能力 - expected_anchor: 「先定義 non-negotiable,其他的都可以討論縮範圍」 - follow_up_max: 1 - stop_condition: 取得具體的取捨範例 - risk_level: low - - id: pm_tpm_skill_04 - title: 數據驅動決策 - text: 你有沒有一個決定,是你原本有直覺,但數據告訴你不一樣,你最後選擇相信哪一個? - purpose: 萃取直覺 vs. 數據的實際排序;是否能說出「相信數據的條件」 - expected_anchor: 「數據樣本太小時我不相信數據,我會先擴充樣本再決定」 - follow_up_max: 2 - stop_condition: 取得受訪者的數據信任標準 - risk_level: low - - id: pm_tpm_skill_05 - title: 上線決策 - text: 你有沒有在不確定情況下做過上線決定?你當時的邏輯是什麼? - purpose: 萃取風險耐受度與回退計畫思維 - expected_anchor: 「上線前必須定義成功指標和失敗指標,才能知道要不要 rollback」 - follow_up_max: 2 - stop_condition: 取得上線決策的評估框架 - risk_level: low - - id: pm_tpm_skill_06 - title: 跨部門衝突協調 - text: 你有沒有遇過工程說做不到、業務說一定要做、你夾在中間的情況?你怎麼處理? - purpose: 萃取三方衝突的協調風格——是否能區隔技術限制與意願問題;是否能創造雙方都接受的方案 - expected_anchor: 「把「不能做」和「現在不做」分開,再去找時間線的解法」 - follow_up_max: 2 - stop_condition: 取得具體的協調策略 - risk_level: low - - id: pm_tpm_skill_07 - title: Roadmap 溝通 - text: 你有沒有一次向 stakeholder 更新 roadmap 的結果,讓你覺得說得不夠清楚或沒達到效果?你後來改了什麼? - purpose: 萃取溝通自我修正能力;是否有意識調整受眾框架 - expected_anchor: 「對 C-level 說 roadmap 要說商業影響,對工程說要說技術選型邏輯,是兩份不同的材料」 - follow_up_max: 1 - stop_condition: 取得至少一個溝通策略改變 - risk_level: low - - id: pm_tpm_skill_08 - title: 失敗的 feature - text: 你做過一個功能,上線後反應不好,你事後怎麼分析為什麼沒有成? - purpose: 萃取反思深度——是否歸因到自己的驗證流程,還是推給市場;是否能說出「早期信號」 - expected_anchor: 「那個功能最大的問題是我在 build 之前沒有真的去問用戶,我問的是我們的業務」 - follow_up_max: 2 - stop_condition: 取得自我歸因的反思 - risk_level: low - people_questions: - - id: pm_tpm_people_01 - title: 工程師關係 - text: 你有沒有跟工程師建立過一種關係,讓他們願意在需求不清楚的時候主動來問你,而不是自己猜?你怎麼建立的? - signal: 信任建立策略;是否有意識地降低工程師溝通成本 - - id: pm_tpm_people_02 - title: 向上管理 - text: 你有沒有一個決定,你的老闆不同意,但你最後還是堅持了?你用什麼說服他,或你用什麼理由接受了他的否決? - signal: 向上溝通能力;是否有能力分辨「可辯護的分歧」和「應該服從的決策」 - - id: pm_tpm_people_03 - title: 利害關係人管理 - text: 你有沒有一個 stakeholder,他很難搞但你沒有辦法繞過他,你怎麼跟他合作? - signal: 關係管理複雜度;是否有針對不同型態 stakeholder 的應對策略 - - id: pm_tpm_people_04 - title: 壞消息傳遞 - text: 你有沒有需要告訴老闆或客戶一個他不會高興聽的消息?你怎麼準備那次對話? - signal: 難對話準備方式;是否有早期預警習慣 - - id: pm_tpm_people_05 - title: 設計師協作 - text: 你和設計師意見不一致的時候,你怎麼決定聽誰的? - signal: 專業邊界的尊重度;是否能分辨「PM 的決定」vs「設計師的決定」 - voice_roleplays: - - id: pm_tpm_voice_01 - title: 正式狀態更新 - text: 一個原定這週上線的 feature,工程說還需要兩週。你需要更新你的業務老闆。請打一封訊息。 - extraction_target: 主動承擔 vs. 推責;是否帶替代方案;是否預先管理期望 - - id: pm_tpm_voice_02 - title: 拒絕新需求 - text: 業務 VP 突然傳訊息說「我需要你在這個 sprint 加一個 feature,客戶急用」。你已排滿。 - extraction_target: 拒絕策略;是否帶條件;是否提出優先序選擇讓對方決定 - - id: pm_tpm_voice_03 - title: 推進停滯中的設計評審 - text: 一個設計稿已等待設計主管 review 超過一週,影響了 sprint 進行。請打一段訊息。 - extraction_target: 催促方式;是否能不得罪人地製造壓迫感 - - id: pm_tpm_voice_04 - title: 跨部門衝突調解 - text: 工程 Lead 在群組裡公開說「這個需求根本就是拍腦袋的,我不知道做這個的目的是什麼」。 - extraction_target: 公開衝突處理方式;是否私下介入;是否有能力重建對話框架 - - id: pm_tpm_voice_05 - title: 上線後道歉/修復 - text: 你昨天上線的功能有個明顯的 UX 問題,用戶開始抱怨。你需要告知你的老闆和相關團隊。 - extraction_target: 責任語氣;是否帶後續計畫;是否在 36 小時內提出修復方向 - boundaries_questions: - - id: pm_tpm_boundaries_01 - title: 決策權邊界 - text: 有哪些決定你覺得是你應該拍板的,哪些你覺得不是你的決定? - signal: 授權邊界清晰度;是否清楚「我的決定」vs.「需要對齊的決定」 - - id: pm_tpm_boundaries_02 - title: 範疇蔓延處理 - text: 你有沒有遇過一個案子,做到一半發現需求比原來大很多?你怎麼判斷要不要收回來? - signal: scope creep 對策;是否有明確的觸發點讓他重新談判 - - id: pm_tpm_boundaries_03 - title: 不可妥協的事項 - text: 有沒有一種壓力或要求,你絕對不會因為主管或客戶要求就讓步的? - signal: 核心原則——品質底線、用戶安全、資料隱私等 - - id: pm_tpm_boundaries_04 - title: 個人工作邊界 - text: PM 的工作很容易沒有邊界,你有沒有一個時期你覺得邊界消失了?後來是怎麼找回來的? - signal: 自我保護機制;是否對可持續工作方式有反思 - - id: pm_tpm_boundaries_05 - title: 放棄一個功能 - text: 你有沒有做到一半決定放棄一個功能的?那個決定是怎麼做的、是誰做的? - signal: 殺死自己的 baby 的能力;是否能說出 kill 決策的觸發條件 - bad_questions: - - bad: 你怎麼平衡技術和商業需求? - why: 太抽象、太面試,PM 都背得出罐頭答案 - better: 你最近一次在技術成本和商業價值之間做取捨,結果是什麼? - - bad: 你覺得 PM 最重要的能力是什麼? - why: 問自我定義,答案永遠是「溝通」加「同理心」 - better: 你做過最難的一個跨部門協調,讓你覺得學到最多的是什麼? - - bad: 你怎麼跟工程師相處? - why: 太廣、易流於表面(「尊重技術判斷」) - better: 工程師覺得你的需求描述不清楚,你怎麼發現的?你改了什麼? - - bad: 你用過什麼 PM 工具? - why: 工具是手段,不是信號;列工具不代表能力 - better: 你怎麼追蹤一個複雜功能的進度?你通常在哪個環節最容易漏掉東西? - - bad: 你的 OKR 完成了多少? - why: 數字脫離脈絡沒有意義,且容易防衛 - better: 上一個目標週期,你最後悔沒做的一件事是什麼?為什麼當時沒做? - persona_anchor_examples: - - 對不在當期策略目標上的需求,即使技術容易,也傾向不進 roadmap,且能說得出理由。 - - 拿到需求時習慣把「對方描述的症狀」和「他要的解法」分開,再回去問問題。 - - 把「做不到」和「現在不做」視為本質不同的兩件事,且會在對話中主動區分。 - - 上線前必定定義失敗指標,否則不啟動上線流程。 - - 對 C-level 說 roadmap 說商業影響,對工程說技術選型邏輯,是兩份不同材料。 - - 習慣在遇到壞消息時先想「我有沒有早期信號錯過了」,而不只是思考怎麼報告。 - - 認為 PM 最大的風險是「問了業務就以為問了用戶」。 - - 資源不足時先定義 non-negotiable,其他的範圍都可以談。 - - 對 feature 被殺死沒有情緒障礙,認為比繼續做錯的東西代價小。 - - 向上管理時,能清楚分辨「可以辯護的意見分歧」和「應該服從的決定」。 - - 遇到 scope creep 有明確的重談觸發點,不會默默擴大做。 - - 把讓工程師「不清楚就主動來問」視為工作成功的重要指標之一。 - consultant: - name: Consultant - source: perplexity_max_domain_pack_v1 - domain_role: - - 管理顧問、策略顧問、IT 顧問、轉型顧問、獨立顧問 - core_task: - - 問題診斷與結構化分析 - - 報告與建議書製作 - - 客戶訪談與工作坊引導 - - 執行建議與變革管理 - primary_counterparty: - - 客戶中高層主管、專案對接窗口 - decision_partner: - - 顧問主管、顧問同儕、客戶關鍵 stakeholder - skill_questions: - - id: consultant_skill_01 - title: 問題結構化 - text: 你接過一個客戶的問題,一開始你以為是 A 問題,後來發現根本是 B 問題?你是怎麼發現的? - purpose: 萃取診斷能力——是否有「不過早收斂問題定義」的習慣 - expected_anchor: 「前兩週做的是挑戰假設,而不是驗證假設」 - follow_up_max: 2 - stop_condition: 取得具體的「重新定義問題」的方法 - risk_level: low - - id: consultant_skill_02 - title: 資料不足下的判斷 - text: 你有沒有在客戶資料不完整的情況下,還是要給建議的經驗?你的做法是什麼? - purpose: 萃取在不確定性下的溝通方式——是否能清楚標注假設前提 - expected_anchor: 「把建議的前提條件寫清楚,讓客戶知道他接受的是哪個假設下的答案」 - follow_up_max: 1 - stop_condition: 取得「假設標注」的習慣 - risk_level: low - - id: consultant_skill_03 - title: 建議書框架 - text: 你最近一份建議書,你最花心思的部分是哪裡?為什麼那個部分最難? - purpose: 萃取思考偏好——邏輯結構、數據驗證、執行可行性、簡報視覺哪個最優先 - expected_anchor: 「最難的是讓客戶接受的不是「最好的答案」,而是「他做得到的答案」」 - follow_up_max: 1 - stop_condition: 取得製作邏輯 - risk_level: low - - id: consultant_skill_04 - title: 客戶推拒處理 - text: 你給過一個建議,客戶當場說他們做不到或不想做,你怎麼應對? - purpose: 萃取說服策略——是否理解阻力根源;是否能重新框架建議 - expected_anchor: 「先問是哪個部分讓他覺得做不到,是資源問題、政治問題還是他根本不相信這個答案」 - follow_up_max: 2 - stop_condition: 取得具體的應對策略 - risk_level: low - - id: consultant_skill_05 - title: 工作坊引導 - text: 你有沒有一個工作坊或訪談,氣氛不太對,你當下做了什麼調整? - purpose: 萃取現場應變能力;是否有閱讀房間的技能 - expected_anchor: 「發現討論卡住通常是議題太大,習慣立刻把問題拆小再問一次」 - follow_up_max: 2 - stop_condition: 取得具體的現場調整行為 - risk_level: low - - id: consultant_skill_06 - title: 報告呈現風格 - text: 你有沒有一份報告,你覺得邏輯很好,但客戶反應很冷淡?你後來的解讀是什麼? - purpose: 萃取溝通自我反思——是否意識到「邏輯好」和「客戶接受」的差距 - expected_anchor: 「分析做完要問的問題不是『這個答案對不對』,而是『客戶願不願意行動』」 - follow_up_max: 1 - stop_condition: 取得受訪者對自身報告風格的反思 - risk_level: low - - id: consultant_skill_07 - title: 超出範疇的問題 - text: 客戶問了一個你的合約範疇之外的問題,你怎麼決定要不要回答? - purpose: 萃取邊界管理風格——是否傾向擴範疇換信任;是否有清楚的職業邊界 - expected_anchor: 「先快速給一個方向,然後說清楚這不在這次的 scope,若要深入需要另外討論」 - follow_up_max: 1 - stop_condition: 取得範疇邊界的處理策略 - risk_level: low - - id: consultant_skill_08 - title: 競品差異化 - text: 你有沒有一次你明確知道,客戶為什麼選你而不是另外一家顧問?那個差異點是什麼? - purpose: 萃取自我差異化認知——是否清楚自己的獨特價值 - expected_anchor: 「客戶說他選我是因為我在提案時就已經定義了他自己沒想到的問題」 - follow_up_max: 1 - stop_condition: 取得受訪者的自我差異化敘事 - risk_level: low - people_questions: - - id: consultant_people_01 - title: 客戶信任深度 - text: 你有沒有一個客戶,在專案結束後還是持續找你的?你覺得那個關係是怎麼建立起來的? - signal: 長期顧問信任建立策略 - - id: consultant_people_02 - title: 客戶內部政治 - text: 你有沒有發現你的建議被客戶內部某個人擋住了,但那個人不是你的對口?你怎麼應對? - signal: 政治敏感度與迂迴影響力策略 - - id: consultant_people_03 - title: 團隊內部協作 - text: 你和顧問同事的工作分工,有沒有你覺得分得不好、或彼此有摩擦的時候?通常是什麼原因? - signal: 團隊信任與責任劃分偏好 - - id: consultant_people_04 - title: 壞消息處理 - text: 你有沒有分析結果顯示客戶的策略根本就是錯的,你怎麼讓他們聽進去? - signal: 衝突性建議的呈現方式;是否能讓客戶接受不舒服的真相 - - id: consultant_people_05 - title: 影響力邊界 - text: 你有沒有一次,你覺得你的建議是對的,但最後客戶還是做了不一樣的事?你後來怎麼看這件事? - signal: 影響力邊界認知;是否能放下控制感 - voice_roleplays: - - id: consultant_voice_01 - title: 正式進度更新 - text: 你的第一階段分析顯示問題比原來預估的複雜,需要延後兩週交付最終報告。請打一封給客戶窗口的信。 - extraction_target: 誠信溝通風格;是否帶新的時間計畫 - - id: consultant_voice_02 - title: 拒絕範疇擴張 - text: 客戶說「你們既然在分析這塊,能不能順便也看一下我們的業務端的狀況?」但這不在合約裡。 - extraction_target: 範疇邊界管理語氣;是否能不得罪對方地說「不」 - - id: consultant_voice_03 - title: 推進卡關的會議 - text: 你已寄出三封信要約一個關鍵主管受訪,但他都沒回。你需要透過客戶窗口推動。 - extraction_target: 升級策略;是否能不顯得強硬但有效 - - id: consultant_voice_04 - title: 意見衝突 - text: 在客戶工作坊中,一位高層主管說「你們顧問都這樣說,但我們行業不一樣」並否定你的分析框架。 - extraction_target: 公開挑戰的應對策略;是否能維持立場而不對立 - - id: consultant_voice_05 - title: 關係修復 - text: 你的分析報告有一個數字算錯,客戶在簡報中當場發現了。請打一封後續的信。 - extraction_target: 錯誤承擔語氣;是否主動說明修正計畫 - boundaries_questions: - - id: consultant_boundaries_01 - title: 不可妥協的建議原則 - text: 有沒有一種情況,客戶要你修改你的結論,但你不願意?你是怎麼處理的? - signal: 職業誠信邊界;是否有「結論不可出售」的核心原則 - - id: consultant_boundaries_02 - title: 範疇蔓延底線 - text: 你什麼時候會對客戶說「這個超出我們這次的範圍」,而不是默默多做? - signal: 時間與資源的邊界管理 - - id: consultant_boundaries_03 - title: 利益衝突處理 - text: 你有沒有發現你給的建議可能對你的公司有商業利益,你怎麼處理這個衝突? - signal: 職業倫理意識 - - id: consultant_boundaries_04 - title: 不接受的委託條件 - text: 有沒有一種案子,你接了之後覺得你不應該接,是什麼讓你有這種感覺? - signal: 案子評估邊界;是否有「不對的案子」的辨識能力 - - id: consultant_boundaries_05 - title: 執行失敗的責任邊界 - text: 顧問給了建議,客戶執行了但失敗了,你覺得責任在哪裡? - signal: 顧問責任範圍認知;是否過度承擔或過度推卸 - bad_questions: - - bad: 你最厲害的一個案子是什麼? - why: NDA 和保密義務讓受訪者很難回答,且易變成炫耀 - better: 你做過一個你認為最有學習價值的案子,不用說是哪個客戶,你從那個案子學到了什麼? - - bad: 你覺得顧問最重要的能力是什麼? - why: 標準答案:「結構化思維和溝通能力」,毫無人格信號 - better: 你認識過一個你覺得真的很強的顧問,讓你覺得強的原因是什麼? - - bad: 你用什麼分析框架? - why: 易流於工具展示;答案不代表實際使用方式 - better: 你最近一次用一個框架沒有用,你後來是怎麼換方向的? - - bad: 你喜歡顧問工作嗎? - why: 情感詢問對理性型受訪者無效,且問不到真實面 - better: 顧問工作哪個部分你覺得你特別有能量?哪個部分你覺得每次都在消耗你? - - bad: 你有沒有讓客戶「成功轉型」的案例? - why: 「轉型成功」定義模糊,且顧問無法對執行結果負責 - better: 你做過一個案子,你覺得建議被認真執行了,那個案子有哪些條件是對的? - persona_anchor_examples: - - 接到問題的前兩週習慣做「挑戰假設」而不是「驗證假設」,避免過早收斂問題定義。 - - 在資料不足時仍能給建議,但會把前提假設明確寫出來,讓客戶知道他接受的是哪個假設下的答案。 - - 認為分析結束後最重要的問題不是「這個答案對不對」,而是「客戶願不願意採取行動」。 - - 傾向給「客戶做得到的答案」而不是「理論上最好的答案」。 - - 面對客戶的反對,習慣先問「是資源問題、政治問題,還是他根本不相信這個答案」,再決定如何回應。 - - 工作坊卡住時,第一反應是把問題拆小而不是換題,認為「議題太大」是討論失速的主因。 - - 對超出合約範疇的請求,習慣給一個方向再說清楚需要另外討論,而不是直接拒絕。 - - 把結論的獨立性視為職業誠信的核心,不接受「修改結論讓客戶好看」的要求。 - - 認為建立長期顧問信任的關鍵在於「在沒有商機的時候還是出現」。 - - 面對公開挑戰時,習慣先承認對方的脈絡再重新定位自己的論點,而不是防禦性反擊。 - - 對範疇蔓延有明確的觸發點,不會因為「順便」的請求而默默擴大工作量。 - - 自我評估案子成功的標準是「客戶是否改變了他自己的決策框架」,而不只是「報告是否被接受」。 - manager_people_lead: - name: Manager / People Lead - source: perplexity_max_domain_pack_v1 - domain_role: - - 工程主管、部門主管、Team Lead、Director、VP of Engineering - core_task: - - 人員招募、評估、培育、績效管理 - - 設定團隊目標並追蹤 - - 跨部門對齊與資源協調 - - 文化建立與團隊氛圍管理 - primary_counterparty: - - 直屬下屬、同層主管、HR - decision_partner: - - 老闆/VP、HR BP、其他主管 - skill_questions: - - id: manager_people_lead_skill_01 - title: 績效對話 - text: 你有沒有跟一個表現不如預期的人談過績效?你當時怎麼開口的? - purpose: 萃取難對話能力——是否能直接但不傷人 - expected_anchor: 「會先說清楚我看到的具體行為,而不是貼標籤說他『態度有問題』」 - follow_up_max: 2 - stop_condition: 取得具體的開場策略 - risk_level: medium - - id: manager_people_lead_skill_02 - title: 授權範圍 - text: 你通常什麼樣的決定你會讓你的人自己做,什麼樣的你會介入? - purpose: 萃取授權哲學——是否有清楚的邊界而非「看情況」 - expected_anchor: 「可逆的決定放手,不可逆的決定一定要過我」 - follow_up_max: 1 - stop_condition: 取得明確的授權標準 - risk_level: low - - id: manager_people_lead_skill_03 - title: 招募判斷 - text: 你在面試時,通常你最後是靠什麼判斷這個人可以還是不行? - purpose: 萃取人才判斷直覺——是否有超過履歷的評估維度 - expected_anchor: 「我看他在不確定的情況下怎麼思考,比看他有沒有經驗更重要」 - follow_up_max: 1 - stop_condition: 取得判斷標準或 dealbreaker - risk_level: low - - id: manager_people_lead_skill_04 - title: 人員離職處理 - text: 你有沒有一個人提離職讓你意外的?你後來怎麼理解為什麼他要走? - purpose: 萃取對人員信號的敏感度——是否有能力回望早期指標 - expected_anchor: 「事後想想他在三個月前就有一些訊號,我沒有接到」 - follow_up_max: 2 - stop_condition: 取得對「早期信號」的反思 - risk_level: medium - - id: manager_people_lead_skill_05 - title: 文化建立 - text: 你有沒有刻意做過某件事,讓你的團隊形成某種你想要的行為模式?那件事是什麼? - purpose: 萃取文化塑造的具體手段——是否靠身教、靠制度,還是靠儀式 - expected_anchor: 「我在 retro 裡面公開說過我自己的失誤,從那次開始大家才開始敢說」 - follow_up_max: 2 - stop_condition: 取得具體的文化行為範例 - risk_level: low - - id: manager_people_lead_skill_06 - title: 弱者保護 vs. 高標準 - text: 你有沒有一個人,你知道他已經在盡力了,但他的輸出就是跟不上?你怎麼處理那個張力? - purpose: 萃取對「努力但不夠好」的處理方式——是否能同時維持公平與人性 - expected_anchor: 「努力和結果是兩件事,我會給他正面的努力反饋,但也要讓他知道我對輸出的期待沒有降低」 - follow_up_max: 2 - stop_condition: 取得受訪者對這個張力的具體解法 - risk_level: medium - - id: manager_people_lead_skill_07 - title: 向上溝通 - text: 你有沒有一個你的老闆的決定,你覺得對你的團隊有負面影響,你怎麼反映? - purpose: 萃取向上影響力——是否有能力為團隊發聲但不讓老闆覺得被攻擊 - expected_anchor: 「我習慣帶數據,說清楚這個決定對團隊 delivery 能力的具體影響,而不是說『大家覺得很不公平』」 - follow_up_max: 1 - stop_condition: 取得向上溝通的具體策略 - risk_level: low - - id: manager_people_lead_skill_08 - title: 自己的成長盲點 - text: 你當主管之後,什麼事是你花了最久才學會的? - purpose: 萃取管理自我成長軌跡——是否有具體的學習時刻 - expected_anchor: 「最久才學會的是:我的焦慮不要傳給我的人」 - follow_up_max: 1 - stop_condition: 取得具體的學習事件 - risk_level: low - people_questions: - - id: manager_people_lead_people_01 - title: 一對一節奏 - text: 你的 1-on-1 通常是誰主導、怎麼進行的?你覺得一個好的 1-on-1 結束時應該要發生什麼? - signal: 傾聽 vs. 指導偏好;是否把 1-on-1 當成工具 - - id: manager_people_lead_people_02 - title: 衝突調解 - text: 你有沒有兩個下屬之間有衝突,需要你介入?你什麼時候決定介入,什麼時候讓他們自己解決? - signal: 干預邊界;是否能讓團隊自己成長解決問題 - - id: manager_people_lead_people_03 - title: 高潛力員工培育 - text: 你有沒有一個你覺得很有潛力的人,你為他做了什麼讓他加速成長? - signal: 培育哲學——給任務、給資源、給曝光、給反饋;是否有意識地設計成長路徑 - - id: manager_people_lead_people_04 - title: 不適任員工 - text: 你有沒有最後決定讓一個人離開的經驗?你怎麼走到那個決定的? - signal: 對困難人事決策的心理準備與執行方式 - - id: manager_people_lead_people_05 - title: 心理安全感 - text: 你覺得你的團隊會不會主動跟你說壞消息?你怎麼知道? - signal: 心理安全感的自我評估;是否有主動確認的機制 - voice_roleplays: - - id: manager_people_lead_voice_01 - title: 目標設定溝通 - text: 你需要向你的團隊說明下一季 OKR 有一個很有挑戰性的目標,你知道他們會覺得太高。請打一段訊息或開場白。 - extraction_target: 動機語言風格;是否帶理由;是否邀請參與 - - id: manager_people_lead_voice_02 - title: 拒絕不合理要求 - text: 你的老闆說希望你的團隊這季多承擔一個跨部門的任務,但你認為你們已經滿載了。 - extraction_target: 向上推拒的策略;是否帶資源解析;是否請老闆做優先序選擇 - - id: manager_people_lead_voice_03 - title: 推進績效改善 - text: 你和一個下屬已談過一次績效問題,但過了三週,改善幅度很有限。你需要再開一次對話。 - extraction_target: second conversation 策略;是否更直接;是否帶明確的期望和後果 - - id: manager_people_lead_voice_04 - title: 衝突後的關係修復 - text: 你在一次公開 review 裡批評了某個人的工作,事後你覺得語氣太重了。 - extraction_target: 道歉方式;是否能在不喪失管理威信的情況下修復關係 - - id: manager_people_lead_voice_05 - title: 好消息公告 - text: 你的一個下屬拿到了一個內部晉升,你想在 team 群組宣布。 - extraction_target: 讚揚方式;是否具體說明他為什麼值得;是否帶團隊文化訊號 - boundaries_questions: - - id: manager_people_lead_boundaries_01 - title: 授權下限 - text: 你有沒有一種決定,你是真的希望下屬自己決定,但你最後還是介入了?你後來覺得那個介入是對的嗎? - signal: 授權後的克制能力;是否容易落入微管理 - - id: manager_people_lead_boundaries_02 - title: 個人邊界 - text: 當下屬的個人問題(家庭、身心狀況)開始影響工作,你介入的邊界在哪裡? - signal: 主管角色邊界——是否能兼顧關懷與職業邊界 - - id: manager_people_lead_boundaries_03 - title: 不可接受的管理要求 - text: 有沒有一種上面交代下來的任務,你覺得你沒辦法、也不應該照辦? - signal: 對「執行違背自己價值觀的指令」的處理方式 - - id: manager_people_lead_boundaries_04 - title: 資訊透明邊界 - text: 有沒有公司的決定或資訊,你覺得應該跟你的人說清楚,但組織不讓你說?你怎麼處理那個張力? - signal: 資訊透明對主管的壓力;是否能在誠信和服從之間找到路 - - id: manager_people_lead_boundaries_05 - title: 與前下屬的邊界 - text: 有沒有一個前下屬,在他離開後還是持續找你諮詢或聊工作的事?你怎麼定義那個關係? - signal: 角色邊界在關係延伸後的維持能力 - bad_questions: - - bad: 你的管理風格是什麼? - why: 罐頭答案:「我是 servant leader,也是數據驅動的...」 - better: 你最近一次幫一個人在你的團隊成長,你做了什麼具體的事? - - bad: 你怎麼激勵你的團隊? - why: 太廣、太抽象,答案永遠是老套的三個面向 - better: 你有沒有一個人,你找到了一個特別有效的方式讓他更有動力?那個方式是什麼? - - bad: 你怎麼平衡人情和績效? - why: 這個問題有「陷阱感」,受訪者會給政治正確的答案 - better: 你有沒有一個案例,你對一個人的情感和你對他的績效要求有明顯的張力?你當時怎麼做的? - - bad: 你覺得你是一個好主管嗎? - why: 自我評分無意義,且大多數人不敢說「不」 - better: 你有沒有一個前下屬,你現在回想起來覺得你沒有帶好他?是什麼情況? - - bad: 你怎麼建立心理安全感? - why: 問法太 buzzword,易引發理論性回答 - better: 你有沒有做過一件事,之後你發現你的人開始更敢說實話了?那件事是什麼? - persona_anchor_examples: - - 績效對話開場習慣先描述看到的具體行為,而不是用性格標籤描述對方的問題。 - - 授權標準是「可逆的決定放手,不可逆的決定介入」,而不是靠感覺判斷。 - - 評估人才時,最看重的是「在不確定情況下他怎麼思考」,而不是過往的成功經歷。 - - 對人員離職感到意外後,習慣回頭找早期三個月的訊號,用來改善下次的敏感度。 - - 文化塑造偏好靠自己公開承認失誤來建立心理安全感,而不是靠政策。 - - 能同時給努力的正面反饋和輸出不足的誠實反饋,不讓兩者互相覆蓋。 - - 向上溝通時習慣帶具體數據,說明決定對 delivery 能力的影響,而不是說「大家覺得不公平」。 - - 認為最難學會的管理技能是「不把自己的焦慮傳給團隊」。 - - 衝突介入的原則是先讓當事人自己解決,介入的觸發點是「問題開始影響到其他人」。 - - 好的 1-on-1 對他而言應以下屬的議題為主,主管的議題是例外而非預設。 - - 對「組織不讓說但自己覺得應該說」的資訊張力,有明確的處理哲學,不靠情緒決定。 - - 讚揚下屬時習慣說具體的行為和影響,而不是說「你做得很好」。 - creator_writer: - name: Creator / Writer - source: perplexity_max_domain_pack_v1 - domain_role: - - 內容創作者、作家、自媒體、編輯、UGC 創作者、品牌內容策略師 - core_task: - - 內容概念發想與企劃 - - 撰寫、剪輯、發布 - - 受眾研究與回饋整合 - - 個人品牌維護 - primary_counterparty: - - 讀者/觀眾、編輯、品牌方 - decision_partner: - - 合作夥伴、設計師、平台演算法(隱性) - skill_questions: - - id: creator_writer_skill_01 - title: 靈感來源與捕捉 - text: 你上一個讓你很興奮的題目是怎麼來的?你通常在什麼狀態下靈感最多? - purpose: 萃取創作觸發機制——是主動尋找還是被動接收;是否有捕捉的系統 - expected_anchor: 「靈感來了不記就不見,我用 X(Notes/實體本)做靈感捕捉,每週看一次」 - follow_up_max: 1 - stop_condition: 取得具體的靈感來源模式 - risk_level: low - - id: creator_writer_skill_02 - title: 創作流程結構 - text: 你從有一個題目到發布,通常要經過哪些步驟?有沒有哪個步驟你最常卡住? - purpose: 萃取工作流程化程度——是否有穩定的流程,還是每次都不同 - expected_anchor: 「我的草稿到最終稿之間一定要放至少一天,冷了再看才能發現問題」 - follow_up_max: 1 - stop_condition: 取得創作流程的關鍵節點 - risk_level: low - - id: creator_writer_skill_03 - title: 受眾反饋整合 - text: 你有沒有因為受眾的回饋改變了你的創作方式?還是你傾向不太被外部意見影響? - purpose: 萃取受眾導向 vs. 創作自主的平衡點 - expected_anchor: 「留言告訴我沒懂,我會調整切入角度;但留言說他不喜歡,我不一定理他」 - follow_up_max: 1 - stop_condition: 取得受訪者的受眾整合策略 - risk_level: low - - id: creator_writer_skill_04 - title: 品質標準 - text: 你有沒有一個你知道不夠好但還是發了的作品?你當時為什麼還是發? - purpose: 萃取品質 vs. 發布節奏的取捨策略 - expected_anchor: 「發布的勇氣比完美主義更重要,但有一個不會妥協的底線」 - follow_up_max: 1 - stop_condition: 取得「可以接受的不完美」的標準 - risk_level: low - - id: creator_writer_skill_05 - title: 創作低潮處理 - text: 你有沒有一段時間完全寫不出來,或做不出東西?你那段時間做了什麼? - purpose: 萃取對創作枯竭的應對策略——是強迫、是休息、是換形式 - expected_anchor: 「那段時間我大量輸入,停止輸出壓力,等到有什麼東西要說的時候自然就回來了」 - follow_up_max: 2 - stop_condition: 取得低潮的應對策略 - risk_level: low - - id: creator_writer_skill_06 - title: 商業創作 vs. 本心創作 - text: 你有沒有一個合作或案子,做完之後覺得不太是你想做的東西?你當時怎麼決定接? - purpose: 萃取商業邊界——是否有清楚的「接案標準」 - expected_anchor: 「接了一個品牌案,但產品不是我真的推薦的,那是我接的最後一個那種案」 - follow_up_max: 1 - stop_condition: 取得接案的邊界條件 - risk_level: low - - id: creator_writer_skill_07 - title: 跨平台策略 - text: 你在不同平台上的風格或主題一樣嗎?你怎麼決定什麼內容放在哪裡? - purpose: 萃取受眾分眾意識與平台差異理解 - expected_anchor: 「長文放部落格,碎片化想法放短影片,但核心觀點不因平台改變」 - follow_up_max: 1 - stop_condition: 取得平台差異化策略 - risk_level: low - - id: creator_writer_skill_08 - title: 個人品牌定位 - text: 你怎麼描述你的創作跟別人不一樣的地方?你最希望讀者/觀眾記住你的什麼? - purpose: 萃取自我差異化意識——是否能清楚說出 POV - expected_anchor: 「我希望大家記得的是:他說的事情我都能自己驗證,不是空話」 - follow_up_max: 1 - stop_condition: 取得自我定位敘事 - risk_level: low - people_questions: - - id: creator_writer_people_01 - title: 讀者/觀眾關係 - text: 有沒有一個讀者或觀眾,他的回應讓你改變了你做某件事的方式?是什麼事? - signal: 受眾連結的深度;是否把觀眾視為對話夥伴還是輸出對象 - - id: creator_writer_people_02 - title: 合作者信任 - text: 你有沒有找人合作過一個內容項目,合作過程最不舒服的是什麼? - signal: 合作邊界與溝通偏好 - - id: creator_writer_people_03 - title: 批評處理 - text: 你有沒有收過一個讓你不舒服的批評,但後來你承認他是對的?那個批評是什麼? - signal: 接受批評的機制;是否能分辨「有用的批評」和「情緒攻擊」 - - id: creator_writer_people_04 - title: 品牌方關係 - text: 你跟品牌合作的時候,你最常需要重申的事情是什麼? - signal: 創作自主性的維護策略 - - id: creator_writer_people_05 - title: 社群互動邊界 - text: 你怎麼管理自己跟社群之間的距離?有沒有你覺得太近或太遠的時候? - signal: 公眾關係中的個人邊界感 - voice_roleplays: - - id: creator_writer_voice_01 - title: 正式合作提案 - text: 一個品牌的 marketing 主管問你有沒有興趣合作,你需要先問清楚條件再決定要不要進一步談。請打一段回覆。 - extraction_target: 談判開場策略;是否主動定義合作條件 - - id: creator_writer_voice_02 - title: 拒絕不合適的合作 - text: 一個你不認同的品牌出了一個高於市場行情的報價。請打一封婉拒信。 - extraction_target: 拒絕方式;是否說出理由;語氣是否保持關係 - - id: creator_writer_voice_03 - title: 推進卡住的合作流程 - text: 你已交出內容初稿三週,品牌那邊一直說「在審核中」,你的發布計畫被卡住了。 - extraction_target: 催促策略;是否設定截止日期 - - id: creator_writer_voice_04 - title: 回應負面評論 - text: 你發了一篇文章,有人留言說「這個觀點根本就是錯的,你根本沒研究清楚」。 - extraction_target: 公開回應策略;是否能分辨內容批評和情緒攻擊 - - id: creator_writer_voice_05 - title: 創作延遲說明 - text: 你告訴訂閱者每週一篇,但這週因為個人狀況沒辦法發。請打一則公告。 - extraction_target: 對受眾的透明度;是否帶理由;是否帶下一步時間線 - boundaries_questions: - - id: creator_writer_boundaries_01 - title: 創作主導權 - text: 品牌合作中,你有沒有一個絕對不讓步的點?是什麼? - signal: 創作自主性的底線 - - id: creator_writer_boundaries_02 - title: 個人資訊邊界 - text: 你決定在創作裡分享多少自己的私人生活?這個邊界是怎麼劃的? - signal: 公私邊界;是否有策略性地決定揭露範圍 - - id: creator_writer_boundaries_03 - title: 發布與生活平衡 - text: 你有沒有一段時間覺得創作的量讓你喘不過氣?你是怎麼重新找到節奏的? - signal: 創作可持續性邊界;是否有節奏管理策略 - - id: creator_writer_boundaries_04 - title: 不接受的合作條件 - text: 有沒有一種合作要求,你直接說「這個我沒辦法接受」?那個要求是什麼? - signal: 創作誠信邊界 - - id: creator_writer_boundaries_05 - title: 社群壓力邊界 - text: 你有沒有因為受眾的期待,做了一個你其實不想做的內容?後來怎麼樣? - signal: 對受眾壓力的抵抗能力;是否能分辨「服務受眾」和「被受眾綁架」 - bad_questions: - - bad: 你每個月有多少流量/追蹤數? - why: 數字脫離脈絡,且易讓受訪者覺得被用數字評價 - better: 你做過一個你覺得沒爆但你自己很在乎的作品,那個作品對你的意義是什麼? - - bad: 你怎麼保持創意? - why: 太廣、易引出罐頭答案(「多出門多閱讀」) - better: 你上一個最花心思的題目,從哪裡來的? - - bad: 你有沒有 niche? - why: 英文術語對部分受訪者有距離感,且易引出自我標籤 - better: 你覺得你的讀者來找你,最主要是因為你能給他們什麼? - - bad: 你有沒有接過廣告? - why: 暗示評判商業化,易引起防衛 - better: 你在商業合作和創作自由之間有沒有發生過衝突?那次是怎麼發生的? - - bad: 你覺得你的寫作風格是什麼? - why: 自我定義偏向表演,而非真實信號 - better: 有沒有一篇你覺得最接近你想說的話的文章?為什麼是那篇? - persona_anchor_examples: - - 靈感捕捉有固定系統,不依賴記憶,因為「靈感來了不記就不見」。 - - 草稿完成後一定放至少一天才發布,冷掉再看才能找到問題。 - - 受眾說「沒懂」會調整切入角度,但「不喜歡」不一定會改變。 - - 接案標準包含「這是我真的推薦的東西嗎」,不只看報酬。 - - 發布節奏比完美主義更重要,但對自己有一條不妥協的品質底線,能清楚說出那條線在哪。 - - 對創作低潮的應對是大量輸入、停止輸出壓力,而不是強迫自己產出。 - - 在不同平台上的核心觀點不因平台演算法改變,但形式和密度會調整。 - - 把「讀者能自己驗證我說的事」視為核心品牌承諾,而不是「讀者喜歡我」。 - - 公私邊界是策略性決定,而不是隨機透露——知道哪些事可以說、哪些事有代價。 - - 對負面評論能快速分辨「有建設性的批評」和「情緒攻擊」,並用不同方式應對。 - - 對社群壓力有清楚的底線——能說出一個他「不會因為受眾期待而做的事」。 - - 創作停滯期不視為失敗,視為必要的重新充電,且有具體的啟動儀式來結束停滯。 - teacher_coach: - name: Teacher / Coach - source: perplexity_max_domain_pack_v1 - domain_role: - - 教師、培訓師、企業內訓師、教練(life/executive/career)、導師 - core_task: - - 評估學習者狀態與需求 - - 設計與執行學習/coaching 路徑 - - 給予有效反饋 - - 支持自我突破與行為改變 - primary_counterparty: - - 學生、學員、被 coaching 的人 - decision_partner: - - 課程設計夥伴、共同督導、機構主管 - skill_questions: - - id: teacher_coach_skill_01 - title: 診斷學習者狀態 - text: 你第一次跟一個新的學員或學生接觸,你怎麼評估他現在的狀態和他真正需要什麼? - purpose: 萃取初步診斷方法——是問問題、觀察行為,還是靠測試 - expected_anchor: 「我會先問他最想解決的問題,再問他嘗試過什麼,這樣就能大概知道他在哪個階段」 - follow_up_max: 2 - stop_condition: 取得具體的評估流程 - risk_level: low - - id: teacher_coach_skill_02 - title: 課程/路徑設計 - text: 你有沒有一個你很滿意的課程或 coaching 計畫設計?它和你之前的版本有什麼不同? - purpose: 萃取設計邏輯——是以結果導向還是以內容導向 - expected_anchor: 「從「學了這個能做什麼」出發,而不是從「我想教什麼」出發」 - follow_up_max: 1 - stop_condition: 取得設計哲學 - risk_level: low - - id: teacher_coach_skill_03 - title: 反饋給予風格 - text: 你給過一個讓學員不舒服但後來他感謝你的反饋,那個反饋說了什麼? - purpose: 萃取直接反饋的能力與方式 - expected_anchor: 「我傾向先說我看到的行為,再說那個行為在外部的影響,最後問他自己怎麼看」 - follow_up_max: 2 - stop_condition: 取得具體的反饋結構 - risk_level: low - - id: teacher_coach_skill_04 - title: 學習卡關的處理 - text: 你有沒有一個學員,他怎樣都跨不過某個關卡,你最後用什麼方式讓他突破的? - purpose: 萃取卡關診斷——是能力問題、是信念問題,還是環境問題 - expected_anchor: 「通常卡關不是不會,而是有個他說不出口的擔心;我習慣先問他最壞的情況是什麼」 - follow_up_max: 2 - stop_condition: 取得具體的突破策略 - risk_level: low - - id: teacher_coach_skill_05 - title: 對個別差異的適應 - text: 你怎麼調整你的教學或 coaching 方式,讓不同類型的人都能接受?有沒有一個你花最多力氣調整的例子? - purpose: 萃取個人化程度——是否有意識地切換 - expected_anchor: 「行動型的人我直接給任務,分析型的人我先給框架,用的語言完全不同」 - follow_up_max: 1 - stop_condition: 取得至少兩種風格切換範例 - risk_level: low - - id: teacher_coach_skill_06 - title: 成效評估 - text: 你怎麼知道你的教學或 coaching 有沒有效?你用什麼來判斷,而不只是靠感覺? - purpose: 萃取成效評估的嚴謹度 - expected_anchor: 「每個 coaching 案例在開始前都要定義一個具體的行為指標,不然結束時很難評估」 - follow_up_max: 1 - stop_condition: 取得具體的評估方式 - risk_level: low - - id: teacher_coach_skill_07 - title: 邊界衝突處理 - text: 你有沒有一個學員或被 coaching 的人,開始依賴你超出了正常的範圍?你怎麼處理? - purpose: 萃取對「依賴」的應對策略——是否能在關懷和邊界間平衡 - expected_anchor: 「我的目標是讓他不需要我,如果他越來越需要我,代表我可能做錯了什麼」 - follow_up_max: 2 - stop_condition: 取得過度依賴的邊界應對 - risk_level: low - - id: teacher_coach_skill_08 - title: 自我更新 - text: 你有沒有一個你以前教的東西,現在你的想法不一樣了?你怎麼更新自己的內容? - purpose: 萃取知識更新意願——是否願意推翻舊有框架 - expected_anchor: 「如果我教的東西和我最新的實踐有落差,我寧可調整課程,不要讓它成為一套舊話術」 - follow_up_max: 1 - stop_condition: 取得自我更新的觸發機制 - risk_level: low - people_questions: - - id: teacher_coach_people_01 - title: 最深刻的學員 - text: 你教過或 coach 過的人裡面,有沒有一個讓你改變了你對某件事的看法?是什麼? - signal: 是否能從學員身上學習;關係是否是單向的 - - id: teacher_coach_people_02 - title: 衝突與抵抗 - text: 你有沒有一個學員或學生,一直在抗拒你說的東西,但你覺得他才是對的那個? - signal: 對「正確抵抗」的尊重;是否能放下教者身分 - - id: teacher_coach_people_03 - title: 學員失敗的責任感 - text: 你有沒有一個學員沒有達到他設定的目標,你覺得自己有責任嗎?那次你怎麼反思? - signal: 責任感邊界;是否會過度承擔學員的失敗 - - id: teacher_coach_people_04 - title: 同儕督導 - text: 你有沒有一個你信任的人,你會把你的教學或 coaching 實踐拿給他看、讓他給你反饋? - signal: 自我精進機制;是否有能力接受針對自己專業的批評 - - id: teacher_coach_people_05 - title: 學員成功的歸因 - text: 你帶過一個成果很好的學員,你覺得他的成功有多少是你的功勞? - signal: 成就歸因——是過度謙虛還是過度邀功;是否能清楚說出自己的貢獻 - voice_roleplays: - - id: teacher_coach_voice_01 - title: 正式學習啟動訊息 - text: 新的一對一 coaching 週期開始,你需要傳一封訊息給新學員,說明接下來的合作方式。 - extraction_target: 框架設定語氣;是否建立清楚的期待 - - id: teacher_coach_voice_02 - title: 拒絕不適合的委託 - text: 有人問你能不能幫他「改變他不想上學的孩子的態度」,但這不在你的專業範圍。 - extraction_target: 轉介建議方式;是否能誠實說明能力邊界而不傷對方的期待 - - id: teacher_coach_voice_03 - title: 推進停滯的 coaching 過程 - text: 你的學員已三週沒完成你們約定好的行動項目,但每次都說「最近太忙」。 - extraction_target: 面質策略;是否能在不批評的情況下讓對方正視模式 - - id: teacher_coach_voice_04 - title: 學員反彈你的觀點 - text: 你在 coaching 中說了一個觀點,學員說「我不同意,我覺得你說的不適用在我這裡」。 - extraction_target: 是否有能力接住反彈;是否能把抵抗轉成探索材料 - - id: teacher_coach_voice_05 - title: 結束 coaching 關係 - text: 你覺得你和某個學員已經到了這段 coaching 能給的極限,你需要告訴他這件事。 - extraction_target: 結束關係的語言;是否能帶著尊重說出「我們到了一個段落」 - boundaries_questions: - - id: teacher_coach_boundaries_01 - title: 可教範圍邊界 - text: 有沒有一種學習需求,你會判斷這個人需要的不是我的 coaching,而是別的支持?你怎麼判斷? - signal: 轉介能力;是否清楚自己的專業邊界(例如心理健康) - - id: teacher_coach_boundaries_02 - title: 關係邊界 - text: 你和學員的關係,你怎麼讓它維持在「專業支持」而不是「私人情感依賴」? - signal: 角色邊界的維持機制 - - id: teacher_coach_boundaries_03 - title: 不可接受的委託 - text: 有沒有一種培訓或 coaching 委託,你接了會讓你對自己不舒服?是什麼條件讓你說不? - signal: 倫理邊界——例如不願意幫助「改造人」而不是「支持人成長」 - - id: teacher_coach_boundaries_04 - title: 個人成本邊界 - text: 教學或 coaching 很容易變成無限的情緒勞動,你有沒有一段時間覺得你付出超過了你能負荷的?你怎麼找回邊界? - signal: 可持續工作的自我保護機制 - - id: teacher_coach_boundaries_05 - title: 成果承諾邊界 - text: 你在跟學員簽約或開始合作前,你通常怎麼設定對成果的期待?你有沒有一個你不會保證的事? - signal: 成果責任的邊界——是否有「我負責過程,不負責結果」或其他明確的框架 - bad_questions: - - bad: 你是什麼風格的老師/教練? - why: 邀請自我貼標籤,答案永遠是「以人為本」 - better: 你教過的人,他們通常說你讓他們最有感的是什麼? - - bad: 你覺得教學最重要的是什麼? - why: 太哲學,答案是老套的「啟發學習者動機」 - better: 你做過一個決定,讓你的某個學員的學習開始不一樣,那個決定是什麼? - - bad: 你怎麼對待不想學習的學生? - why: 太籠統,且暗示「不想學習」是可以解決的 - better: 你有沒有一個學員,你後來判斷不管你做什麼,他現在就是沒準備好?你當時怎麼做的? - - bad: 你有沒有執照或認證? - why: 第一個問題就問資格,易讓受訪者感到被審查 - better: 你是什麼契機開始在這個領域教學/coaching 的? - - bad: 你的學員成功率是多少? - why: 沒有可比標準,且暗示 coaching 成敗由教練決定 - better: 你怎麼評估一段 coaching 關係的成功?你會用什麼訊號判斷? - persona_anchor_examples: - - 初次接觸新學員時習慣先問「最想解決的問題」和「嘗試過的方法」,用來快速定位學習階段。 - - 課程設計從「學了這個能做什麼」出發,而不是從「我想教什麼」出發。 - - 給反饋的結構是:先說看到的行為,再說外部影響,最後問學員自己怎麼看。 - - 認為學員卡關通常不是「不會」,而是有個說不出口的擔心——習慣用「最壞的情況是什麼」來開啟這個對話。 - - 對行動型學員直接給任務,對分析型學員先給框架,這兩種語言是完全不同的系統。 - - 每個 coaching 案在開始前定義一個具體的行為指標,而不是用「感覺有沒有進步」衡量。 - - 把「學員越來越不需要我」視為 coaching 成功的指標,而不是「學員越來越依賴我」。 - - 如果自己目前的實踐和教的內容有落差,會主動更新課程,不讓舊話術繼續流通。 - - 對學員的失敗有清楚的責任邊界——負責過程和工具,不對學員的最終選擇負全責。 - - 轉介能力是核心能力之一——能清楚說出「這個需求超出 coaching 的範疇,他需要的是 X」。 - - 在沒有委託的情況下不給建議,讓學員自己說出方向是 coaching 的核心方法。 - - 對「成果保證」有明確的語言邊界,不會說「我保證你一定能做到」,但能說「我承諾我的陪伴是全力以赴的」。 - founder_operator: - name: Founder / Operator - source: perplexity_max_domain_pack_v1 - domain_role: - - 創辦人、共同創辦人、執行長、COO、早期員工 - core_task: - - 策略制定與公司方向把舵 - - 資源(資金、人才)取得與分配 - - 產品/市場驗證 - - 組織建立與文化塑造 - - 危機管理與不確定性下的決策 - primary_counterparty: - - 投資人、共同創辦人、早期員工、客戶 - decision_partner: - - 共同創辦人、顧問、董事會、信任的外部人士 - skill_questions: - - id: founder_operator_skill_01 - title: 核心決策框架 - text: 你有沒有做過一個決定,事後回看你覺得是你做過最正確的決定之一?你當時怎麼想到這樣做? - purpose: 萃取決策邏輯的結構——是資料驅動、直覺信任,還是討論收斂 - expected_anchor: 「那個決定我做了兩週的小規模測試,沒等到完整數據就決定了,因為我覺得等下去機會就沒了」 - follow_up_max: 2 - stop_condition: 取得受訪者的決策觸發邏輯 - risk_level: low - - id: founder_operator_skill_02 - title: 放棄某個方向 - text: 你有沒有放棄過一個你花了很多時間和資源的方向?那個決定是怎麼做出來的? - purpose: 萃取 pivot / kill 決策的邏輯——是否能快速認錯;是否會因沉沒成本卡住 - expected_anchor: 「我有一個『如果這是我今天才看到的機會,我還會做嗎?』的自問,一旦答案是不,我就開始認真想放棄」 - follow_up_max: 2 - stop_condition: 取得放棄決策的觸發條件 - risk_level: medium - - id: founder_operator_skill_03 - title: 資源極度有限下的優先序 - text: 你在資源最緊張的時候,你怎麼決定錢或人力放在哪裡?有沒有一個你覺得這個取捨很痛,但你知道你做對了? - purpose: 萃取資源分配邏輯——是否有清楚的生存優先排序 - expected_anchor: 「那個時期我的原則是:活下去的事情優先,增長的事情有餘力才做」 - follow_up_max: 2 - stop_condition: 取得資源緊張時的優先序原則 - risk_level: low - - id: founder_operator_skill_04 - title: 找人與選人 - text: 你找過一個你後來覺得選錯了的人,你當時為什麼選他?你從那次學到了什麼? - purpose: 萃取人才判斷的自我修正——是否能說出具體的教訓 - expected_anchor: 「那次我選了一個亮眼履歷但沒有在 chaos 環境工作過的人,我沒確認他對模糊和變動的耐受度」 - follow_up_max: 2 - stop_condition: 取得選錯人的具體教訓 - risk_level: low - - id: founder_operator_skill_05 - title: 危機應對模式 - text: 你有沒有遇過一個你覺得公司可能活不下去的時刻?那個時候你做了什麼,你自己的狀態是什麼? - purpose: 萃取危機行為模式——是否能在極端壓力下保持清晰 - expected_anchor: 「那段時間我的原則是:先穩住自己,再穩住團隊,問題的解法是第三步」 - follow_up_max: 2 - stop_condition: 取得危機應對的行為模式 - risk_level: low - - id: founder_operator_skill_06 - title: 投資人/董事會關係 - text: 你有沒有一次你的判斷和投資人的想法不一樣,你怎麼處理? - purpose: 萃取獨立決策能力——是否能在資金壓力下維持自己的方向判斷 - expected_anchor: 「我把我的邏輯完整說清楚,讓投資人知道我考慮了什麼,再告訴他我的決定是什麼」 - follow_up_max: 1 - stop_condition: 取得與投資人的意見衝突處理策略 - risk_level: low - - id: founder_operator_skill_07 - title: 文化塑造 - text: 你有沒有一件事,你做了之後,你的團隊開始出現一個你想要的行為模式?那件事是什麼? - purpose: 萃取文化設計的具體手法 - expected_anchor: 「我開始在每週全員會議上分享自己犯的錯,後來大家都開始在自己的範疇這樣做」 - follow_up_max: 1 - stop_condition: 取得一個具體的文化行為設計範例 - risk_level: low - - id: founder_operator_skill_08 - title: 創辦人孤獨感 - text: 創辦人很多決定沒辦法跟別人討論,你怎麼在這種狀態下保持判斷力清晰? - purpose: 萃取孤獨決策下的自我管理策略 - expected_anchor: 「我有一個固定跟三到四個我信任的人的月會,他們不一定懂我的行業,但他們能幫我看清楚我自己的狀態」 - follow_up_max: 1 - stop_condition: 取得孤獨決策的應對方式 - risk_level: low - people_questions: - - id: founder_operator_people_01 - title: 共同創辦人關係 - text: 你和共同創辦人(或早期核心夥伴)有沒有過一次嚴重的意見衝突?那次是怎麼解的? - signal: 最重要的合夥關係處理;是否有健康的衝突解法 - - id: founder_operator_people_02 - title: 放棄一個人 - text: 你有沒有讓一個很早就加入的人離開,那個決定你怎麼走到的? - signal: 對創始團隊人員更替的處理;情感與組織需求的平衡 - - id: founder_operator_people_03 - title: 投資人信任 - text: 你怎麼判斷一個投資人是你想要的夥伴,而不只是想要他的錢? - signal: 對資金以外的投資人價值的評估能力 - - id: founder_operator_people_04 - title: 員工離心 - text: 你有沒有感覺到某一段時間,你的團隊對公司方向失去信心?你怎麼察覺、又怎麼應對? - signal: 組織信號敏感度;危機中的溝通策略 - - id: founder_operator_people_05 - title: 創辦人與員工的邊界 - text: 你和你的早期員工,邊界是清楚的嗎?你有沒有發現哪裡的邊界需要重新劃? - signal: 角色與關係邊界的管理——創辦人很容易與早期員工建立過度的情感依賴 - voice_roleplays: - - id: founder_operator_voice_01 - title: 全員重要訊息 - text: 公司剛做了一個策略轉向,方向和你三個月前說的不一樣。你需要向全員說明。 - extraction_target: 說清楚轉向理由的能力;是否帶信心感但不迴避矛盾 - - id: founder_operator_voice_02 - title: 拒絕投資條款 - text: 一個投資人給了你資金 offer,但其中一個條款你接受了會讓你的控制權受損。請打一封給投資人的信。 - extraction_target: 談判策略;是否能在拒絕條款的同時維持關係 - - id: founder_operator_voice_03 - title: 推進停滯中的合作 - text: 你一個關鍵合作夥伴(可能是通路/供應商/技術夥伴)最近回覆越來越慢,你需要讓這個關係重新活起來。 - extraction_target: 關係重啟策略;是否帶價值點 - - id: founder_operator_voice_04 - title: 公司危機內部溝通 - text: 公司剛失去一個大客戶,這件事會影響這個季度的數字。你需要在 30 分鐘後的全員會議說明這件事。 - extraction_target: 危機中的透明度與穩定感;是否能同時誠實和提振信心 - - id: founder_operator_voice_05 - title: 修復與共同創辦人的關係 - text: 你和共同創辦人上週有一次很激烈的爭論,你說了一些話你後來覺得超出了邊界。 - extraction_target: 創辦人夥伴修復語言;是否能承認而不失去領導感 - boundaries_questions: - - id: founder_operator_boundaries_01 - title: 不可談判的創業底線 - text: 你有沒有一個條件,投資人或客戶提出來你直接說「不行,這個我不談」?那個條件是什麼? - signal: 核心控制點認知;是否清楚自己的 non-negotiable - - id: founder_operator_boundaries_02 - title: 創辦人個人邊界 - text: 創業之後,你有沒有某段時間覺得「我」消失了,只剩下「公司」?你怎麼重新找回? - signal: 個人身份與公司身份的邊界;可持續性 - - id: founder_operator_boundaries_03 - title: 不接受的投資人行為 - text: 投資人有沒有做過什麼讓你覺得越界的事?那個越界是什麼? - signal: 治理邊界;是否有能力在資金關係中維護決策主權 - - id: founder_operator_boundaries_04 - title: 放手授權邊界 - text: 你有沒有一件事,你交給別人做,但到最後還是你親自接手?你後來的判斷是:那次授權是失敗的,還是你的干預是多餘的? - signal: 創辦人微管理傾向;授權能力與信任門檻 - - id: founder_operator_boundaries_05 - title: 公司存亡決策邊界 - text: 你有沒有想過,如果某件事發生了,你會選擇結束這家公司?那個條件是什麼? - signal: 創辦人對「退出」的心理準備;是否能清楚說出放棄的邏輯而不只是情緒 - bad_questions: - - bad: 你的公司現在估值是多少? - why: 太直接、且可能 NDA 保護,易讓受訪者關閉 - better: 這個階段你最在意的一個指標是什麼,為什麼是那個? - - bad: 你有沒有想過放棄? - why: 太情緒化,且易引出英雄敘事式的假答案 - better: 你有沒有一個時刻,你認真評估過要轉向或停止某個方向?當時你怎麼決定繼續的? - - bad: 你怎麼平衡工作和生活? - why: 老套問題,答案通常是「其實沒辦法平衡」或是一個正向假裝 - better: 你有沒有一段時間覺得公司讓你消耗到了一個讓你不舒服的程度?你是怎麼注意到的? - - bad: 你覺得你的競爭優勢是什麼? - why: 易引發 pitch 模式而不是真實反思 - better: 你有沒有遇過一個客戶,他在你和競品之間選了你,你後來才知道真正的理由是什麼? - - bad: 你的創業動機是什麼? - why: 「創業動機」問題幾乎一定得到品牌化答案 - better: 在你決定做這件事之前,你有沒有一個讓你最猶豫的風險?是什麼讓你最後還是跨出去了? - persona_anchor_examples: - - 做決定時有一個「如果這是我今天才看到的機會,我還會做嗎?」的自問,一旦答案是「不」就開始認真考慮放棄。 - - 資源最緊張時的優先序原則是:先讓公司活下去,增長是有餘力才做的事。 - - 面對危機的行動順序是:先穩住自己,再穩住團隊,解決問題是第三步。 - - 和投資人意見不同時,習慣把自己的完整邏輯說清楚,再告訴對方自己的決定,而不是等對方核准。 - - 文化塑造偏好靠「公開承認自己的錯誤」來示範,而不是靠訂規定。 - - 有固定與 3-4 個信任者的月會,用來維持在孤獨決策中的判斷力清晰。 - - 對選錯人有具體的教訓敘事,能說出「下次會多問什麼問題」。 - - 認為共同創辦人關係需要像合夥契約一樣被維護,不因為熟就省略邊界對話。 - - 對「不接受的投資條款」有清楚的語言——能說出哪個條件是 dealbreaker 而不只是「感覺不對」。 - - 對公司和個人身份的邊界有意識,能說出一個具體的方式讓自己在創業高壓期還是保有「自己」的空間。 - - 在全員溝通策略轉向時,習慣直接說為什麼之前的方向是錯的,而不是用「進化」或「升級」等術語迴避。 - - 把「讓早期員工離開」視為最難但有時必要的決定,且能說出自己走到那個決定的邏輯。 diff --git a/src/virtualme/data/question-pool-v2.yaml b/src/virtualme/data/question-pool-v2.yaml deleted file mode 100644 index b390d76..0000000 --- a/src/virtualme/data/question-pool-v2.yaml +++ /dev/null @@ -1,1024 +0,0 @@ -version: 2 -status: draft -source: - - Perplexity Max interview design handbook - - SuperGrok product flow review - -meta: - target_questions_per_dimension: 8 - target_total_questions: 64 - intake_questions: 5 - default_follow_up_max: 2 - production_enabled: false - -intake: - purpose: "在正式萃取前校準受訪者領域,避免後續題目過於泛用或 placeholder 外洩。" - completion_rule: "取得 domain_role、core_task、primary_counterparty、decision_partner;如果使用者不想定義,可先用 broad mode。" - questions: - - id: intake_01_domain - text: "在開始前,我想先知道你的主要領域或角色。你會怎麼介紹自己現在主要在做什麼?" - captures: [domain_role] - user_explain: "這會讓後面的問題更貼近你的工作與生活脈絡。" - follow_up_max: 1 - stop_condition: "得到一個角色或領域描述。" - risk_level: low - - id: intake_02_core_task - text: "你最常反覆處理的核心工作或決策是什麼?" - captures: [core_task] - user_explain: "我會用這個校準後面的 SKILL 題,不會問得太泛。" - follow_up_max: 1 - stop_condition: "得到一個主要工作流程或決策類型。" - risk_level: low - - id: intake_03_counterparty - text: "你平常最常跟哪些人互動?例如同事、客戶、主管、部屬、合作夥伴或社群朋友。" - captures: [primary_counterparty, counterparty] - user_explain: "這會讓 PEOPLE 和 VOICE 題更像真實情境。" - follow_up_max: 1 - stop_condition: "得到 1-3 種常見互動對象。" - risk_level: low - - id: intake_04_decision_partner - text: "你通常跟誰討論範圍、優先順序、預算、時程或取捨?" - captures: [decision_partner] - user_explain: "這能幫我理解你做決策時真正需要協調的人。" - follow_up_max: 1 - stop_condition: "得到一種決策協調對象;若沒有,標記為 mostly_self_decided。" - risk_level: low - - id: intake_05_use_case - text: "你希望 VirtualMe 之後主要幫你做什麼?回訊息、整理想法、替你判斷事情,還是別的?" - captures: [virtualme_use_case] - user_explain: "這會影響我優先萃取語氣、判斷邏輯,還是工作方法。" - follow_up_max: 1 - stop_condition: "得到一個主要 use case。" - risk_level: low - -dimensions: - STATE: - name: "近況" - purpose: "當前優先級、近期壓力源、能量分佈、最近改變了什麼想法。" - avoid: "不要把短期狀態當成長期人格定論。" - completion_threshold: 75 - HISTORY: - name: "人生歷程" - purpose: "轉折點、決定性事件、長期習慣的形成原因、困難中的行動選擇。" - avoid: "不要蒐集無詮釋的履歷流水帳。" - completion_threshold: 80 - SOUL: - name: "靈魂・核心價值" - purpose: "核心信念、人生判準、行動背後的理由、重複出現的價值取捨。" - avoid: "不要收集沒有行為錨點的抽象自我標籤。" - completion_threshold: 85 - PEOPLE: - name: "人際關係" - purpose: "信任建立機制、合作偏好、影響圈、關係中的期待與退出條件。" - avoid: "不要引導使用者八卦或評斷他人。" - completion_threshold: 80 - SKILL: - name: "專業技能" - purpose: "可複製的工作流程、決策依據、工具偏好、實作與學習方法。" - avoid: "不要只收履歷式技能清單。" - completion_threshold: 80 - JOURNAL: - name: "反思札記" - purpose: "反思頻率與深度、失敗消化方式、自我敘事與自我修正模式。" - avoid: "不要做心理治療或追問創傷細節。" - completion_threshold: 75 - BOUNDARIES: - name: "界線・原則" - purpose: "不可接受線、拒絕方式、授權範圍、原則在壓力下如何維持。" - avoid: "不要收集政治正確但無行為依據的宣稱。" - completion_threshold: 85 - VOICE: - name: "語氣・表達" - purpose: "說話節奏、慣用詞、訊息風格、衝突時表達、什麼話不會說。" - avoid: "不要只收使用者刻意表演的形象語氣。" - completion_threshold: 85 - -progress_prompts: - p00_20: "我們剛起步,不用一次說完。今天我會一次只問一題,隨時可以停。" - p20_50: "我們已經有一些脈絡了,接下來會慢慢補你怎麼判斷、怎麼做事。" - p50_80: "已經過半了。後面會比較像在補你的思考模式和界線,可以說多說少。" - p80_95: "快到了,剩下是幾個缺口和說話風格樣本。" - p95_plus: "基本上都聊完了,最後會請你看哪些描述不像你。" - -transitions: - STATE_to_HISTORY: "我對你現在的狀態有初步感覺了。接下來想往前一點,理解你是怎麼走到現在。" - HISTORY_to_SOUL: "剛才的歷程裡有一些選擇很關鍵。接下來我想補你做選擇背後的判準。" - SOUL_to_PEOPLE: "你的價值判準比較清楚了。下一塊我想看它怎麼出現在你跟人的互動裡。" - PEOPLE_to_SKILL: "人際合作的模式有輪廓了。接下來我想看你實際怎麼做事、怎麼解題。" - SKILL_to_JOURNAL: "你的工作方法我抓到一些了。下一段想了解你事後怎麼復盤和修正。" - JOURNAL_to_BOUNDARIES: "反思方式聊完後,我想補你的界線:哪些事你會拒絕或不授權。" - BOUNDARIES_to_VOICE: "界線清楚後,最後要收的是你實際會怎麼說,讓 VirtualMe 回覆更像你。" - skip: "這個方向我們先放著。不是每題都一定要聊,我們直接換下一個比較適合的部分。" - -questions: - - id: state_01 - dimension: STATE - stage: warmup - text: "最近這陣子生活過得怎麼樣?有什麼事讓你特別有動力,或是有點壓力?" - purpose: "捕捉當前情緒、壓力源、能量來源與短期目標。" - user_explain: "我想先從你現在的狀態開始,這樣 VirtualMe 回覆時比較貼近你目前的感受。" - expected_anchor: fact - acceptable_answers: [daily_story, emotion, concrete_example] - follow_ups: - - "它最近大概佔了你多少心力?" - - "你通常怎麼處理這種狀態?" - follow_up_max: 2 - stop_condition: "取得 1-2 個具體事項與感受,或使用者想換話題。" - risk_level: low - optional: false - - id: state_02 - dimension: STATE - stage: warmup - text: "目前你最想完成的 1-2 個目標是什麼?為什麼現在對你重要?" - purpose: "萃取短期目標與動機優先序。" - user_explain: "這能讓 VirtualMe 知道你現在最在意什麼,做判斷時比較不會偏掉。" - expected_anchor: fact - acceptable_answers: [goal, reason] - follow_ups: - - "如果只能先推一件,你會選哪一件?" - - "這件事卡住的主要原因是什麼?" - follow_up_max: 2 - stop_condition: "有明確目標與理由。" - risk_level: low - optional: false - - id: state_03 - dimension: STATE - stage: warmup - text: "這陣子最讓你感到放鬆或有趣的事情是什麼?" - purpose: "捕捉恢復能量方式與近期興趣。" - user_explain: "我想知道你怎麼充電,這會影響 VirtualMe 的語氣和建議方向。" - expected_anchor: fact - acceptable_answers: [activity, interest] - follow_ups: - - "它讓你放鬆的是哪一個部分?" - follow_up_max: 1 - stop_condition: "有具體活動或明確表示不想多說。" - risk_level: low - optional: false - - id: state_04 - dimension: STATE - stage: warmup - text: "最近有什麼事讓你覺得「還好有做這件事」?" - purpose: "連結當前成就感與正向動機來源。" - user_explain: "這題是想抓你最近覺得值得的事情。" - expected_anchor: fact - acceptable_answers: [story, feeling] - follow_ups: - - "如果沒做,現在會有什麼不同?" - - "它讓你對自己多理解了什麼?" - follow_up_max: 2 - stop_condition: "有事件與價值感連結。" - risk_level: low - optional: false - - id: state_05 - dimension: STATE - stage: warmup - text: "如果下個月你只能改善一件事,你會想先改善什麼?" - purpose: "萃取當前瓶頸與短期期待。" - user_explain: "這能讓 VirtualMe 知道你近期最想往哪裡調整。" - expected_anchor: fact - acceptable_answers: [plan, bottleneck] - follow_ups: - - "它現在為什麼還沒解決?" - follow_up_max: 1 - stop_condition: "說出一件事與原因。" - risk_level: low - optional: false - - id: state_06 - dimension: STATE - stage: warmup - text: "最近你最常分心想的事情是什麼?" - purpose: "捕捉目前認知負荷與隱性壓力。" - user_explain: "這不是要分析你,只是想知道最近什麼事情最佔腦容量。" - expected_anchor: fact - acceptable_answers: [concern, recurring_thought] - follow_ups: - - "它通常在什麼時候冒出來?" - follow_up_max: 1 - stop_condition: "取得一個反覆出現的念頭或使用者跳過。" - risk_level: medium - optional: true - - id: state_07 - dimension: STATE - stage: warmup - text: "最近有沒有什麼事情改變了你的想法或習慣?" - purpose: "萃取近期認知更新。" - user_explain: "近期改變會讓 VirtualMe 不只像過去的你,也比較像現在的你。" - expected_anchor: pattern - acceptable_answers: [change, trigger] - follow_ups: - - "這個改變是你主動選的,還是被情境推著走?" - follow_up_max: 1 - stop_condition: "有具體改變與觸發原因。" - risk_level: low - optional: false - - id: state_08 - dimension: STATE - stage: warmup - text: "如果用一個天氣或電量形容你最近的狀態,會是什麼?" - purpose: "低壓取得能量狀態與自我感受。" - user_explain: "這題比較輕鬆,讓我抓你現在大概是滿電、中電還是低電。" - expected_anchor: fact - acceptable_answers: [metaphor, energy_level] - follow_ups: - - "是什麼讓它變成這個狀態?" - follow_up_max: 1 - stop_condition: "有比喻或能量描述。" - risk_level: low - optional: false - - - id: history_01 - dimension: HISTORY - stage: timeline - text: "可以簡單分享一下你是怎麼一步步走到現在這個職涯或生活階段的嗎?" - purpose: "建立人生與職涯時間線。" - user_explain: "我想先聽你的故事,這樣 VirtualMe 才知道你是怎麼形成現在的樣子。" - expected_anchor: fact - acceptable_answers: [timeline, turning_point] - follow_ups: - - "其中哪一段最不像你原本預期?" - - "哪一個選擇最關鍵?" - follow_up_max: 2 - stop_condition: "出現 2-3 個主要階段。" - risk_level: low - optional: false - - id: history_02 - dimension: HISTORY - stage: timeline - text: "你職涯中有沒有某個轉捩點,讓你走上一條不同的路?" - purpose: "萃取 life turning point 與選擇邏輯。" - user_explain: "轉捩點通常會留下很穩定的判斷模式。" - expected_anchor: fact - acceptable_answers: [incident, choice] - follow_ups: - - "如果當時選了另一條路,你覺得現在會在哪裡?" - - "那次之後,你判斷事情有變嗎?" - follow_up_max: 2 - stop_condition: "描述事件、選擇與後續影響。" - risk_level: low - optional: false - - id: history_03 - dimension: HISTORY - stage: timeline - text: "你什麼時候第一次覺得「我知道自己在做什麼了」?" - purpose: "找能力自信形成時刻。" - user_explain: "這能幫我理解你怎麼建立自信。" - expected_anchor: fact - acceptable_answers: [moment, confidence_signal] - follow_ups: - - "是什麼讓你有這種感覺?" - follow_up_max: 1 - stop_condition: "有具體情境與感覺來源。" - risk_level: low - optional: false - - id: history_04 - dimension: HISTORY - stage: timeline - text: "有沒有某個決定,你當初覺得在賭,但後來證明賭對了?" - purpose: "萃取風險偏好與直覺信任。" - user_explain: "我想看你在不確定時怎麼下注。" - expected_anchor: pattern - acceptable_answers: [risk, decision, result] - follow_ups: - - "當時你看見了什麼別人沒看見的訊號?" - - "你現在還會做同樣決定嗎?" - follow_up_max: 2 - stop_condition: "有風險、判斷依據與結果。" - risk_level: low - optional: false - - id: history_05 - dimension: HISTORY - stage: timeline - text: "你人生或工作中最艱難的一段時期是什麼?你那時候怎麼撐過去的?" - purpose: "萃取逆境因應模式。" - user_explain: "如果你願意說,這能幫我理解你遇到壓力時真正會怎麼做。" - expected_anchor: pattern - acceptable_answers: [hard_time, coping] - follow_ups: - - "那段時間結束後,你有什麼不一樣?" - follow_up_max: 1 - stop_condition: "說出因應方式;若涉及創傷或不願說,立即跳過。" - risk_level: high - optional: true - - id: history_06 - dimension: HISTORY - stage: timeline - text: "有沒有某個人,在你不知不覺中改變了你的方向?" - purpose: "找隱性影響者與學習來源。" - user_explain: "影響你的人,通常也會影響你後來怎麼看人和做選擇。" - expected_anchor: fact - acceptable_answers: [person, influence] - follow_ups: - - "他影響你的是想法、做法,還是價值觀?" - follow_up_max: 1 - stop_condition: "說出影響機制。" - risk_level: low - optional: false - - id: history_07 - dimension: HISTORY - stage: timeline - text: "如果五年前的你看到現在的你,最驚訝的會是什麼?" - purpose: "萃取自我變化與長期敘事。" - user_explain: "這題是想看你覺得自己哪裡真的變了。" - expected_anchor: pattern - acceptable_answers: [self_change, surprise] - follow_ups: - - "這個變化是你主動選的,還是被環境推著走?" - follow_up_max: 1 - stop_condition: "指出一個變化與原因。" - risk_level: low - optional: false - - id: history_08 - dimension: HISTORY - stage: timeline - text: "有沒有哪個你曾經後悔的專業決定,後來反而變成重要教訓?" - purpose: "萃取錯誤後的學習與判斷修正。" - user_explain: "不是要檢討你,是想知道你怎麼從錯誤裡調整判斷。" - expected_anchor: principle - acceptable_answers: [mistake, lesson] - follow_ups: - - "如果重來一次,你會用什麼標準判斷?" - follow_up_max: 1 - stop_condition: "有教訓或使用者不想談。" - risk_level: medium - optional: true - - - id: soul_01 - dimension: SOUL - stage: depth - text: "你做過最不划算、但還是覺得應該做的決定是什麼?" - purpose: "萃取在代價下仍守住的價值。" - user_explain: "這題能看出什麼東西對你來說比效率或利益更重要。" - expected_anchor: principle - acceptable_answers: [decision, tradeoff, reason] - follow_ups: - - "為什麼那時候還是覺得值得?" - - "現在回頭看,你會改變嗎?" - follow_up_max: 2 - stop_condition: "說明值得付出的理由。" - risk_level: low - optional: false - - id: soul_02 - dimension: SOUL - stage: depth - text: "你什麼時候會覺得「這件事我沒辦法妥協」?" - purpose: "萃取非協商性原則。" - user_explain: "這能讓 VirtualMe 知道哪些地方不能替你退讓。" - expected_anchor: principle - acceptable_answers: [boundary, reason] - follow_ups: - - "上次發生是什麼情境?" - - "如果對方繼續施壓,你會怎麼做?" - follow_up_max: 2 - stop_condition: "有具體情境與理由。" - risk_level: medium - optional: false - - id: soul_03 - dimension: SOUL - stage: depth - text: "你怎麼判斷一件事值得你花時間?" - purpose: "萃取資源分配底層邏輯。" - user_explain: "這會影響 VirtualMe 幫你排序事情時的判準。" - expected_anchor: principle - acceptable_answers: [criteria, example] - follow_ups: - - "最近一次用這個標準做判斷是什麼時候?" - follow_up_max: 1 - stop_condition: "給出可重複判斷框架。" - risk_level: low - optional: false - - id: soul_04 - dimension: SOUL - stage: depth - text: "有沒有某個信念,你以前很相信,但現在已經放棄或修正了?" - purpose: "萃取信念演化與可塑性。" - user_explain: "這能讓 VirtualMe 不只記住你相信什麼,也知道你怎麼改變想法。" - expected_anchor: pattern - acceptable_answers: [belief_change, trigger] - follow_ups: - - "是什麼讓你改變?" - - "改變後你做事有什麼不同?" - follow_up_max: 2 - stop_condition: "說出具體轉變原因。" - risk_level: medium - optional: false - - id: soul_05 - dimension: SOUL - stage: depth - text: "什麼樣的人或事會讓你打從心底佩服?" - purpose: "用投射方式萃取隱性價值觀。" - user_explain: "你佩服什麼,通常也透露你重視什麼。" - expected_anchor: pattern - acceptable_answers: [admiration, traits] - follow_ups: - - "你自己在哪些地方也有這個特質?" - follow_up_max: 1 - stop_condition: "出現兩個以上特質。" - risk_level: low - optional: false - - id: soul_06 - dimension: SOUL - stage: depth - text: "如果有人說你做事太堅持或太死板,你會怎麼回應?" - purpose: "萃取自我認知、價值防衛與例外條件。" - user_explain: "這題是想看你怎麼看待自己的堅持。" - expected_anchor: pattern - acceptable_answers: [reaction, self_view] - follow_ups: - - "你覺得這評價公平嗎?" - follow_up_max: 1 - stop_condition: "有真實回應或自我評估。" - risk_level: medium - optional: true - - id: soul_07 - dimension: SOUL - stage: depth - text: "最近有什麼事讓你覺得「這才是我想要的人生」?" - purpose: "萃取滿足感來源與人生方向。" - user_explain: "我想知道什麼狀態最像你想活成的樣子。" - expected_anchor: principle - acceptable_answers: [story, value] - follow_ups: - - "它滿足的是你哪一個需求?" - follow_up_max: 1 - stop_condition: "有具體事件與價值。" - risk_level: low - optional: false - - id: soul_08 - dimension: SOUL - stage: depth - text: "如果只能留一句話給後輩,你會想留什麼?" - purpose: "萃取長期價值優先排序。" - user_explain: "這能濃縮你覺得最值得傳下去的判準。" - expected_anchor: principle - acceptable_answers: [message, reason] - follow_ups: - - "為什麼是這句,不是別的?" - follow_up_max: 1 - stop_condition: "有一句話與理由。" - risk_level: low - optional: false - - - id: people_01 - dimension: PEOPLE - stage: relationship - text: "你通常怎麼判斷一個人可不可以信任?有沒有什麼小細節?" - purpose: "萃取信任模型與人際判斷準則。" - user_explain: "這會讓 VirtualMe 知道你怎麼看人、怎麼決定能不能合作。" - expected_anchor: principle - acceptable_answers: [criteria, example] - follow_ups: - - "有沒有哪次你因此看準或看錯?" - - "哪個訊號最重要?" - follow_up_max: 2 - stop_condition: "有明確信任指標。" - risk_level: medium - optional: false - - id: people_02 - dimension: PEOPLE - stage: relationship - text: "工作上你最欣賞的合作夥伴,通常有什麼特質?" - purpose: "萃取理想協作模式。" - user_explain: "你欣賞的合作方式,通常也是你願意長期投入的關係模式。" - expected_anchor: pattern - acceptable_answers: [traits, example] - follow_ups: - - "這些特質裡,哪一個最不能少?" - follow_up_max: 1 - stop_condition: "說出兩個以上特質。" - risk_level: low - optional: false - - id: people_03 - dimension: PEOPLE - stage: relationship - text: "如果你跟某人合作不順,你通常怎麼處理?" - purpose: "萃取衝突管理模式。" - user_explain: "這能讓 VirtualMe 知道你面對摩擦時會直接處理、調整方式,還是退出。" - expected_anchor: pattern - acceptable_answers: [conflict, action] - follow_ups: - - "有沒有一次你選擇不處理,讓它過去?" - follow_up_max: 1 - stop_condition: "描述行動策略。" - risk_level: medium - optional: false - - id: people_04 - dimension: PEOPLE - stage: relationship - text: "誰對你的人生或工作影響最大?他是怎麼影響你的?" - purpose: "萃取影響圈與學習來源。" - user_explain: "影響你的人,會幫我理解你後來怎麼判斷與合作。" - expected_anchor: fact - acceptable_answers: [person, influence] - follow_ups: - - "如果他看到現在的你,他可能會怎麼說?" - follow_up_max: 1 - stop_condition: "說出影響機制。" - risk_level: low - optional: false - - id: people_05 - dimension: PEOPLE - stage: relationship - text: "你在什麼情況下會對別人關上門,不太想再合作或靠近?" - purpose: "萃取關係邊界 trigger。" - user_explain: "這題是想知道人際上哪些事會讓你踩煞車。" - expected_anchor: principle - acceptable_answers: [trigger, boundary] - follow_ups: - - "這種情況發生後,還有機會打開嗎?" - follow_up_max: 1 - stop_condition: "有具體觸發條件。" - risk_level: high - optional: true - - id: people_06 - dimension: PEOPLE - stage: relationship - text: "你通常主動聯繫什麼樣的人?什麼樣的人你會放著等對方來?" - purpose: "萃取主動性與關係優先排序。" - user_explain: "這能看出你怎麼分配人際注意力。" - expected_anchor: pattern - acceptable_answers: [relationship_pattern, priority] - follow_ups: - - "最近最常主動聯繫的是誰?為什麼?" - follow_up_max: 1 - stop_condition: "說出主動/被動差異。" - risk_level: low - optional: false - - id: people_07 - dimension: PEOPLE - stage: relationship - text: "你比較喜歡跟很像你的人合作,還是跟互補的人合作?" - purpose: "萃取合作偏好與互補容忍度。" - user_explain: "這會影響 VirtualMe 幫你判斷合作對象時的偏好。" - expected_anchor: pattern - acceptable_answers: [preference, condition] - follow_ups: - - "什麼情況下你會改選另一種人?" - follow_up_max: 1 - stop_condition: "有偏好與例外條件。" - risk_level: low - optional: false - - id: people_08 - dimension: PEOPLE - stage: relationship - text: "有沒有一段關係,讓你學會了以後不要怎麼跟人合作?" - purpose: "萃取負面經驗形成的合作原則。" - user_explain: "如果你願意說,這能幫我避開 VirtualMe 未來踩到同樣雷點。" - expected_anchor: principle - acceptable_answers: [lesson, boundary] - follow_ups: - - "那次之後你多了哪一條判斷規則?" - follow_up_max: 1 - stop_condition: "有學到的合作原則;使用者不願說即跳過。" - risk_level: high - optional: true - - - id: skill_01 - dimension: SKILL - stage: work_method - text: "你在目前工作中做得最順手的是什麼?可以說一次具體過程嗎?" - purpose: "萃取核心工作 SOP。" - user_explain: "我想知道你實際怎麼做,而不是只知道你會什麼。" - expected_anchor: pattern - acceptable_answers: [process, example] - follow_ups: - - "為什麼你會選這個方法,不選別的方法?" - follow_up_max: 1 - stop_condition: "描述完整流程。" - risk_level: low - optional: false - - id: skill_02 - dimension: SKILL - stage: work_method - text: "當你面對一個完全陌生的問題,你第一步通常做什麼?" - purpose: "萃取學習與問題解決起點。" - user_explain: "這能讓 VirtualMe 遇到新問題時像你一樣起手。" - expected_anchor: pattern - acceptable_answers: [first_step, example] - follow_ups: - - "上次這樣做的是什麼問題?" - follow_up_max: 1 - stop_condition: "描述行動步驟。" - risk_level: low - optional: false - - id: skill_03 - dimension: SKILL - stage: work_method - text: "你做決定時,有沒有固定會問自己的問題或過濾機制?" - purpose: "萃取決策框架。" - user_explain: "這是讓 VirtualMe 做選擇時不亂猜的核心。" - expected_anchor: principle - acceptable_answers: [decision_filter, example] - follow_ups: - - "最近一次用這個框架做了什麼決定?" - follow_up_max: 1 - stop_condition: "出現一個以上決策問題或框架。" - risk_level: low - optional: false - - id: skill_04 - dimension: SKILL - stage: work_method - text: "你做過最複雜的一個工作任務是什麼?你怎麼拆解它?" - purpose: "萃取複雜任務分解邏輯。" - user_explain: "這題可以幫我理解你處理複雜問題的步驟。" - expected_anchor: pattern - acceptable_answers: [complex_task, decomposition] - follow_ups: - - "哪一步最難?當時怎麼解決?" - follow_up_max: 1 - stop_condition: "出現分解步驟。" - risk_level: medium - optional: false - - id: skill_05 - dimension: SKILL - stage: work_method - text: "你大概怎麼衡量一個工作成果夠好了?" - purpose: "萃取品質判斷標準。" - user_explain: "這會影響 VirtualMe 什麼時候該停、什麼時候該繼續打磨。" - expected_anchor: principle - acceptable_answers: [quality_bar, example] - follow_ups: - - "有沒有交出去後覺得其實沒做好的例子?" - follow_up_max: 1 - stop_condition: "給出可操作標準。" - risk_level: low - optional: false - - id: skill_06 - dimension: SKILL - stage: work_method - text: "你工作上最依賴的工具、系統或流程是什麼?沒有它你會怎樣?" - purpose: "萃取工具偏好與外部記憶方式。" - user_explain: "工具通常透露你怎麼組織事情。" - expected_anchor: fact - acceptable_answers: [tool, reason] - follow_ups: - - "是什麼讓你開始用它?" - follow_up_max: 1 - stop_condition: "說出工具與原因。" - risk_level: low - optional: false - - id: skill_07 - dimension: SKILL - stage: work_method - text: "你有沒有搞砸過某件事,但從那次學到很重要的教訓?" - purpose: "萃取失敗後形成的隱性知識。" - user_explain: "不是要檢討,是想知道你怎麼把錯誤變成方法。" - expected_anchor: principle - acceptable_answers: [failure, lesson] - follow_ups: - - "那個教訓現在還在影響你嗎?" - follow_up_max: 1 - stop_condition: "說出具體教訓;不願說即跳過。" - risk_level: high - optional: true - - id: skill_08 - dimension: SKILL - stage: work_method - text: "你協作時比較常主導還是配合?什麼情況會換角色?" - purpose: "萃取協作角色與彈性。" - user_explain: "這能讓 VirtualMe 知道你在團隊裡通常怎麼站位。" - expected_anchor: pattern - acceptable_answers: [role_preference, condition] - follow_ups: - - "最近一次你換角色是什麼情況?" - follow_up_max: 1 - stop_condition: "有角色偏好與情境條件。" - risk_level: low - optional: false - - - id: journal_01 - dimension: JOURNAL - stage: reflection - text: "你通常怎麼想清楚一件事?是寫下來、說出來、走路想,還是睡一覺?" - purpose: "萃取思考模態。" - user_explain: "這能讓 VirtualMe 知道你怎麼整理腦中的東西。" - expected_anchor: pattern - acceptable_answers: [reflection_method, example] - follow_ups: - - "最近一次這樣做是在想什麼?" - follow_up_max: 1 - stop_condition: "說出至少一個具體方法。" - risk_level: low - optional: false - - id: journal_02 - dimension: JOURNAL - stage: reflection - text: "當一件事沒有照你預期發展,你通常第一反應是什麼?" - purpose: "萃取失敗或落差的初始反應。" - user_explain: "這不是要評價情緒,而是想知道你遇到落差時怎麼開始處理。" - expected_anchor: pattern - acceptable_answers: [reaction, action] - follow_ups: - - "你會馬上行動,還是需要先消化?" - follow_up_max: 1 - stop_condition: "有情緒與行動描述。" - risk_level: medium - optional: false - - id: journal_03 - dimension: JOURNAL - stage: reflection - text: "你怎麼知道自己復盤完了?有沒有什麼結束訊號?" - purpose: "萃取反思完結機制。" - user_explain: "這能讓 VirtualMe 知道你什麼時候覺得一件事已經想清楚。" - expected_anchor: principle - acceptable_answers: [completion_signal, criteria] - follow_ups: - - "如果一直復盤不完,你會怎麼做?" - follow_up_max: 1 - stop_condition: "說出具體結束訊號。" - risk_level: low - optional: false - - id: journal_04 - dimension: JOURNAL - stage: reflection - text: "你有在記錄什麼嗎?例如筆記、日記、語音備忘或任務紀錄。" - purpose: "萃取外化反思工具。" - user_explain: "記錄方式會影響 VirtualMe 怎麼保存和整理你的想法。" - expected_anchor: fact - acceptable_answers: [tool, habit] - follow_ups: - - "你之後還會回去看嗎?" - follow_up_max: 1 - stop_condition: "說明記錄方式。" - risk_level: low - optional: false - - id: journal_05 - dimension: JOURNAL - stage: reflection - text: "最近有沒有哪件事,你事後回想覺得下次可以改?" - purpose: "萃取自我修正模式。" - user_explain: "這會讓 VirtualMe 更像一個會修正的你,而不是只記住靜態偏好。" - expected_anchor: pattern - acceptable_answers: [review, adjustment] - follow_ups: - - "下次你會具體怎麼改?" - follow_up_max: 1 - stop_condition: "有反思與調整。" - risk_level: low - optional: false - - id: journal_06 - dimension: JOURNAL - stage: reflection - text: "當你描述自己是什麼樣的人,你最常用哪幾個詞?" - purpose: "萃取自我敘事標籤,再用行為校準。" - user_explain: "我會把這些詞當線索,不會直接當定論。" - expected_anchor: fact - acceptable_answers: [self_label, caveat] - follow_ups: - - "哪一個詞其實更真實,但你比較少說出來?" - follow_up_max: 1 - stop_condition: "出現兩個以上標籤或使用者不想標籤自己。" - risk_level: medium - optional: true - - id: journal_07 - dimension: JOURNAL - stage: reflection - text: "最近有沒有一個你一直在想、但還沒有答案的問題?" - purpose: "萃取當前探索邊界與認知負荷。" - user_explain: "這能讓 VirtualMe 知道你現在腦中還有哪些未完成的思考。" - expected_anchor: fact - acceptable_answers: [open_question] - follow_ups: - - "你目前比較偏向哪個答案?" - follow_up_max: 1 - stop_condition: "描述問題本身。" - risk_level: low - optional: false - - id: journal_08 - dimension: JOURNAL - stage: reflection - text: "你比較常把自己看成學習者、解題者、照顧者、推動者,還是別的角色?" - purpose: "萃取自我敘事框架。" - user_explain: "這不是人格測驗,只是想抓你怎麼理解自己的角色。" - expected_anchor: pattern - acceptable_answers: [self_frame, reason] - follow_ups: - - "這個角色是你喜歡的,還是你習慣承擔的?" - follow_up_max: 1 - stop_condition: "有角色框架與理由。" - risk_level: medium - optional: true - - - id: boundaries_01 - dimension: BOUNDARIES - stage: limits - text: "你上次說不,是什麼時候?你是怎麼說的?" - purpose: "萃取拒絕語氣與時機。" - user_explain: "這能讓 VirtualMe 知道什麼時候該替你拒絕,以及怎麼拒絕比較像你。" - expected_anchor: fact - acceptable_answers: [refusal, wording] - follow_ups: - - "對方反應如何?你後來有後悔嗎?" - follow_up_max: 1 - stop_condition: "說出拒絕方式。" - risk_level: low - optional: false - - id: boundaries_02 - dimension: BOUNDARIES - stage: limits - text: "有沒有什麼要求,不管是誰提的,你都不會答應?" - purpose: "萃取絕對邊界。" - user_explain: "這題是為了避免 VirtualMe 未來替你答應不該答應的事。" - expected_anchor: principle - acceptable_answers: [hard_limit, reason] - follow_ups: - - "如果是你很在乎的人提呢?" - follow_up_max: 1 - stop_condition: "有明確條件。" - risk_level: medium - optional: false - - id: boundaries_03 - dimension: BOUNDARIES - stage: limits - text: "如果有人越過你的界線,你一般怎麼反應?" - purpose: "萃取邊界被侵犯時的行動模式。" - user_explain: "我想知道你會直接說、提醒一次,還是默默退出。" - expected_anchor: pattern - acceptable_answers: [boundary_response] - follow_ups: - - "你會直接說,還是先觀察?" - follow_up_max: 1 - stop_condition: "描述行動模式。" - risk_level: medium - optional: false - - id: boundaries_04 - dimension: BOUNDARIES - stage: limits - text: "在工作上,你覺得什麼樣的要求算是超過份際?" - purpose: "萃取工作邊界與授權範圍。" - user_explain: "這會讓 VirtualMe 知道哪些工作要求可以接,哪些要擋。" - expected_anchor: principle - acceptable_answers: [work_boundary, example] - follow_ups: - - "你遇過嗎?當時怎麼處理?" - follow_up_max: 1 - stop_condition: "給出具體類型與處理方式。" - risk_level: low - optional: false - - id: boundaries_05 - dimension: BOUNDARIES - stage: limits - text: "你有沒有因為說了不該說的話而後悔?那次你為什麼說出來?" - purpose: "萃取自我揭露邊界。" - user_explain: "如果你願意說,這能幫我理解你在什麼情況下會越過自己的說話界線。" - expected_anchor: fact - acceptable_answers: [regret, disclosure] - follow_ups: - - "現在遇到類似情況,你會怎麼做?" - follow_up_max: 1 - stop_condition: "說出事件與原因;不願說即跳過。" - risk_level: high - optional: true - - id: boundaries_06 - dimension: BOUNDARIES - stage: limits - text: "什麼事情你可以授權別人替你決定?什麼事情一定要自己來?" - purpose: "萃取 delegation boundary。" - user_explain: "這對 VirtualMe 很重要,因為它需要知道哪些能代答、哪些不能代你決定。" - expected_anchor: principle - acceptable_answers: [delegation, limit] - follow_ups: - - "這條線是怎麼形成的?" - follow_up_max: 1 - stop_condition: "有可授權/不可授權清單。" - risk_level: low - optional: false - - id: boundaries_07 - dimension: BOUNDARIES - stage: limits - text: "如果有人一直催你、逼你快點做決定,你通常怎麼反應?" - purpose: "萃取壓力下的界線維持方式。" - user_explain: "這能讓 VirtualMe 知道你被推著走時,會怎麼守住節奏。" - expected_anchor: pattern - acceptable_answers: [pressure_response] - follow_ups: - - "什麼情況下你會讓步?" - follow_up_max: 1 - stop_condition: "有壓力回應與例外條件。" - risk_level: medium - optional: false - - id: boundaries_08 - dimension: BOUNDARIES - stage: limits - text: "有沒有什麼話,不管誰說了,你都很難忘記或原諒?" - purpose: "萃取極高敏感觸發語。" - user_explain: "這題比較敏感,可以跳過。它只是幫我知道哪些話 VirtualMe 永遠不要說。" - expected_anchor: fact - acceptable_answers: [trigger_phrase] - follow_ups: [] - follow_up_max: 0 - stop_condition: "只要提到主題即可,不強求細節。" - risk_level: high - optional: true - - - id: voice_01 - dimension: VOICE - stage: voice_sample - text: "你平常跟朋友傳訊息,跟跟老闆或客戶說話,語氣差很多嗎?怎麼個不同法?" - purpose: "萃取語境切換模式。" - user_explain: "這能讓 VirtualMe 在不同對象面前切換成像你的語氣。" - expected_anchor: pattern - acceptable_answers: [style_difference, example] - follow_ups: - - "能舉一個你最近會傳的訊息例子嗎?" - follow_up_max: 1 - stop_condition: "說出至少一個語氣差異。" - risk_level: low - optional: false - - id: voice_02 - dimension: VOICE - stage: voice_sample - text: "你有沒有覺得只有你會這樣說的口頭禪、慣用詞或習慣說法?" - purpose: "萃取語言特徵詞。" - user_explain: "這些小詞會讓 VirtualMe 的回覆更像你。" - expected_anchor: fact - acceptable_answers: [phrase, origin] - follow_ups: - - "這個說法是怎麼來的?" - follow_up_max: 1 - stop_condition: "出現一個以上具體詞語。" - risk_level: low - optional: false - - id: voice_03 - dimension: VOICE - stage: voice_sample - text: "當你不同意某件事,你一般怎麼說出來?可以舉最近的例子嗎?" - purpose: "萃取異議表達模式。" - user_explain: "這能讓 VirtualMe 不只知道你不同意,也知道你會怎麼說。" - expected_anchor: pattern - acceptable_answers: [disagreement, wording] - follow_ups: - - "對方反應後,你通常會怎麼接?" - follow_up_max: 1 - stop_condition: "有具體場景或句子。" - risk_level: medium - optional: false - - id: voice_04 - dimension: VOICE - stage: voice_sample - text: "你比較喜歡直接說結論,還是先鋪陳再說?什麼情況會換另一種?" - purpose: "萃取資訊架構偏好。" - user_explain: "這會影響 VirtualMe 回覆時先講答案還是先講脈絡。" - expected_anchor: pattern - acceptable_answers: [communication_structure, exception] - follow_ups: - - "最近一次你改用另一種方式,是為什麼?" - follow_up_max: 1 - stop_condition: "有偏好與場景依據。" - risk_level: low - optional: false - - id: voice_05 - dimension: VOICE - stage: voice_sample - text: "有人誤解你說的話,讓你覺得很無奈,那次是什麼情況?" - purpose: "萃取溝通誤差的高敏感區。" - user_explain: "這能讓 VirtualMe 避免用容易被誤解的方式替你說話。" - expected_anchor: fact - acceptable_answers: [misunderstanding, repair] - follow_ups: - - "後來你怎麼補救?" - follow_up_max: 1 - stop_condition: "描述具體誤解與應對;不願回憶即跳過。" - risk_level: high - optional: true - - id: voice_06 - dimension: VOICE - stage: voice_sample - text: "你打字時會用 emoji、貼圖或很多標點嗎?還是偏簡短乾淨?" - purpose: "萃取書寫風格特徵。" - user_explain: "這些格式習慣會直接影響 VirtualMe 看起來像不像你。" - expected_anchor: fact - acceptable_answers: [format_preference] - follow_ups: [] - follow_up_max: 0 - stop_condition: "有明確偏好。" - risk_level: low - optional: false - - id: voice_07 - dimension: VOICE - stage: voice_sample - text: "如果要跟一位剛認識但你尊重的前輩傳訊息,你實際會怎麼寫?" - purpose: "取得正式但自然的 first-contact voice sample。" - user_explain: "這是角色扮演題,直接收一段可用語氣樣本。" - expected_anchor: fact - acceptable_answers: [message_sample] - follow_ups: - - "這段裡你刻意避免了什麼語氣?" - follow_up_max: 1 - stop_condition: "取得一段訊息樣本。" - risk_level: low - optional: false - - id: voice_08 - dimension: VOICE - stage: voice_sample - text: "如果要跟熟人說一個對他不利的決定,你會怎麼講?直接示範給我看。" - purpose: "取得衝突或壞消息情境的 voice sample。" - user_explain: "這能讓 VirtualMe 在難講的情境裡,也保留你的語氣和分寸。" - expected_anchor: pattern - acceptable_answers: [message_sample, conflict_tone] - follow_ups: - - "你這樣說時,最想避免對方產生什麼感覺?" - follow_up_max: 1 - stop_condition: "取得示範句與意圖。" - risk_level: medium - optional: false diff --git a/src/virtualme/export/auto.py b/src/virtualme/export/auto.py index d1b32c7..d552c89 100644 --- a/src/virtualme/export/auto.py +++ b/src/virtualme/export/auto.py @@ -30,7 +30,12 @@ _GIT_AUTHOR = ["-c", "user.name=VirtualMe", "-c", "user.email=virtualme@localhost"] -async def auto_export_persona(db: DB, interviewee_id: str, export_dir: str) -> list[Path]: +async def auto_export_persona( + db: DB, + interviewee_id: str, + export_dir: str, + extra_files: dict[str, str] | None = None, +) -> list[Path]: """Export the persona archive and commit it to a local-only git repo. The markdown export always runs. Git versioning is best-effort: a git @@ -41,6 +46,14 @@ async def auto_export_persona(db: DB, interviewee_id: str, export_dir: str) -> l base = Path(export_dir) base.mkdir(parents=True, exist_ok=True) written = await export_markdown(db, interviewee_id, base) + if extra_files: + target = base / interviewee_id + for name, content in extra_files.items(): + if "/" in name or "\\" in name or ".." in name: + raise ValueError("Invalid extra persona export filename") + path = target / name + path.write_text(content, encoding="utf-8") + written.append(path) await _commit_archive(base, interviewee_id) return written diff --git a/src/virtualme/export/download_tokens.py b/src/virtualme/export/download_tokens.py new file mode 100644 index 0000000..6f782f0 --- /dev/null +++ b/src/virtualme/export/download_tokens.py @@ -0,0 +1,256 @@ +from __future__ import annotations + +import hashlib +import secrets +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from pathlib import Path + +from virtualme.storage.db import DB + + +class DownloadTokenError(Exception): + """Base error for persona download token failures.""" + + +class DownloadTokenNotFound(DownloadTokenError): + """Token does not exist.""" + + +class DownloadTokenExpired(DownloadTokenError): + """Token exists but expired.""" + + +class DownloadFileUnavailable(DownloadTokenError): + """Token is valid, but the zip path is unavailable or unsafe.""" + + +@dataclass(frozen=True) +class DownloadTokenRecord: + token_hash: str + interviewee_id: str + zip_path: Path + expires_at: datetime + + +def build_download_url(base_url: str, raw_token: str) -> str: + base = base_url.rstrip("/") + return f"{base}/download/persona/{raw_token}" + + +async def create_download_token( + db: DB, + interviewee_id: str, + zip_path: Path, + *, + expiry_minutes: int = 60, + now: datetime | None = None, +) -> str: + resolved_zip = zip_path.resolve() + if not resolved_zip.is_file(): + raise DownloadFileUnavailable(f"persona zip does not exist: {resolved_zip}") + + raw_token = secrets.token_urlsafe(32) + token_hash = hash_token(raw_token) + created_at = _utc_now(now) + expires_at = created_at + timedelta(minutes=expiry_minutes) + + await db.init() + async with db._connect() as conn: + await conn.execute( + """ + INSERT INTO persona_download_tokens( + token_hash, interviewee_id, zip_path, created_at, expires_at + ) + VALUES (?, ?, ?, ?, ?) + """, + ( + token_hash, + interviewee_id, + str(resolved_zip), + _format_time(created_at), + _format_time(expires_at), + ), + ) + await conn.commit() + return raw_token + + +async def resolve_download_token( + db: DB, + raw_token: str, + *, + persona_export_dir: str, + ip_address: str | None = None, + user_agent: str | None = None, + now: datetime | None = None, +) -> DownloadTokenRecord: + token_hash = hash_token(raw_token) + requested_at = _utc_now(now) + await db.init() + async with db._connect() as conn: + conn.row_factory = None + row = await ( + await conn.execute( + """ + SELECT token_hash, interviewee_id, zip_path, expires_at + FROM persona_download_tokens + WHERE token_hash = ? + """, + (token_hash,), + ) + ).fetchone() + if row is None: + await _insert_download_log( + conn, + token_hash=token_hash, + requested_at=requested_at, + ip_address=ip_address, + user_agent=user_agent, + success=False, + failure_reason="token_not_found", + ) + await conn.commit() + raise DownloadTokenNotFound("persona download token not found") + + row_token_hash, interviewee_id, zip_path, expires_at = row + parsed_expiry = _parse_time(expires_at) + if parsed_expiry <= requested_at: + await _insert_download_log( + conn, + token_hash=token_hash, + interviewee_id=interviewee_id, + requested_at=requested_at, + ip_address=ip_address, + user_agent=user_agent, + success=False, + failure_reason="token_expired", + zip_path=zip_path, + ) + await conn.commit() + raise DownloadTokenExpired("persona download token expired") + + resolved_zip = _safe_zip_path(Path(zip_path), Path(persona_export_dir)) + if resolved_zip is None or not resolved_zip.is_file(): + await _insert_download_log( + conn, + token_hash=token_hash, + interviewee_id=interviewee_id, + requested_at=requested_at, + ip_address=ip_address, + user_agent=user_agent, + success=False, + failure_reason="zip_unavailable", + zip_path=zip_path, + ) + await conn.commit() + raise DownloadFileUnavailable("persona zip unavailable") + + await conn.execute( + """ + UPDATE persona_download_tokens + SET download_count = download_count + 1, + last_downloaded_at = ? + WHERE token_hash = ? + """, + (_format_time(requested_at), token_hash), + ) + await _insert_download_log( + conn, + token_hash=token_hash, + interviewee_id=interviewee_id, + requested_at=requested_at, + ip_address=ip_address, + user_agent=user_agent, + success=True, + zip_path=str(resolved_zip), + ) + await conn.commit() + + return DownloadTokenRecord( + token_hash=row_token_hash, + interviewee_id=interviewee_id, + zip_path=resolved_zip, + expires_at=parsed_expiry, + ) + + +async def cleanup_expired_download_tokens( + db: DB, + *, + now: datetime | None = None, +) -> int: + cutoff = _format_time(_utc_now(now)) + await db.init() + async with db._connect() as conn: + cursor = await conn.execute( + "DELETE FROM persona_download_tokens WHERE expires_at < ?", + (cutoff,), + ) + await conn.commit() + return cursor.rowcount or 0 + + +def hash_token(raw_token: str) -> str: + return hashlib.sha256(raw_token.encode("utf-8")).hexdigest() + + +async def _insert_download_log( + conn, + *, + token_hash: str, + requested_at: datetime, + success: bool, + interviewee_id: str | None = None, + ip_address: str | None = None, + user_agent: str | None = None, + failure_reason: str | None = None, + zip_path: str | None = None, +) -> None: + await conn.execute( + """ + INSERT INTO persona_download_logs( + interviewee_id, token_hash, requested_at, ip_address, user_agent, + success, failure_reason, zip_path + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + interviewee_id, + token_hash, + _format_time(requested_at), + ip_address, + user_agent, + 1 if success else 0, + failure_reason, + zip_path, + ), + ) + + +def _safe_zip_path(zip_path: Path, export_dir: Path) -> Path | None: + resolved = zip_path.resolve() + base = export_dir.resolve() + try: + resolved.relative_to(base) + except ValueError: + return None + if resolved.suffix.lower() != ".zip": + return None + return resolved + + +def _utc_now(now: datetime | None) -> datetime: + if now is None: + return datetime.now(UTC) + return now.astimezone(UTC) if now.tzinfo else now.replace(tzinfo=UTC) + + +def _format_time(value: datetime) -> str: + return value.astimezone(UTC).isoformat(timespec="seconds") + + +def _parse_time(value: str) -> datetime: + normalized = value.replace("Z", "+00:00") + parsed = datetime.fromisoformat(normalized) + return parsed.astimezone(UTC) if parsed.tzinfo else parsed.replace(tzinfo=UTC) diff --git a/src/virtualme/export/persona_package.py b/src/virtualme/export/persona_package.py new file mode 100644 index 0000000..1c5868f --- /dev/null +++ b/src/virtualme/export/persona_package.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from zipfile import ZIP_DEFLATED, ZipFile +from zoneinfo import ZoneInfo + +from virtualme.export.auto import auto_export_persona +from virtualme.interview.progress_card import ( + calculate_weighted_completion, + render_coverage_summary, + weakest_dimension_labels, +) +from virtualme.interview.turn_state import CoverageSnapshot +from virtualme.storage.db import DB + +_TAIPEI = ZoneInfo("Asia/Taipei") +_EXPORT_NOTE = "本次匯出說明.txt" + + +@dataclass(frozen=True) +class PersonaExportPackage: + zip_path: Path + file_name: str + caption: str + written_files: list[Path] + + +async def build_persona_export_package( + db: DB, + interviewee_id: str, + export_dir: str, + snapshot: CoverageSnapshot, + now: datetime | None = None, +) -> PersonaExportPackage: + """Export persona markdown, add the user-facing note, commit, and zip files.""" + exported_at = now.astimezone(_TAIPEI) if now else datetime.now(_TAIPEI) + note = render_export_note(snapshot, exported_at) + written = await auto_export_persona( + db, + interviewee_id, + export_dir, + extra_files={_EXPORT_NOTE: note}, + ) + + file_name = f"VirtualMe_人格檔_{exported_at:%Y%m%d}.zip" + zip_dir = Path(export_dir) / "_packages" / interviewee_id + zip_dir.mkdir(parents=True, exist_ok=True) + zip_path = zip_dir / file_name + _write_zip(zip_path, written, Path(export_dir) / interviewee_id) + return PersonaExportPackage( + zip_path=zip_path, + file_name=file_name, + caption="你的人格檔 zip 已準備好", + written_files=written, + ) + + +def render_export_note(snapshot: CoverageSnapshot, exported_at: datetime) -> str: + completion = calculate_weighted_completion(snapshot) + weak = "、".join(weakest_dimension_labels(snapshot)) or "暫無" + return "\n".join( + [ + "本次匯出說明", + "", + f"匯出時間:{exported_at.isoformat(timespec='seconds')}", # noqa: RUF001 + f"目前加權完成度:約 {completion}%", # noqa: RUF001 + "", + "目前 8 維 × 3 層覆蓋情況:", # noqa: RUF001 + render_coverage_summary(snapshot), + "", + f"較弱維度提醒:{weak}", # noqa: RUF001 + "", + "這是階段性版本,隨著訪談繼續會越來越成熟。", # noqa: RUF001 + "", + ] + ) + + +def _write_zip(zip_path: Path, paths: list[Path], subject_dir: Path) -> None: + with ZipFile(zip_path, "w", compression=ZIP_DEFLATED) as archive: + for path in sorted(paths): + archive.write(path, arcname=path.relative_to(subject_dir)) diff --git a/src/virtualme/interview/bot.py b/src/virtualme/interview/bot.py index c305ee8..dce6801 100644 --- a/src/virtualme/interview/bot.py +++ b/src/virtualme/interview/bot.py @@ -1,11 +1,12 @@ import logging import unicodedata -from pathlib import Path from anthropic import AsyncAnthropic from virtualme.config import Settings from virtualme.export.auto import auto_export_persona +from virtualme.export.download_tokens import build_download_url, create_download_token +from virtualme.export.persona_package import build_persona_export_package from virtualme.interview import byok from virtualme.interview.anchor_extractor import extract_anchors from virtualme.interview.commands import ( @@ -28,14 +29,20 @@ from virtualme.interview.lang import INTERVIEW_OUTPUT_LANGUAGE from virtualme.interview.models import MODEL_DEEP, create_message from virtualme.interview.pii import scrub_pii +from virtualme.interview.progress_card import ( + calculate_weighted_completion, + render_8x3_progress_card, +) from virtualme.interview.question_selector import QuestionSelector from virtualme.interview.session_lifecycle import ( finalize_session_if_closing, is_persona_sufficient, is_session_closing, ) -from virtualme.snapshot.core import export_snapshot -from virtualme.storage.db import DB, Dimension, Question, Session +from virtualme.interview.turn_reasoner import TurnReasoner, load_system_prompt +from virtualme.interview.turn_reasoner_schema import BoundaryStatus, NextMove +from virtualme.interview.turn_state import TurnState, build_turn_state, compute_coverage_snapshot +from virtualme.storage.db import DB, Dimension, Layer, Question, Session from virtualme.subject import score_completeness logger = logging.getLogger(__name__) @@ -48,6 +55,11 @@ energy_tax="low", ) MAX_PROBES_PER_QUESTION = 2 +_LAYER_ORDER = { + Layer.FACT: 0, + Layer.PATTERN: 1, + Layer.PRINCIPLE: 2, +} INTERVIEW_ERROR_REPLY = ( "抱歉,我這邊剛才出了點狀況,麻煩你再說一次。" # noqa: RUF001 @@ -55,6 +67,66 @@ ) +class PersonaExportLineReply(str): + """Text reply plus local zip artifact metadata for LINE transport.""" + + zip_path: str + file_name: str + caption: str + + def __new__(cls, text: str, *, zip_path: str, file_name: str, caption: str): + obj = str.__new__(cls, text) + obj.zip_path = zip_path + obj.file_name = file_name + obj.caption = caption + return obj + + +def _reasoner_output_should_extract(reasoner_output: object) -> bool: + """Return whether the user answer should become evidence in the new reasoner path.""" + if getattr(reasoner_output, "boundary_status", None) == BoundaryStatus.EXPLICIT_REFUSAL: + return False + return getattr(reasoner_output, "next_move", None) not in ( + NextMove.ADDRESS_META, + NextMove.HONOR_SKIP, + ) + + +def _deeper_layer(current: Layer | None, candidate: Layer) -> Layer: + if current is None: + return candidate + return candidate if _LAYER_ORDER[candidate] > _LAYER_ORDER[current] else current + + +def _fallback_next_reasoner_question(state: TurnState) -> Question | None: + """Pick a deterministic next question when the model says advance without an id. + + This keeps the collection strategy from depending entirely on the model's + willingness to choose a target. Prefer the weakest shallow dimension that + still has an available candidate, then fall back to the next non-current + candidate in pool order. + """ + candidates = [q for q in state.candidate_questions if q.id != state.current_question.id] + if not candidates: + return None + + candidates_by_dimension: dict[Dimension, list[Question]] = {} + for question in candidates: + candidates_by_dimension.setdefault(question.dimension, []).append(question) + + weak_dimensions: list[tuple[float, Dimension]] = [] + for dimension, progress in state.coverage_snapshot.per_dimension.items(): + if dimension not in candidates_by_dimension: + continue + shallow = progress.layers.get(Layer.FACT) + weak_dimensions.append((shallow.quality_score if shallow else 0.0, dimension)) + weak_dimensions.sort(key=lambda item: item[0]) + + if weak_dimensions: + return candidates_by_dimension[weak_dimensions[0][1]][0] + return candidates[0] + + async def process_turn( interviewee_id: str, incoming_message: str, @@ -63,6 +135,7 @@ async def process_turn( selector: QuestionSelector, settings: Settings | None = None, override_week: int | None = None, + download_base_url: str | None = None, ) -> str: settings = settings or Settings() active_client = claude @@ -101,6 +174,23 @@ async def process_turn( else await db.get_current_week(interviewee_id, max_week) ) session = await db.get_or_create_session(interviewee_id, week=week) + + # === Progress (user-triggered) — now using real CoverageSnapshot + progress_keywords = ["進度", "目前進度", "訪談進度", "收集進度", "請問現在的訪談進度"] + if any(kw in incoming_message for kw in progress_keywords): + logger.info("[PROGRESS] User requested real progress: %s", interviewee_id) + + # Build real state (this will compute the actual coverage_snapshot from DB) + turn_state = await build_turn_state( + interviewee_id=interviewee_id, + db=db, + selector=selector, + session=session, + adaptive=settings.adaptive_extraction, + ) + + return render_8x3_progress_card(turn_state.coverage_snapshot) + command = pre_gate_command if is_session_closing(incoming_message): return await _close_session( @@ -124,6 +214,7 @@ async def process_turn( db, selector, settings, + download_base_url, ) turn_count = await db.count_turns(session.id) if _is_light_greeting(incoming_message): @@ -136,6 +227,106 @@ async def process_turn( selector, ) + # === L2 TurnReasoner (whitelist-only) === + # Only designated test users go through the reasoning engine. + # The engine now actually respects next_move / next_question_id / echo (basic version). + if getattr(settings, "reasoning_turn_enabled", False): + test_ids_raw = getattr(settings, "reasoning_test_user_ids", "") or "" + allowed = {x.strip() for x in test_ids_raw.split(",") if x.strip()} + if interviewee_id in allowed: + # Record the user message first so TurnState has up-to-date history + scrub_result = scrub_pii(incoming_message) + if scrub_result.redactions: + logger.info( + "PII redacted: %s items in turn from %s", + len(scrub_result.redactions), + interviewee_id, + ) + user_turn = await db.save_turn(session.id, "user", scrub_result.scrubbed_text) + await db.save_redactions(user_turn.id, scrub_result.redactions) + + turn_state = await build_turn_state( + interviewee_id=interviewee_id, + db=db, + selector=selector, + session=session, + adaptive=settings.adaptive_extraction, + ) + + reasoner_model = getattr(settings, "reasoner_model_name", None) + reasoner_prompt = load_system_prompt(getattr(settings, "reasoner_prompt_file", None)) + reasoner = ( + TurnReasoner(active_client, model=reasoner_model, system_prompt=reasoner_prompt) + if reasoner_model + else TurnReasoner(active_client, system_prompt=reasoner_prompt) + ) + reasoner_output = await reasoner.run(turn_state) + + # Rich decision log — this is what we will watch to debug ghost-wall + logger.info( + "[NEW REASONER] %s | move=%s boundary=%s eng=%s next_q=%s echo=%s has_reflection=%s", + interviewee_id, + reasoner_output.next_move, + reasoner_output.boundary_status, + reasoner_output.engagement_state, + reasoner_output.next_question_id, + reasoner_output.should_echo, + bool(reasoner_output.reflection_note), + ) + + reply_text = reasoner_output.reply + if reasoner_output.should_echo and reasoner_output.echo_content: + reply_text = f"{reasoner_output.echo_content}\n\n{reply_text}" + + should_extract = _reasoner_output_should_extract(reasoner_output) + if should_extract: + extracted_anchors = await extract_anchors( + user_turn, turn_state.current_question, active_client + ) + deepest_layer = None + for anchor in extracted_anchors: + deepest_layer = _deeper_layer(deepest_layer, anchor.layer) + await db.save_anchor( + interviewee_id, + anchor.dimension, + anchor.layer, + anchor.content, + anchor.source_turn_ids, + anchor.source_question_ids, + model=reasoner_model, + ) + if deepest_layer is not None: + await db.record_question_answered( + interviewee_id, + turn_state.current_question.id, + session.week, + deepest_layer.value, + ) + logger.info( + "[NEW REASONER] extracted %s anchors from %s", + len(extracted_anchors), + turn_state.current_question.id, + ) + + next_question_id = reasoner_output.next_question_id + if reasoner_output.next_move == NextMove.ADVANCE and not next_question_id: + next_question = _fallback_next_reasoner_question(turn_state) + next_question_id = next_question.id if next_question else None + + # Act on the reasoner's decision. + if reasoner_output.next_move == NextMove.ADVANCE and next_question_id: + await db.set_current_question_id(session.id, next_question_id) + await db.record_question_asked(interviewee_id, next_question_id, session.week) + logger.info("[NEW REASONER] advanced to question %s", next_question_id) + + if reasoner_output.next_move == NextMove.HONOR_SKIP or reasoner_output.boundary_status == BoundaryStatus.EXPLICIT_REFUSAL: + logger.info("[NEW REASONER] honored skip / explicit refusal") + + await db.save_turn(session.id, "assistant", reply_text) + + # Note: finalize / auto-export still skipped in this early path + return reply_text + scrub_result = scrub_pii(incoming_message) if scrub_result.redactions: logger.info( @@ -199,6 +390,7 @@ async def process_turn( if assessment.kind == TurnKind.SUFFICIENT: extracted_anchors = await extract_anchors(user_turn, current_question, active_client) for anchor in extracted_anchors: + model_name = getattr(settings, "reasoner_model_name", None) await db.save_anchor( interviewee_id, anchor.dimension, @@ -206,6 +398,7 @@ async def process_turn( anchor.content, anchor.source_turn_ids, anchor.source_question_ids, + model=model_name, ) probe_count = await db.get_probe_count(interviewee_id, current_question.id) @@ -558,13 +751,14 @@ async def _handle_command( db: DB, selector: QuestionSelector, settings: Settings, + download_base_url: str | None = None, ) -> str: """Reply to a meta-command. Saves the turn pair but runs no extraction.""" if isinstance(command, GenerateProfileRequest): scrub_result = scrub_pii(incoming_message) user_turn = await db.save_turn(session.id, "user", scrub_result.scrubbed_text) await db.save_redactions(user_turn.id, scrub_result.redactions) - reply = await _handle_generate_profile(interviewee_id, db, settings) + reply = await _handle_generate_profile(interviewee_id, db, settings, download_base_url) await db.save_turn(session.id, "assistant", reply) return reply @@ -611,54 +805,62 @@ async def _handle_generate_profile( interviewee_id: str, db: DB, settings: Settings, + download_base_url: str | None = None, ) -> str: - if not _line_snapshot_export_allowed(interviewee_id, settings): + if settings.byok_enabled and not byok.has_key(settings.byok_keys_dir, interviewee_id): return format_generate_profile_denied() + + anchors = await db.load_anchors_summary(interviewee_id) + if not is_persona_sufficient(0, 1, anchors): + return format_generate_profile_denied() + + snapshot = compute_coverage_snapshot(anchors) try: - paths = await export_snapshot(db, interviewee_id, Path(settings.snapshot_export_dir)) + progress_card = render_8x3_progress_card(snapshot) + completion = calculate_weighted_completion(snapshot) except Exception as exc: - logger.exception("Snapshot export failed for %s: %s", interviewee_id, exc) - return "行為模式檔草稿輸出失敗; 資料仍保留在訪談資料庫, 請稍後再試。" - behavior_profile = next((path for path in paths if path.name == "behavior-profile.md"), None) - if behavior_profile is None: - logger.error("Snapshot export for %s did not include behavior-profile.md", interviewee_id) - return "行為模式檔草稿輸出失敗; 資料仍保留在訪談資料庫, 請稍後再試。" - return _behavior_profile_for_line(behavior_profile.read_text(encoding="utf-8")) - - -def _behavior_profile_for_line(markdown: str) -> str: - lines = [] - for line in markdown.splitlines(): - heading_marks = len(line) - len(line.lstrip("#")) - if 1 <= heading_marks <= 6 and len(line) > heading_marks and line[heading_marks] == " ": - line = line[heading_marks + 1 :] - if line.startswith("> "): - line = line[2:] - elif line.startswith(">"): - line = line[1:] - if ( - len(line) >= 2 - and line.startswith("_") - and line.endswith("_") - and not line.startswith("__") - and not line.endswith("__") - ): - line = line[1:-1] - lines.append(line) - return "\n".join(lines) - + logger.exception("Progress card rendering failed for %s: %s", interviewee_id, exc) + progress_card = "" + completion = 0 -def _line_snapshot_export_allowed(interviewee_id: str, settings: Settings) -> bool: - if not settings.line_snapshot_export_enabled: - return False - allowed = { - item.strip() - for item in settings.line_snapshot_export_user_ids.split(",") - if item.strip() - } - if settings.owner_line_user_id: - allowed.add(settings.owner_line_user_id) - return interviewee_id in allowed + try: + package = await build_persona_export_package( + db, + interviewee_id, + settings.persona_export_dir, + snapshot, + ) + raw_token = await create_download_token( + db, + interviewee_id, + package.zip_path, + expiry_minutes=settings.persona_download_expiry_minutes, + ) + except Exception as exc: + logger.exception("Persona export zip failed for %s: %s", interviewee_id, exc) + return "人格檔 zip 產生失敗; 資料仍保留在訪談資料庫, 請稍後再試。" + + base_url = download_base_url or settings.persona_download_base_url or "" + download_url = build_download_url(base_url, raw_token) + text = "\n\n".join( + part + for part in [ + progress_card, + f"你目前訪談總完成度約 {completion}%。已為你產生目前版本的人格檔 zip。", + ( + "下載連結有效 60 分鐘。若下載失敗或檔案損毀,請在有效期間內重新點擊連結。" # noqa: RUF001 + "若超過時間,請重新輸入「請匯出人格檔」取得新連結。\n" # noqa: RUF001 + f"{download_url}" + ), + ] + if part + ) + return PersonaExportLineReply( + text, + zip_path=str(package.zip_path), + file_name=package.file_name, + caption=package.caption, + ) def _handle_revoke_key(interviewee_id: str, settings: Settings) -> str: diff --git a/src/virtualme/interview/commands.py b/src/virtualme/interview/commands.py index 977af76..329aa55 100644 --- a/src/virtualme/interview/commands.py +++ b/src/virtualme/interview/commands.py @@ -260,7 +260,7 @@ def format_restart_reply(archive_note: str, archived_counts: dict[str, int], fir def format_generate_profile_denied() -> str: - return "目前沒有開放 LINE 直接產生行為模式檔; 請由 Maki / operator 協助匯出。" + return "目前VM對你的認識還不夠,請多與VM再訪談一陣子喔" # noqa: RUF001 def format_revoke_key_reply(removed: bool) -> str: diff --git a/src/virtualme/interview/guardrail.py b/src/virtualme/interview/guardrail.py new file mode 100644 index 0000000..6404bd8 --- /dev/null +++ b/src/virtualme/interview/guardrail.py @@ -0,0 +1,74 @@ +from dataclasses import is_dataclass, replace + +from virtualme.interview.turn_reasoner_schema import ( + BoundaryStatus, + EngagementState, + NextMove, + TurnReasonerOutput, +) + + +def _replace_output(output: TurnReasonerOutput, **changes) -> TurnReasonerOutput: + if is_dataclass(output): + return replace(output, **changes) + + data = vars(output).copy() + data.update(changes) + return TurnReasonerOutput(**data) + + +class Guardrail: + """Conservative guardrail for boundary, fatigue, and probe-budget handling.""" + + def __init__(self, max_probes_per_question: int = 2): + self.max_probes_per_question = max_probes_per_question + + def apply( + self, + output: TurnReasonerOutput, + current_probe_count: int, + ) -> TurnReasonerOutput: + new_output = output + + # Explicit refusal has highest priority. + if output.boundary_status == BoundaryStatus.EXPLICIT_REFUSAL: + if output.next_move != NextMove.HONOR_SKIP: + new_output = _replace_output( + new_output, + next_move=NextMove.HONOR_SKIP, + next_question_id=None, + skip_stop_reason="refusal", + ) + return new_output + + if ( + output.boundary_status == BoundaryStatus.STRONG_RELUCTANCE + and output.next_move == NextMove.PROBE + ): + new_output = _replace_output( + new_output, + next_move=NextMove.SOFTEN, + skip_stop_reason="reluctance", + ) + + if ( + output.engagement_state in (EngagementState.FATIGUED, EngagementState.GUARDED) + and output.next_move == NextMove.PROBE + ): + new_output = _replace_output( + new_output, + next_move=NextMove.SOFTEN, + skip_stop_reason="fatigue", + ) + + if ( + current_probe_count >= self.max_probes_per_question + and output.next_move in (NextMove.PROBE, NextMove.SOFTEN) + ): + new_output = _replace_output( + new_output, + next_move=NextMove.ADVANCE, + skip_stop_reason="probe_cap_reached", + ) + + return new_output diff --git a/src/virtualme/interview/progress_card.py b/src/virtualme/interview/progress_card.py new file mode 100644 index 0000000..890e6fc --- /dev/null +++ b/src/virtualme/interview/progress_card.py @@ -0,0 +1,363 @@ +# ruff: noqa: RUF001,RUF002 +""" +Progress Card Renderer for LINE Flex Message + +Generates a visual 8-dimension × 3-layer progress card. +This is designed for L3, but the data shape is compatible with what L2 TurnReasoner will eventually receive. + +Usage: + snapshot = { + "VOICE": {"shallow": 0.9, "middle": 0.4, "deep": 0.0}, + ... + } + flex_message = build_progress_flex(snapshot, trigger="user_asked") +""" + +from virtualme.interview.turn_state import CoverageSnapshot +from virtualme.storage.db import Dimension, Layer + +DIMENSION_LABELS = { + "VOICE": "聲音 / 表達", + "BOUNDARIES": "界線 / 責任", + "SOUL": "靈魂 / 價值觀", + "SKILL": "專業技能", + "PEOPLE": "人際關係", + "HISTORY": "經歷 / 歷史", + "JOURNAL": "日誌 / 日常", + "STATE": "當下狀態 / 能量", +} + +LAYER_ORDER = ["shallow", "middle", "deep"] +LAYER_LABELS = { + "shallow": "淺層", + "middle": "中層", + "deep": "深層", +} + +# Color scheme (LINE friendly) +COLORS = { + "shallow": "#A5D6A7", # Light green + "middle": "#66BB6A", # Medium green + "deep": "#2E7D32", # Dark green + "none": "#E0E0E0", + "header": "#1A237E", + "text": "#212121", +} + + +def _layer_segment(status: str, layer: str, width: int = 3) -> dict: + """Create one small colored box representing a layer segment.""" + color = COLORS.get(status, COLORS["none"]) + return { + "type": "box", + "layout": "vertical", + "width": f"{width}px", + "height": "18px", + "backgroundColor": color, + "cornerRadius": "4px", + } + + +def _build_dimension_row(dim_key: str, progress: dict) -> dict: + """Build one row for a dimension with 3 layer segments.""" + label = DIMENSION_LABELS.get(dim_key, dim_key) + + segments = [] + for layer in LAYER_ORDER: + score = progress.get(layer, 0.0) + if score >= 0.8: + status = layer # use the layer color + elif score >= 0.4: + status = layer + else: + status = "none" + segments.append(_layer_segment(status, layer)) + + # Simple text summary + reached = "尚未開始" + if progress.get("deep", 0) > 0.5: + reached = "已跨深層" + elif progress.get("middle", 0) > 0.5: + reached = "已跨中層" + elif progress.get("shallow", 0) > 0.5: + reached = "已跨淺層" + + return { + "type": "box", + "layout": "horizontal", + "spacing": "sm", + "contents": [ + { + "type": "text", + "text": label, + "size": "sm", + "color": COLORS["text"], + "flex": 3, + }, + { + "type": "box", + "layout": "horizontal", + "spacing": "2px", + "contents": segments, + "flex": 4, + }, + { + "type": "text", + "text": reached, + "size": "xs", + "color": "#616161", + "flex": 3, + "align": "end", + }, + ], + } + + +def build_progress_flex( + snapshot: dict[str, dict], + trigger: str = "user_asked", +) -> dict: + """ + Generate LINE Flex Message (bubble) for interview progress. + + snapshot example: + { + "SKILL": {"shallow": 0.95, "middle": 0.65, "deep": 0.1}, + "VOICE": {"shallow": 0.8, "middle": 0.3, "deep": 0.0}, + ... + } + """ + contents = [] + + # Header + contents.append({ + "type": "text", + "text": "目前訪談收集進度", + "weight": "bold", + "size": "xl", + "color": COLORS["header"], + }) + + contents.append({ + "type": "text", + "text": "八維 × 三層(淺層 → 中層 → 深層)", + "size": "xs", + "color": "#757575", + "margin": "sm", + }) + + # Separator + contents.append({"type": "separator", "margin": "md"}) + + # Dimension rows + for dim in ["VOICE", "BOUNDARIES", "SOUL", "SKILL", "PEOPLE", "HISTORY", "JOURNAL", "STATE"]: + prog = snapshot.get(dim, {"shallow": 0, "middle": 0, "deep": 0}) + contents.append(_build_dimension_row(dim, prog)) + + # Footer note + contents.append({"type": "separator", "margin": "lg"}) + contents.append({ + "type": "text", + "text": "只有當回答貢獻有意義的證據時才會推進進度。\n想繼續哪一塊?可以直接告訴我。", + "size": "xs", + "color": "#616161", + "wrap": True, + }) + + bubble = { + "type": "bubble", + "size": "kilo", + "header": { + "type": "box", + "layout": "vertical", + "contents": [ + { + "type": "text", + "text": "VirtualMe 訪談進度", + "size": "sm", + "color": "#FFFFFF", + "weight": "bold", + } + ], + "backgroundColor": COLORS["header"], + "paddingAll": "12px", + }, + "body": { + "type": "box", + "layout": "vertical", + "spacing": "md", + "contents": contents, + }, + } + + return { + "type": "flex", + "altText": "目前訪談收集進度", + "contents": bubble, + } + + +# Convenience function for bot.py integration +def get_progress_flex_for_user( + interviewee_id: str, + trigger: str = "user_asked", +) -> dict: + """ + Returns the full Flex Message payload ready to be sent. + For now uses a realistic sample snapshot (will be replaced by real CoverageSnapshot later). + """ + # Realistic snapshot based on recent dogfood session (SKILL has the most progress) + snapshot = { + "VOICE": {"shallow": 0.6, "middle": 0.25, "deep": 0.0}, + "BOUNDARIES": {"shallow": 0.35, "middle": 0.05, "deep": 0.0}, + "SOUL": {"shallow": 0.45, "middle": 0.15, "deep": 0.0}, + "SKILL": {"shallow": 0.92, "middle": 0.68, "deep": 0.12}, # strongest + "PEOPLE": {"shallow": 0.55, "middle": 0.28, "deep": 0.0}, + "HISTORY": {"shallow": 0.78, "middle": 0.35, "deep": 0.0}, + "JOURNAL": {"shallow": 0.22, "middle": 0.0, "deep": 0.0}, + "STATE": {"shallow": 0.65, "middle": 0.18, "deep": 0.0}, + } + return build_progress_flex(snapshot, trigger=trigger) + + +def render_progress_text(snapshot: "CoverageSnapshot") -> str: + """ + Render a clean text progress report from a real CoverageSnapshot. + This is the version that shows actual data from the database. + """ + from virtualme.storage.db import Dimension, Layer + + DIM_LABEL = { + Dimension.VOICE: "聲音/表達", + Dimension.BOUNDARIES: "界線/責任", + Dimension.SOUL: "靈魂/價值", + Dimension.SKILL: "專業技能", + Dimension.PEOPLE: "人際關係", + Dimension.HISTORY: "經歷/歷史", + Dimension.JOURNAL: "日誌/日常", + Dimension.STATE: "當下狀態", + } + + LAYER_ORDER = [Layer.FACT, Layer.PATTERN, Layer.PRINCIPLE] + lines = ["【目前訪談收集進度(八維 × 三層)】\n"] + + for dim in [Dimension.VOICE, Dimension.BOUNDARIES, Dimension.SOUL, Dimension.SKILL, + Dimension.PEOPLE, Dimension.HISTORY, Dimension.JOURNAL, Dimension.STATE]: + + dprog = snapshot.per_dimension.get(dim) + if not dprog: + lines.append(f"{DIM_LABEL.get(dim, dim.value):8} 資料不足") + continue + + label = DIM_LABEL.get(dim, dim.value) + + parts = [] + for layer in LAYER_ORDER: + lp = dprog.layers.get(layer) + if lp and lp.quality_score >= 0.75: + parts.append("●●●") + elif lp and lp.quality_score >= 0.5: + parts.append("●●○") + elif lp and lp.quality_score >= 0.2: + parts.append("●○○") + else: + parts.append("○○○") + + lines.append(f"{label:8} 淺層:{parts[0]} 中層:{parts[1]} 深層:{parts[2]}") + + lines.append("\n(只有當回答貢獻有意義的證據時才會推進)") + lines.append("想繼續哪一塊或看更細的進度,告訴我即可。") + + return "\n".join(lines) + + +_DIMENSION_ORDER = [ + Dimension.VOICE, + Dimension.BOUNDARIES, + Dimension.SOUL, + Dimension.SKILL, + Dimension.PEOPLE, + Dimension.HISTORY, + Dimension.JOURNAL, + Dimension.STATE, +] +_DIMENSION_LABELS_TEXT = { + Dimension.VOICE: "聲音/表達", + Dimension.BOUNDARIES: "界線/責任", + Dimension.SOUL: "靈魂/價值", + Dimension.SKILL: "專業技能", + Dimension.PEOPLE: "人際關係", + Dimension.HISTORY: "經歷/歷史", + Dimension.JOURNAL: "日誌/日常", + Dimension.STATE: "當下狀態", +} +_LAYER_WEIGHTS = { + Layer.FACT: 0.15, + Layer.PATTERN: 0.35, + Layer.PRINCIPLE: 0.50, +} + + +def calculate_weighted_completion(snapshot: CoverageSnapshot) -> int: + """Weighted average completion: shallow 15%, middle 35%, deep 50%.""" + if not snapshot.per_dimension: + return 0 + total = 0.0 + for dimension in Dimension: + progress = snapshot.per_dimension.get(dimension) + if progress is None: + continue + total += sum( + (progress.layers.get(layer).quality_score if progress.layers.get(layer) else 0.0) + * weight + for layer, weight in _LAYER_WEIGHTS.items() + ) + return round((total / len(Dimension)) * 100) + + +def render_8x3_progress_card(snapshot: CoverageSnapshot) -> str: + """Render the user-facing 8 dimensions x 3 layers progress card.""" + lines = ["VirtualMe 訪談機器人 【目前訪談收集進度(八維 × 三層)】", ""] + for dimension in _DIMENSION_ORDER: + progress = snapshot.per_dimension.get(dimension) + parts = [] + for layer in [Layer.FACT, Layer.PATTERN, Layer.PRINCIPLE]: + layer_progress = progress.layers.get(layer) if progress else None + parts.append(_dots(layer_progress.quality_score if layer_progress else 0.0)) + lines.append( + f"{_DIMENSION_LABELS_TEXT[dimension]:8} " + f"淺層:{parts[0]} 中層:{parts[1]} 深層:{parts[2]}" + ) + return "\n".join(lines) + + +def render_coverage_summary(snapshot: CoverageSnapshot) -> str: + """Text version for export notes.""" + return render_8x3_progress_card(snapshot) + + +def weakest_dimension_labels(snapshot: CoverageSnapshot, limit: int = 3) -> list[str]: + ranked: list[tuple[float, Dimension]] = [] + for dimension in Dimension: + progress = snapshot.per_dimension.get(dimension) + if progress is None: + ranked.append((0.0, dimension)) + continue + score = sum( + (progress.layers.get(layer).quality_score if progress.layers.get(layer) else 0.0) + * weight + for layer, weight in _LAYER_WEIGHTS.items() + ) + ranked.append((score, dimension)) + ranked.sort(key=lambda item: item[0]) + return [_DIMENSION_LABELS_TEXT[dimension] for _, dimension in ranked[:limit]] + + +def _dots(score: float) -> str: + if score >= 0.75: + return "●●●" + if score >= 0.5: + return "●●○" + if score >= 0.2: + return "●○○" + return "○○○" diff --git a/src/virtualme/interview/turn_reasoner.py b/src/virtualme/interview/turn_reasoner.py new file mode 100644 index 0000000..40feab7 --- /dev/null +++ b/src/virtualme/interview/turn_reasoner.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from enum import StrEnum +from pathlib import Path + +from anthropic import AsyncAnthropic + +from virtualme.interview.guardrail import Guardrail +from virtualme.interview.json_utils import extract_json_payload +from virtualme.interview.models import MODEL_FAST, create_message +from virtualme.interview.turn_state import TurnState +from virtualme.storage.db import Layer + +# ruff: noqa: RUF001 + + +class BoundaryStatus(StrEnum): + NONE = "none" + EXPLICIT_REFUSAL = "explicit_refusal" + STRONG_RELUCTANCE = "strong_reluctance" + + +class EngagementState(StrEnum): + ENGAGED = "engaged" + FATIGUED = "fatigued" + DRIFTING = "drifting" + GUARDED = "guarded" + DISTRUSTFUL = "distrustful" + + +class NextMove(StrEnum): + ADVANCE = "advance" + PROBE = "probe" + HONOR_SKIP = "honor_skip" + ADDRESS_META = "address_meta" + SOFTEN = "soften" + + +class SkipStopReason(StrEnum): + NONE = "none" + REFUSAL = "refusal" + RELUCTANCE = "reluctance" + FATIGUE = "fatigue" + PROBE_CAP_REACHED = "probe_cap_reached" + + +@dataclass +class TurnReasonerOutput: + read: str + boundary_status: BoundaryStatus + engagement_state: EngagementState + next_move: NextMove + next_question_id: str | None + should_echo: bool + echo_content: str | None + reflection_note: str | None + reply: str + skip_stop_reason: str = "none" + + +# Public baseline prompt. Production deployments may provide a private prompt file +# via Settings.reasoner_prompt_file / REASONER_PROMPT_FILE. Keep detailed +# calibration examples and dogfood-derived strategy outside the public repo. +BASELINE_SYSTEM_PROMPT = """You are a careful interview assistant for building a persona profile. + +Priorities: +- Respect explicit refusals and reluctance. +- Avoid over-interpreting short-term states as durable traits. +- Use the provided coverage snapshot and candidate questions to choose useful next steps. +- Prefer descriptive observations over psychological labels. +- Output only valid JSON with the requested schema. + +Return fields: +read, boundary_status, engagement_state, next_move, next_question_id, +should_echo, echo_content, reflection_note, reply. + +Allowed enum values: +- boundary_status: none, explicit_refusal, strong_reluctance +- engagement_state: engaged, fatigued, drifting, guarded, distrustful +- next_move: advance, probe, honor_skip, address_meta, soften +""" + +SYSTEM_PROMPT = BASELINE_SYSTEM_PROMPT + + +def load_system_prompt(path: str | None = None) -> str: + if not path: + return BASELINE_SYSTEM_PROMPT + prompt_path = Path(path).expanduser() + return prompt_path.read_text(encoding="utf-8").strip() + + +class TurnReasoner: + def __init__( + self, + client: AsyncAnthropic, + guardrail: Guardrail | None = None, + model: str = MODEL_FAST, + system_prompt: str | None = None, + ): + self.client = client + self.guardrail = guardrail or Guardrail() + self.model = model + self.system_prompt = system_prompt or BASELINE_SYSTEM_PROMPT + + async def run(self, state: TurnState) -> TurnReasonerOutput: + raw_output = await self._call_model(state) + final_output = self.guardrail.apply( + output=raw_output, + current_probe_count=state.probe_count, + ) + return final_output + + async def _call_model(self, state: TurnState) -> TurnReasonerOutput: + user_prompt = self._build_user_prompt(state) + + response = await create_message( + self.client, + model=self.model, + max_tokens=900, + temperature=0.2, + system=self.system_prompt, + messages=[{"role": "user", "content": user_prompt}], + ) + + raw_text = response.content[0].text.strip() + + try: + data = json.loads(extract_json_payload(raw_text)) + return TurnReasonerOutput(**data) + except Exception: + return TurnReasonerOutput( + read="模型輸出解析失敗,採用保守策略。", + boundary_status="none", + engagement_state="engaged", + next_move="advance", + next_question_id=None, + should_echo=False, + echo_content=None, + reflection_note=None, + reply="抱歉,我剛剛思考有點問題。我們繼續剛才的話題好嗎?", + ) + + def _build_user_prompt(self, state: TurnState) -> str: + history_lines = [ + f"{turn.role}: {turn.content}" for turn in state.recent_history[-8:] + ] + history_text = "\n".join(history_lines) if history_lines else "(尚無歷史對話)" + + candidate_lines = [ + f"- [{q.id}] {q.dimension.value}|{q.text}" + for q in state.candidate_questions + ] + candidate_text = "\n".join(candidate_lines) if candidate_lines else "(無候選題)" + + anchor_lines = [] + for dim, anchors in state.anchors_summary.items(): + if anchors: + contents = [a.content for a in anchors[:3]] + anchor_lines.append(f"{dim.value}: {'; '.join(contents)}") + anchor_text = "\n".join(anchor_lines) if anchor_lines else "(目前尚無 anchors)" + + gap_lines = [ + f"{dim.value}: {gap:.2f}" + for dim, gap in state.coverage_gaps.items() + if gap > 0.3 + ] + gap_text = "\n".join(gap_lines) if gap_lines else "(目前各維度覆蓋尚可)" + + # Build human-readable coverage summary — emphasize what still needs to be collected + LAYER_LABEL = { + Layer.FACT: "淺層", + Layer.PATTERN: "中層", + Layer.PRINCIPLE: "深層", + } + coverage_lines = [] + weak_shallow = [] + for dim, dprog in state.coverage_snapshot.per_dimension.items(): + shallow = dprog.layers.get(Layer.FACT) + shallow_status = f"{shallow.status}({shallow.quality_score:.2f})" if shallow else "none" + reached_label = LAYER_LABEL.get(dprog.overall_reached, "無") if dprog.overall_reached else "無" + coverage_lines.append(f"- {dim.value}: 淺層 {shallow_status} | 已跨 {reached_label}") + if shallow and shallow.quality_score < 0.5: + weak_shallow.append((dim.value, shallow.quality_score)) + + weak_shallow.sort(key=lambda x: x[1]) + weak_text = ", ".join([d[0] for d in weak_shallow[:3]]) if weak_shallow else "無" + + coverage_text = "\n".join(coverage_lines) + f"\n目前淺層最弱的前三個維度:{weak_text}" + if not coverage_lines: + coverage_text = "(尚無收集資料)" + + return f"""【訪談目標】 +{state.goal} + +【當前問題】 +ID: {state.current_question.id} +維度: {state.current_question.dimension.value} +問題內容: {state.current_question.text} + +【上一次對受訪者說的話】 +{state.last_prompt_text or "(無)"} + +【最近對話歷史】(由舊到新,最多顯示 8 輪) +{history_text} + +【目前已追問次數】 +{state.probe_count} + +【已累積的 anchors 摘要】 +{anchor_text} + +【覆蓋缺口較大的維度】 +{gap_text} + +【各維度真實收集狀態(coverage_snapshot)】 +{coverage_text} + +【可選擇的候選題】 +{candidate_text} + +請依照「思考步驟」嚴格判斷後,輸出 JSON。 +""" diff --git a/src/virtualme/interview/turn_reasoner_schema.py b/src/virtualme/interview/turn_reasoner_schema.py new file mode 100644 index 0000000..bdc199a --- /dev/null +++ b/src/virtualme/interview/turn_reasoner_schema.py @@ -0,0 +1,47 @@ +from enum import StrEnum + + +class BoundaryStatus(StrEnum): + NONE = "none" + EXPLICIT_REFUSAL = "explicit_refusal" + STRONG_RELUCTANCE = "strong_reluctance" + + +class EngagementState(StrEnum): + ENGAGED = "engaged" + FATIGUED = "fatigued" + DRIFTING = "drifting" + GUARDED = "guarded" + DISTRUSTFUL = "distrustful" + + +class NextMove(StrEnum): + ADVANCE = "advance" + PROBE = "probe" + HONOR_SKIP = "honor_skip" + ADDRESS_META = "address_meta" + SOFTEN = "soften" + + +class SkipStopReason(StrEnum): + NONE = "none" + REFUSAL = "refusal" + RELUCTANCE = "reluctance" + FATIGUE = "fatigue" + PROBE_CAP_REACHED = "probe_cap_reached" + + +class TurnReasonerOutput: + """Simple container for reasoner output (no Pydantic to avoid extra_forbidden issues)""" + + def __init__(self, **kwargs): + self.read = kwargs.get("read", "") + self.boundary_status = kwargs.get("boundary_status", "none") + self.engagement_state = kwargs.get("engagement_state", "engaged") + self.next_move = kwargs.get("next_move", "advance") + self.next_question_id = kwargs.get("next_question_id") + self.should_echo = kwargs.get("should_echo", False) + self.echo_content = kwargs.get("echo_content") + self.reflection_note = kwargs.get("reflection_note") + self.reply = kwargs.get("reply", "") + self.skip_stop_reason = kwargs.get("skip_stop_reason", "none") diff --git a/src/virtualme/interview/turn_state.py b/src/virtualme/interview/turn_state.py new file mode 100644 index 0000000..719d7f5 --- /dev/null +++ b/src/virtualme/interview/turn_state.py @@ -0,0 +1,312 @@ +"""TurnState (L1): read-only snapshot of per-turn interview context. + +Purpose: feed a single, well-fed context object to the future turn_reasoner (L2). +Assembled purely from existing DB helpers + QuestionSelector. No decision logic, +no side effects, no changes to process_turn or any existing call sites. + +Design constraints (per 2026-05-18 ratified slice): +- GREEN: new file only; zero behavior change until L4 flag wiring. +- Supports restart / retalk / light-greeting / normal paths. +- Goal: subject.goal (if set) else explicit DEFAULT_GOAL for M1. +- Current question keeps pool identity (id/dim); last_prompt_text reflects + what the user is actually replying to (may be follow-up wording). +- candidate_questions = not-yet-asked questions in the relevant pool + (week-specific or adaptive). L2 can further shortlist 2-3 from this. + +Resolution helpers are intentionally duplicated (tiny, deterministic) to keep +turn_state.py self-contained in L1. Will be consolidated when wiring in L4. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from pydantic import BaseModel, ConfigDict + +from virtualme.interview.question_selector import QuestionSelector +from virtualme.storage.db import DB, Anchor, Dimension, Layer, Question, Session, Turn + +# Fallback goal for M1 slice (explicit, matches the "會推理、不像機器人" objective). +# Real per-subject goal (if populated via future UI or init) takes precedence. +DEFAULT_GOAL: str = ( + "Extract rich, layered (fact/pattern/principle) evidence across 8 dimensions " + "(SOUL, VOICE, SKILL, PEOPLE, HISTORY, JOURNAL, BOUNDARIES, STATE) " + "to build a truthful, non-overfitted persona profile of the interviewee." +) + +# Safe fallback when selector has no questions at all (should never happen in prod). +# Mirrors bot.DEFAULT_QUESTION but defined locally to avoid early import coupling. +_FALLBACK_QUESTION = Question( + id="STATE-OPEN", + week=1, + dimension=Dimension.STATE, + text="How has your work been this past week?", + energy_tax="low", +) + + +# === Layered Coverage Model (for real 8-dimension x 3-layer progress) === + +@dataclass +class LayerProgress: + """Progress for one layer of one dimension.""" + evidence_count: int = 0 + quality_score: float = 0.0 # 0.0 ~ 1.0 + status: str = "none" # "none" | "partial" | "sufficient" + + +@dataclass +class DimensionProgress: + """Progress across three layers for one dimension.""" + dimension: Dimension + layers: dict[Layer, LayerProgress] = field(default_factory=dict) + overall_reached: Layer | None = None # highest layer that reached "sufficient" + + +@dataclass +class CoverageSnapshot: + """Complete snapshot of collection progress for all 8 dimensions.""" + per_dimension: dict[Dimension, DimensionProgress] = field(default_factory=dict) + overall_completion: float = 0.0 # rough 0.0~1.0 across all relevant layers + + +class TurnState(BaseModel): + """Immutable (frozen) snapshot of everything the reasoner needs for one turn. + + All fields are read-only after construction. Use .model_copy() only for + test scaffolding; never mutate in production paths. + """ + + model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True) + + goal: str + """整場訪談的目標 (不隨單題漂移)。""" + + current_question: Question + """當前題的池定義 (id, week, dimension, 原始 text)。id 用於記錄與 anchor 歸屬。""" + + last_prompt_text: str | None = None + """實際送給受訪者的上一個 bot 訊息文字 (可能是追問改寫或翻譯後版本)。 + 若 None, 代表這是新 session 或 resume 後第一題。""" + + recent_history: list[Turn] = [] + """最近 N 輪對話 (chronological)。role + content, 供承接前文。""" + + anchors_summary: dict[Dimension, list[Anchor]] = {} + """已萃取 anchor 摘要 (per dimension), 供 coverage 與 gap 判斷。""" + + coverage_gaps: dict[Dimension, float] = {} + """Coverage gap 0~1 (值越高越缺)。由 anchors 動態計算。""" + + coverage_snapshot: CoverageSnapshot = CoverageSnapshot() + """Real per-dimension per-layer progress (L2/L3).""" + + probe_count: int = 0 + """本題已追問次數 (硬 cap 由 L3 護欄管)。""" + + candidate_questions: list[Question] = [] + """本輪可選的候選題清單 (尚未 asked_count > 0 的 pool 問題)。 + L2 reasoner 從中挑 (或再短列 2-3 題)。""" + + +async def build_turn_state( + interviewee_id: str, + db: DB, + selector: QuestionSelector, + session: Session, + adaptive: bool = False, +) -> TurnState: + """Assemble a frozen TurnState for the current moment of this interviewee/session. + + Safe for all entry points: + - Normal answer path (after resolve_current) + - Light greeting / resume + - Post-restart (new week-1 session, first question, probe=0) + - Post-retalk (dimension reset, current pinned to first of that dim) + + Does NOT call any setter (record_*, set_current_*, save_*). Pure read + derive. + """ + subject = await db.get_or_create_subject(interviewee_id) + goal = subject.goal or DEFAULT_GOAL + + current_q = await _resolve_current_question(db, selector, session.id, session.week) + last_prompt_text = await db.get_last_assistant_content(session.id) + recent_history = await db.load_recent_turns(session.id, 10) + anchors_summary = await db.load_anchors_summary(interviewee_id) + coverage_gaps = await db.compute_coverage_gap(interviewee_id) + probe_count = await db.get_probe_count(interviewee_id, current_q.id) + + # === Compute real Layered Coverage (first version) === + coverage_snapshot = _compute_coverage_snapshot(anchors_summary) + + asked = await db.load_asked_question_ids(interviewee_id) + + # Relevant pool for candidates (same policy as selector.select_next) + if adaptive: + pool = [q for questions in selector.question_pool.values() for q in questions] + else: + pool = selector.question_pool.get(session.week, []) or [ + q for questions in selector.question_pool.values() for q in questions + ] + + # Not-yet-asked first; if everything asked (edge), fall back to full pool for this context + candidate_questions: list[Question] = [q for q in pool if q.id not in asked] or pool + + return TurnState( + goal=goal, + current_question=current_q, + last_prompt_text=last_prompt_text, + recent_history=recent_history, + anchors_summary=anchors_summary, + coverage_gaps=coverage_gaps, + coverage_snapshot=coverage_snapshot, + probe_count=probe_count, + candidate_questions=candidate_questions, + ) + + +# ---------------------------------------------------------------------------- +# Internal resolution helpers (L1 isolation) +# Exact mirrors of bot._current_pool_question / _resolve... / _all... / _default... +# Consolidated into a shared helper module at L4 wiring time. +# ---------------------------------------------------------------------------- + + +async def _resolve_current_question( + db: DB, selector: QuestionSelector, session_id: int, week: int +) -> Question: + """Return the Question the current user message is answering. + + - id / dimension / week come from the pool question (stable for storage). + - text may be overridden by the actual last assistant utterance (follow-up + wording or natural-language render) so depth/anchor context is accurate. + """ + base = await _current_pool_question(db, selector, session_id, week) + last_asked = await db.get_last_assistant_content(session_id) + if last_asked: + return base.model_copy(update={"text": last_asked}) + return base + + +async def _current_pool_question( + db: DB, selector: QuestionSelector, session_id: int, week: int +) -> Question: + """The canonical pool question for this session/week (or default).""" + base = _default_question(selector, week) + question_id = await db.get_current_question_id(session_id) + if question_id: + for question in _all_questions(selector): + if question.id == question_id: + return question + return base + + +def _default_question(selector: QuestionSelector, week: int) -> Question: + """First question of the week, or the absolute fallback.""" + questions = selector.question_pool.get(week) + if questions: + return questions[0] + # absolute last resort (matches bot.DEFAULT_QUESTION semantics) + return _FALLBACK_QUESTION + + +def _all_questions(selector: QuestionSelector) -> list[Question]: + """Flatten the entire pool (used for lookup by id).""" + return [question for questions in selector.question_pool.values() for question in questions] + + +# === Real Coverage Computation (L2 first version) === + +def _compute_coverage_snapshot( + anchors_summary: dict[Dimension, list[Anchor]], +) -> CoverageSnapshot: + """First-pass real computation of per-dimension per-layer progress. + + This is intentionally simple for L2. Quality scoring can be made much + smarter in L3 (triangulation, recency, contradiction, source diversity, etc.). + """ + snapshot = CoverageSnapshot() + total_score_sum = 0.0 + dim_count = 0 + + for dim in list(Dimension): + dim_prog = DimensionProgress(dimension=dim) + dim_prog.layers = {layer: LayerProgress() for layer in Layer} + + anchors = anchors_summary.get(dim, []) if anchors_summary else [] + + layer_counts: dict[Layer, int] = {layer: 0 for layer in Layer} + for anchor in anchors: + if hasattr(anchor, "layer") and anchor.layer in layer_counts: + layer_counts[anchor.layer] += 1 + + # --- Layer-dependent scoring (Option A) --- + # We enforce that higher layers only get full credit if lower layers have sufficient foundation. + # This prevents the weird "no shallow but has middle/deep" situation. + + raw_scores = {} + for layer, count in layer_counts.items(): + raw_scores[layer] = min(1.0, count * 0.28) + + # Apply layer dependency + shallow = raw_scores.get(Layer.FACT, 0.0) + middle_raw = raw_scores.get(Layer.PATTERN, 0.0) + deep_raw = raw_scores.get(Layer.PRINCIPLE, 0.0) + + # Middle layer progress is discounted by how much shallow foundation exists + middle = middle_raw * max(0.3, shallow) # at least keep a little credit even with weak shallow + + # Deep layer progress is discounted by middle foundation + deep = deep_raw * max(0.3, middle) + + effective = { + Layer.FACT: shallow, + Layer.PATTERN: middle, + Layer.PRINCIPLE: deep, + } + + for layer in [Layer.FACT, Layer.PATTERN, Layer.PRINCIPLE]: + score = effective[layer] + status = "none" + if score >= 0.75: + status = "sufficient" + elif score >= 0.35: + status = "partial" + + count = layer_counts.get(layer, 0) + dim_prog.layers[layer] = LayerProgress( + evidence_count=count, + quality_score=score, + status=status, + ) + + if status == "sufficient": + if dim_prog.overall_reached is None: + dim_prog.overall_reached = layer + else: + order = [Layer.FACT, Layer.PATTERN, Layer.PRINCIPLE] + if order.index(layer) > order.index(dim_prog.overall_reached): + dim_prog.overall_reached = layer + + # Rough dimension contribution (still weight middle more) + dim_score = ( + dim_prog.layers[Layer.FACT].quality_score * 0.2 + + dim_prog.layers[Layer.PATTERN].quality_score * 0.6 + + dim_prog.layers[Layer.PRINCIPLE].quality_score * 0.2 + ) + + snapshot.per_dimension[dim] = dim_prog + total_score_sum += dim_score + dim_count += 1 + + if dim_count > 0: + snapshot.overall_completion = round(total_score_sum / dim_count, 2) + + return snapshot + + +def compute_coverage_snapshot( + anchors_summary: dict[Dimension, list[Anchor]], +) -> CoverageSnapshot: + """Public wrapper for callers that need current 8x3 coverage outside TurnState.""" + return _compute_coverage_snapshot(anchors_summary) diff --git a/src/virtualme/interview/v2_loader.py b/src/virtualme/interview/v2_loader.py deleted file mode 100644 index 721897b..0000000 --- a/src/virtualme/interview/v2_loader.py +++ /dev/null @@ -1,191 +0,0 @@ -from __future__ import annotations - -from importlib.resources import files -from pathlib import Path -from typing import Any - -import yaml - -from virtualme.interview.v2_schema import ( - DomainPack, - DomainPackCollection, - DomainPackQuestion, - V2DimensionConfig, - V2IntakeQuestion, - V2Question, - V2QuestionPool, - VoiceRoleplay, -) -from virtualme.storage.db import Dimension - -USER_TEXT_FIELDS = ("text", "user_explain", "purpose", "stop_condition") - - -def default_v2_question_pool_path() -> Path: - return Path(str(files("virtualme").joinpath("data/question-pool-v2.yaml"))) - - -def default_domain_packs_path() -> Path: - return Path(str(files("virtualme").joinpath("data/domain-packs-v2.yaml"))) - - -def load_v2_question_pool(path: str | Path | None = None) -> V2QuestionPool: - source = default_v2_question_pool_path() if path is None else Path(path) - raw = _load_yaml(source) - meta = raw.get("meta", {}) - intake = raw.get("intake", {}) - pool = V2QuestionPool( - version=raw["version"], - status=raw.get("status", ""), - production_enabled=bool(meta.get("production_enabled", False)), - intake_questions=[V2IntakeQuestion(**item) for item in intake.get("questions", [])], - dimensions={ - Dimension(key): V2DimensionConfig(**value) - for key, value in raw.get("dimensions", {}).items() - }, - progress_prompts=dict(raw.get("progress_prompts", {})), - transitions=dict(raw.get("transitions", {})), - questions=[V2Question(**item) for item in raw.get("questions", [])], - ) - _assert_no_placeholders(_pool_texts(pool)) - return pool - - -def load_domain_packs(path: str | Path | None = None) -> DomainPackCollection: - source = default_domain_packs_path() if path is None else Path(path) - raw = _load_yaml(source) - collection = DomainPackCollection(**raw) - _assert_no_placeholders(_domain_pack_texts(collection)) - return collection - - -def load_merged_v2_question_pool( - *, - question_pool_path: str | Path | None = None, - domain_packs_path: str | Path | None = None, - domain_pack: str | None = None, -) -> V2QuestionPool: - pool = load_v2_question_pool(question_pool_path) - if domain_pack is None: - return pool - - packs = load_domain_packs(domain_packs_path) - try: - pack = packs.packs[domain_pack] - except KeyError as exc: - raise ValueError(f"unknown domain pack: {domain_pack}") from exc - - merged_questions = [*pool.questions, *_domain_pack_questions(domain_pack, pack)] - return pool.model_copy(update={"questions": merged_questions}) - - -def _load_yaml(path: Path) -> dict[str, Any]: - raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {} - if not isinstance(raw, dict): - raise ValueError(f"YAML root must be a mapping: {path}") - return raw - - -def _domain_pack_questions(slug: str, pack: DomainPack) -> list[V2Question]: - questions: list[V2Question] = [] - questions.extend( - _domain_question_to_v2(slug, Dimension.SKILL, question) - for question in pack.skill_questions - ) - questions.extend( - _domain_question_to_v2(slug, Dimension.PEOPLE, question) - for question in pack.people_questions - ) - questions.extend( - _voice_roleplay_to_v2(slug, roleplay) for roleplay in pack.voice_roleplays - ) - questions.extend( - _domain_question_to_v2(slug, Dimension.BOUNDARIES, question) - for question in pack.boundaries_questions - ) - return questions - - -def _domain_question_to_v2( - slug: str, dimension: Dimension, question: DomainPackQuestion -) -> V2Question: - purpose = question.purpose or question.signal or f"補充 {dimension.value} 領域化人格訊號。" - return V2Question( - id=question.id, - dimension=dimension, - text=question.text, - purpose=purpose, - expected_anchor="domain_signal", - follow_up_max=question.follow_up_max, - stop_condition=question.stop_condition or "取得領域化人格訊號後停止。", - risk_level=question.risk_level, - source=f"domain:{slug}", - ) - - -def _voice_roleplay_to_v2(slug: str, roleplay: VoiceRoleplay) -> V2Question: - return V2Question( - id=roleplay.id, - dimension=Dimension.VOICE, - stage="voice_roleplay", - text=roleplay.text, - purpose=roleplay.extraction_target or "萃取領域化語氣樣本。", - expected_anchor="domain_signal", - follow_up_max=1, - stop_condition="取得一段真實訊息範本後停止。", - risk_level="medium", - source=f"domain:{slug}", - ) - - -def _pool_texts(pool: V2QuestionPool) -> list[str]: - texts: list[str] = [] - for question in pool.intake_questions: - texts.extend([question.text, question.user_explain, question.stop_condition]) - for dimension in pool.dimensions.values(): - texts.extend([dimension.name, dimension.purpose, dimension.avoid]) - texts.extend(pool.progress_prompts.values()) - texts.extend(pool.transitions.values()) - for question in pool.questions: - for field in USER_TEXT_FIELDS: - texts.append(str(getattr(question, field))) - texts.extend(question.follow_ups) - return texts - - -def _domain_pack_texts(collection: DomainPackCollection) -> list[str]: - texts: list[str] = [] - for pack in collection.packs.values(): - texts.append(pack.name) - texts.extend(pack.domain_role) - texts.extend(pack.core_task) - texts.extend(pack.primary_counterparty) - texts.extend(pack.decision_partner) - for question in [ - *pack.skill_questions, - *pack.people_questions, - *pack.boundaries_questions, - ]: - texts.extend( - [ - question.title, - question.text, - question.purpose, - question.expected_anchor, - question.stop_condition, - question.signal, - ] - ) - for roleplay in pack.voice_roleplays: - texts.extend([roleplay.title, roleplay.text, roleplay.extraction_target]) - for bad_question in pack.bad_questions: - texts.extend([bad_question.bad, bad_question.why, bad_question.better]) - texts.extend(pack.persona_anchor_examples) - return texts - - -def _assert_no_placeholders(texts: list[str]) -> None: - leaked = [text for text in texts if "{" in text or "}" in text] - if leaked: - raise ValueError(f"unresolved placeholders in v2 interview data: {leaked[:3]}") - diff --git a/src/virtualme/interview/v2_schema.py b/src/virtualme/interview/v2_schema.py deleted file mode 100644 index 8485d2a..0000000 --- a/src/virtualme/interview/v2_schema.py +++ /dev/null @@ -1,111 +0,0 @@ -from __future__ import annotations - -from typing import Literal - -from pydantic import BaseModel, Field, field_validator - -from virtualme.storage.db import Dimension - -RiskLevel = Literal["low", "medium", "high"] -ExpectedAnchor = Literal["fact", "pattern", "principle", "mixed", "domain_signal"] - - -class V2IntakeQuestion(BaseModel): - id: str - text: str - captures: list[str] = Field(default_factory=list) - user_explain: str - follow_up_max: int = 1 - stop_condition: str - risk_level: RiskLevel = "low" - - -class V2DimensionConfig(BaseModel): - name: str - purpose: str - avoid: str - completion_threshold: int - - -class V2Question(BaseModel): - id: str - dimension: Dimension - stage: str = "domain_overlay" - text: str - purpose: str - user_explain: str = "" - expected_anchor: ExpectedAnchor = "domain_signal" - acceptable_answers: list[str] = Field(default_factory=list) - follow_ups: list[str] = Field(default_factory=list) - follow_up_max: int = 1 - stop_condition: str - risk_level: RiskLevel = "low" - optional: bool = False - source: str = "generic" - - @field_validator("text", "purpose", "stop_condition") - @classmethod - def _required_text(cls, value: str) -> str: - if not value.strip(): - raise ValueError("field must not be empty") - return value - - -class V2QuestionPool(BaseModel): - version: int - status: str - production_enabled: bool - intake_questions: list[V2IntakeQuestion] - dimensions: dict[Dimension, V2DimensionConfig] - progress_prompts: dict[str, str] - transitions: dict[str, str] - questions: list[V2Question] - - -class BadQuestionAlternative(BaseModel): - bad: str - why: str - better: str - - -class DomainPackQuestion(BaseModel): - id: str - title: str = "" - text: str - purpose: str = "" - expected_anchor: str = "" - follow_up_max: int = 1 - stop_condition: str = "" - risk_level: RiskLevel = "low" - signal: str = "" - - -class VoiceRoleplay(BaseModel): - id: str - title: str = "" - text: str - extraction_target: str = "" - - -class DomainPack(BaseModel): - name: str - source: str = "" - domain_role: list[str] - core_task: list[str] - primary_counterparty: list[str] - decision_partner: list[str] - skill_questions: list[DomainPackQuestion] - people_questions: list[DomainPackQuestion] - voice_roleplays: list[VoiceRoleplay] - boundaries_questions: list[DomainPackQuestion] - bad_questions: list[BadQuestionAlternative] - persona_anchor_examples: list[str] - - -class DomainPackCollection(BaseModel): - version: int - status: str - production_enabled: bool - source: str = "" - packs: dict[str, DomainPack] - diff --git a/src/virtualme/main.py b/src/virtualme/main.py index d8be51d..aa2fe54 100644 --- a/src/virtualme/main.py +++ b/src/virtualme/main.py @@ -3,9 +3,16 @@ from anthropic import AsyncAnthropic from fastapi import BackgroundTasks, FastAPI, HTTPException, Request +from fastapi.responses import FileResponse, PlainTextResponse from virtualme import __version__ from virtualme.config import Settings, sqlite_path +from virtualme.export.download_tokens import ( + DownloadFileUnavailable, + DownloadTokenExpired, + DownloadTokenNotFound, + resolve_download_token, +) from virtualme.interview.question_selector import QuestionSelector, load_question_pool from virtualme.responder.persona import load_persona from virtualme.storage.db import DB @@ -41,6 +48,35 @@ async def healthz() -> dict[str, str]: return {"ok": "true", "version": __version__} +@app.get("/download/persona/{token}") +async def download_persona(token: str, request: Request): + ip_address = request.client.host if request.client else None + user_agent = request.headers.get("user-agent") + try: + record = await resolve_download_token( + db, + token, + persona_export_dir=settings.persona_export_dir, + ip_address=ip_address, + user_agent=user_agent, + ) + except DownloadTokenExpired: + return PlainTextResponse( + "下載連結已過期,請回到 LINE 重新輸入「請匯出人格檔」取得新連結。", # noqa: RUF001 + status_code=410, + ) + except DownloadTokenNotFound as exc: + raise HTTPException(status_code=404, detail="download link not found") from exc + except DownloadFileUnavailable as exc: + raise HTTPException(status_code=404, detail="persona zip is unavailable") from exc + + return FileResponse( + record.zip_path, + media_type="application/zip", + filename=record.zip_path.name, + ) + + @app.post("/webhook/line") async def line_webhook(request: Request, background_tasks: BackgroundTasks) -> dict: secret = ( diff --git a/src/virtualme/snapshot/hedge_validator.py b/src/virtualme/snapshot/hedge_validator.py new file mode 100644 index 0000000..0bdb911 --- /dev/null +++ b/src/virtualme/snapshot/hedge_validator.py @@ -0,0 +1,65 @@ +"""Hedge wording validator — Constitution v1.1 §P5 hard gate. + +Detects unhedged stable-trait assertions in synthesis/export output. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +FORBIDDEN_PATTERNS: list[re.Pattern[str]] = [ + re.compile(r"\b[Yy]ou are (an? )?[A-Za-z][A-Za-z -]+\b"), + re.compile(r"\b[Yy]our true (self|nature) is\b"), + re.compile(r"\b[Yy]ou always\b"), + re.compile(r"\b[Yy]ou never\b"), + re.compile(r"你是個?[一-鿿]+的人"), + re.compile(r"你的本質是"), + re.compile(r"你的真實面是?"), + re.compile(r"你總是[一-鿿]+"), +] + +HEDGE_MARKERS: list[str] = [ + "目前觀察到", + "根據訪談", + "可能", + "傾向", + "似乎", + "tentative", + "draft", + "hypothesis", + "appears to", + "tends to", + "in W", + "在 W", +] + + +@dataclass(frozen=True) +class HedgeViolation: + line_number: int + matched_text: str + pattern_id: int + + +def find_unhedged_assertions(text: str) -> list[HedgeViolation]: + """Return forbidden stable-trait assertions found in text.""" + violations: list[HedgeViolation] = [] + for line_number, line in enumerate(text.splitlines(), start=1): + for pattern_id, pattern in enumerate(FORBIDDEN_PATTERNS): + match = pattern.search(line) + if match: + violations.append( + HedgeViolation( + line_number=line_number, + matched_text=match.group(0), + pattern_id=pattern_id, + ) + ) + return violations + + +def has_hedge_marker(text: str) -> bool: + """Return True when text includes at least one known hedge marker.""" + lowered = text.lower() + return any(marker.lower() in lowered for marker in HEDGE_MARKERS) diff --git a/src/virtualme/snapshot/multi_session_validator.py b/src/virtualme/snapshot/multi_session_validator.py new file mode 100644 index 0000000..03dab67 --- /dev/null +++ b/src/virtualme/snapshot/multi_session_validator.py @@ -0,0 +1,60 @@ +"""Multi-session validation gate -- Constitution v1.1 §P4 hard gate. + +Decides whether an anchor's source evidence spans multiple sessions, which +is the prerequisite for promoting it to a "validated" stable trait. + +P4 M1 baseline (negative constraint only): single-session anchors must +never be presented as validated stable traits, regardless of how many +unique question_ids they cover. + +This module provides detection helpers only. Runtime promotion-gate +integration is deferred to M2. +""" + +from __future__ import annotations + +from virtualme.storage.db import DB, Anchor + + +async def unique_session_count(db: DB, turn_ids: list[int]) -> int: + """Return the number of unique session_ids covered by these turns. + + Returns 0 if turn_ids is empty or no matching turns are found. Invalid + turn ids are ignored by the database query. + """ + if not turn_ids: + return 0 + + placeholders = ", ".join("?" for _ in turn_ids) + async with db._connect() as conn: + row = await ( + await conn.execute( + f""" + SELECT COUNT(DISTINCT session_id) + FROM turns + WHERE id IN ({placeholders}) + """, + tuple(turn_ids), + ) + ).fetchone() + + return int(row[0]) if row and row[0] is not None else 0 + + +async def is_single_session(db: DB, anchor: Anchor) -> bool: + """True if all source turns of this anchor belong to one session.""" + count = await unique_session_count(db, anchor.source_turn_ids) + return count <= 1 + + +async def can_be_validated(db: DB, anchor: Anchor) -> bool: + """Return whether an anchor is eligible for validated status. + + P4 M1 gate: anchor is eligible for "validated" status only if it spans + multiple sessions. Single-session anchors get "tentative" at most. + + This helper only declares eligibility; downstream promotion logic + (save_anchor / synthesis) is not yet wired to call this. M2 will add the + integration. + """ + return not await is_single_session(db, anchor) diff --git a/src/virtualme/snapshot/stability_gate.py b/src/virtualme/snapshot/stability_gate.py new file mode 100644 index 0000000..4982a4a --- /dev/null +++ b/src/virtualme/snapshot/stability_gate.py @@ -0,0 +1,34 @@ +"""State-Trait stability gate - Constitution v1.1 §P1 hard gate. + +Decides which anchors are eligible to be rendered as "Core Truths" or stable +traits in persona representations. + +P1 baseline (M1): exclude `Dimension.STATE` from SOUL/VOICE/SKILL/BOUNDARIES +Core Truths. STATE may still appear in its own STATE.md file as a current-state +snapshot (per DIMENSION_DESCRIPTIONS). +""" + +from __future__ import annotations + +from virtualme.storage.db import Anchor, Dimension + +# Dimensions that represent durable identity/trait surfaces. +# STATE is current snapshot, not Core Truth. +CORE_TRUTH_DIMENSIONS: frozenset[Dimension] = frozenset( + { + Dimension.SOUL, + Dimension.VOICE, + Dimension.SKILL, + Dimension.BOUNDARIES, + } +) + + +def is_eligible_for_core_truths(anchor: Anchor) -> bool: + """Return False for anchors whose source dimension is STATE.""" + return anchor.dimension != Dimension.STATE + + +def filter_core_truth_candidates(anchors: list[Anchor]) -> list[Anchor]: + """Filter a list of candidate anchors for Core Truths surfaces.""" + return [anchor for anchor in anchors if is_eligible_for_core_truths(anchor)] diff --git a/src/virtualme/storage/db.py b/src/virtualme/storage/db.py index e6ea7e5..3c02c00 100644 --- a/src/virtualme/storage/db.py +++ b/src/virtualme/storage/db.py @@ -227,6 +227,10 @@ async def _apply_schema_migrations(conn: aiosqlite.Connection) -> None: "archive_reason", "ALTER TABLE anchors ADD COLUMN archive_reason TEXT", ), + ( + "model", + "ALTER TABLE anchors ADD COLUMN model TEXT", + ), # v0.5+ anchor migrations append here ] @@ -286,6 +290,47 @@ async def _apply_schema_migrations(conn: aiosqlite.Connection) -> None: ) """ ) + await conn.execute( + """ + CREATE TABLE IF NOT EXISTS persona_download_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token_hash TEXT NOT NULL UNIQUE, + interviewee_id TEXT NOT NULL, + zip_path TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + download_count INTEGER NOT NULL DEFAULT 0, + last_downloaded_at TEXT + ) + """ + ) + await conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_persona_download_tokens_expires_at + ON persona_download_tokens(expires_at) + """ + ) + await conn.execute( + """ + CREATE TABLE IF NOT EXISTS persona_download_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + interviewee_id TEXT, + token_hash TEXT NOT NULL, + requested_at TEXT NOT NULL, + ip_address TEXT, + user_agent TEXT, + success INTEGER NOT NULL, + failure_reason TEXT, + zip_path TEXT + ) + """ + ) + await conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_persona_download_logs_token_hash + ON persona_download_logs(token_hash) + """ + ) async def init_db(path: str) -> None: @@ -893,6 +938,7 @@ async def save_anchor( content: str, source_turn_ids: list[int], source_question_ids: list[str] | None = None, + model: str | None = None, ) -> Anchor: turn_ids = _dedupe_preserve_order(source_turn_ids) question_ids = _dedupe_preserve_order( @@ -913,9 +959,9 @@ async def save_anchor( """ INSERT INTO anchors( interviewee_id, dimension, layer, content, - triangulated, source_turn_ids, source_question_ids + triangulated, source_turn_ids, source_question_ids, model ) - VALUES (?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, ( interviewee_id, @@ -925,6 +971,7 @@ async def save_anchor( int(triangulated), json.dumps(turn_ids), json.dumps(question_ids), + model, ), ) await conn.commit() diff --git a/src/virtualme/storage/schema.sql b/src/virtualme/storage/schema.sql index f841dfc..46b56ce 100644 --- a/src/virtualme/storage/schema.sql +++ b/src/virtualme/storage/schema.sql @@ -46,6 +46,7 @@ CREATE TABLE IF NOT EXISTS anchors ( active INTEGER NOT NULL DEFAULT 1, archived_at TEXT, archive_reason TEXT, + model TEXT, -- 記錄產生此 anchor 的模型 (e.g. claude-3-5-haiku-20241022, claude-3-5-sonnet-20241022) created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, pii_tag TEXT ); @@ -120,3 +121,32 @@ CREATE TABLE IF NOT EXISTS transport_events ( created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); + +CREATE TABLE IF NOT EXISTS persona_download_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token_hash TEXT NOT NULL UNIQUE, + interviewee_id TEXT NOT NULL, + zip_path TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + download_count INTEGER NOT NULL DEFAULT 0, + last_downloaded_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_persona_download_tokens_expires_at + ON persona_download_tokens(expires_at); + +CREATE TABLE IF NOT EXISTS persona_download_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + interviewee_id TEXT, + token_hash TEXT NOT NULL, + requested_at TEXT NOT NULL, + ip_address TEXT, + user_agent TEXT, + success INTEGER NOT NULL, + failure_reason TEXT, + zip_path TEXT +); + +CREATE INDEX IF NOT EXISTS idx_persona_download_logs_token_hash + ON persona_download_logs(token_hash); diff --git a/src/virtualme/transport/line.py b/src/virtualme/transport/line.py index f961618..b57089a 100644 --- a/src/virtualme/transport/line.py +++ b/src/virtualme/transport/line.py @@ -11,6 +11,7 @@ AsyncApiClient, AsyncMessagingApi, Configuration, + FlexMessage, PushMessageRequest, ReplyMessageRequest, TextMessage, @@ -78,6 +79,10 @@ async def handle_line_webhook( event_id = _event_id(event) message_id = getattr(event.message, "id", None) + if event_id is None: + logger.error("LINE text event skipped because stable event id is missing") + skipped += 1 + continue if not await db.claim_transport_event( event_id, "line", @@ -99,6 +104,7 @@ async def handle_line_webhook( db=db, selector=selector, settings=settings, + download_base_url=_download_base_url(settings, request), ), background_tasks, ) @@ -118,6 +124,7 @@ async def _process_text_event( db: DB, selector: QuestionSelector, settings: Settings, + download_base_url: str | None = None, ) -> None: configuration = Configuration(access_token=access_token) async with AsyncApiClient(configuration) as api_client: @@ -130,6 +137,7 @@ async def _process_text_event( db=db, selector=selector, settings=settings, + download_base_url=download_base_url, ) except Exception as exc: logger.error("process_turn failed for %s: %s", interviewee_id, exc) @@ -144,24 +152,55 @@ async def _send_reply_or_push( line_bot_api: AsyncMessagingApi, reply_token: str, user_id: str, - reply: str, + reply: str | dict, ) -> bool: token_hint = reply_token[:8] + zip_path = getattr(reply, "zip_path", None) + zip_caption = getattr(reply, "caption", None) + if zip_path: + logger.warning( + "Persona zip artifact is ready for %s at %s, but LINE Messaging API " + "does not support arbitrary file/zip send messages; sending text reply only.", + user_id, + zip_path, + ) + + # Support Flex Message (progress card etc.) + if isinstance(reply, dict) and reply.get("type") == "flex": + try: + # More tolerant construction for different SDK versions + flex_message = FlexMessage( + alt_text=reply.get("altText", "訪談進度"), + contents=reply["contents"], # pass the bubble dict directly + ) + await line_bot_api.reply_message( + ReplyMessageRequest(reply_token=reply_token, messages=[flex_message]) + ) + logger.info("LINE Flex reply sent with token %s", token_hint) + return True + except Exception as flex_exc: + logger.error("LINE Flex reply failed for %s: %s", user_id, flex_exc) + # fallback to text + reply = "目前進度卡片暫時無法顯示, 已切換為文字模式。" + + # Normal text path try: await line_bot_api.reply_message( ReplyMessageRequest( reply_token=reply_token, - messages=[TextMessage(text=reply)], + messages=[TextMessage(text=str(reply))], ) ) logger.info("LINE reply sent with token %s", token_hint) + if zip_path and zip_caption: + logger.info("Persona zip caption for %s: %s", user_id, zip_caption) return True except Exception as reply_exc: logger.error("LINE reply failed for %s with token %s: %s", user_id, token_hint, reply_exc) try: await line_bot_api.push_message( - PushMessageRequest(to=user_id, messages=[TextMessage(text=reply)]) + PushMessageRequest(to=user_id, messages=[TextMessage(text=str(reply))]) ) logger.info("LINE push fallback sent for %s after token %s failed", user_id, token_hint) return True @@ -174,10 +213,18 @@ def _secret_value(secret) -> str | None: return secret.get_secret_value() if secret is not None else None -def _event_id(event: MessageEvent) -> str: +def _download_base_url(settings: Settings, request: Request) -> str | None: + if settings.persona_download_base_url: + return settings.persona_download_base_url + base_url = getattr(request, "base_url", None) + return str(base_url).rstrip("/") if base_url is not None else None + + +def _event_id(event: MessageEvent) -> str | None: webhook_event_id = getattr(event, "webhook_event_id", None) message_id = getattr(event.message, "id", None) - return str(webhook_event_id or message_id or f"line:{id(event)}") + event_id = webhook_event_id or message_id + return str(event_id) if event_id else None def _enqueue(coro: Awaitable[None], background_tasks: BackgroundTasks | None) -> None: diff --git a/tests/integration/test_current_question_tracking.py b/tests/integration/test_current_question_tracking.py index 1bd5d2e..1033352 100644 --- a/tests/integration/test_current_question_tracking.py +++ b/tests/integration/test_current_question_tracking.py @@ -6,7 +6,7 @@ from virtualme.config import Settings from virtualme.interview.bot import process_turn from virtualme.interview.question_selector import QuestionSelector -from virtualme.storage.db import DB, Dimension, Question +from virtualme.storage.db import DB, Dimension, Layer, Question class _Content: @@ -15,8 +15,9 @@ def __init__(self, text: str): class _Messages: - def __init__(self, depth: str = "principle"): + def __init__(self, depth: str = "principle", reasoner_payload: dict | None = None): self.depth = depth + self.reasoner_payload = reasoner_payload self.depth_questions: list[str] = [] self.ppa_calls = 0 @@ -52,14 +53,16 @@ async def create(self, **kwargs): elif max_tokens == 150: self.ppa_calls += 1 text = '{"assistant": "freeform ppa reply"}' + elif max_tokens == 900 and self.reasoner_payload is not None: + text = json.dumps(self.reasoner_payload) else: text = "OK" return type("Response", (), {"content": [_Content(text)]}) class _Claude: - def __init__(self, depth: str = "principle"): - self.messages = _Messages(depth) + def __init__(self, depth: str = "principle", reasoner_payload: dict | None = None): + self.messages = _Messages(depth, reasoner_payload) class _FixedSelector: @@ -228,3 +231,129 @@ async def test_ppa_does_not_override_explicit_next_question(tmp_path): assert reply == "What skill matters most?" assert claude.messages.ppa_calls == 0 + + +async def test_reasoner_path_extracts_anchors_and_advances_with_explicit_next_question(tmp_path): + db = DB(str(tmp_path / "virtualme.db")) + await db.init() + settings = Settings( + anthropic_api_key=SecretStr("test"), + use_ppa=False, + reasoning_turn_enabled=True, + reasoning_test_user_ids="u1", + ) + q2 = Question( + id="Q2", + week=1, + dimension=Dimension.SKILL, + text="What skill matters most?", + ) + selector = _FixedSelector(q2) + claude = _Claude( + reasoner_payload={ + "read": "user gave evidence", + "boundary_status": "none", + "engagement_state": "engaged", + "next_move": "advance", + "next_question_id": "Q2", + "should_echo": False, + "echo_content": None, + "reflection_note": None, + "reply": "Next.", + } + ) + + reply = await process_turn("u1", "Sufficient answer.", claude, db, selector, settings) + + assert reply == "Next." + assert await _session_current_question_id(db, 1) == "Q2" + summary = await db.load_anchors_summary("u1") + assert summary[Dimension.STATE][0].content == "directness over deference" + assert summary[Dimension.STATE][0].source_question_ids == ["Q1"] + + +async def test_reasoner_path_falls_back_to_weakest_shallow_question_when_advance_has_no_id( + tmp_path, +): + db = DB(str(tmp_path / "virtualme.db")) + await db.init() + settings = Settings( + anthropic_api_key=SecretStr("test"), + use_ppa=False, + reasoning_turn_enabled=True, + reasoning_test_user_ids="u1", + ) + selector = QuestionSelector( + { + 1: [ + Question( + id="Q1", + week=1, + dimension=Dimension.STATE, + text="How has work been?", + ), + Question( + id="Q2", + week=1, + dimension=Dimension.SKILL, + text="What skill matters most?", + ), + Question( + id="Q3", + week=1, + dimension=Dimension.VOICE, + text="How do you usually speak under pressure?", + ), + ] + } + ) + for index in range(3): + await db.save_anchor("u1", Dimension.SKILL, Layer.FACT, f"skill-{index}", [1], ["QS"]) + claude = _Claude( + reasoner_payload={ + "read": "user gave evidence", + "boundary_status": "none", + "engagement_state": "engaged", + "next_move": "advance", + "next_question_id": None, + "should_echo": False, + "echo_content": None, + "reflection_note": None, + "reply": "Next.", + } + ) + + await process_turn("u1", "Sufficient answer.", claude, db, selector, settings) + + assert await _session_current_question_id(db, 1) == "Q3" + + +async def test_reasoner_path_does_not_extract_on_explicit_refusal(tmp_path): + db = DB(str(tmp_path / "virtualme.db")) + await db.init() + settings = Settings( + anthropic_api_key=SecretStr("test"), + use_ppa=False, + reasoning_turn_enabled=True, + reasoning_test_user_ids="u1", + ) + selector = _FixedSelector(None) + claude = _Claude( + reasoner_payload={ + "read": "user refused", + "boundary_status": "explicit_refusal", + "engagement_state": "guarded", + "next_move": "honor_skip", + "next_question_id": None, + "should_echo": False, + "echo_content": None, + "reflection_note": None, + "reply": "我們先跳過。", + } + ) + + reply = await process_turn("u1", "不想回答", claude, db, selector, settings) + + assert reply == "我們先跳過。" + summary = await db.load_anchors_summary("u1") + assert all(not anchors for anchors in summary.values()) diff --git a/tests/unit/test_commands.py b/tests/unit/test_commands.py index e5720c6..28fe7e0 100644 --- a/tests/unit/test_commands.py +++ b/tests/unit/test_commands.py @@ -123,9 +123,9 @@ async def test_process_turn_status_query_reports_completion_progress(tmp_path): reply = await process_turn("u1", "萃取進度", object(), db, selector, settings) - assert "語氣・表達: 33%" in reply - assert "界線・原則: 100%" in reply - assert "目前最缺" in reply + assert "目前訪談收集進度" in reply + assert "聲音/表達" in reply + assert "界線/責任 淺層:●●○" in reply async def test_process_turn_generate_profile_denied_by_default(tmp_path): @@ -140,7 +140,7 @@ async def test_process_turn_generate_profile_denied_by_default(tmp_path): reply = await process_turn("u1", "產生人格檔", object(), db, selector, settings) - assert "沒有開放 LINE 直接產生行為模式檔" in reply + assert reply == "目前VM對你的認識還不夠,請多與VM再訪談一陣子喔" # noqa: RUF001 assert not (tmp_path / "snapshots").exists() turns = await db.load_session_turns(1) assert [turn.role for turn in turns] == ["user", "assistant"] @@ -160,46 +160,44 @@ async def test_process_turn_generate_profile_denied_when_user_not_allowed(tmp_pa reply = await process_turn("friend1", "generate profile", object(), db, selector, settings) - assert "沒有開放 LINE 直接產生行為模式檔" in reply + assert reply == "目前VM對你的認識還不夠,請多與VM再訪談一陣子喔" # noqa: RUF001 assert not (tmp_path / "snapshots").exists() -async def test_process_turn_generate_profile_exports_for_allowed_user_without_extraction(tmp_path): +async def test_process_turn_generate_profile_exports_persona_zip_when_sufficient(tmp_path): db = await _new_db(tmp_path) selector = QuestionSelector( {1: [Question(id="Q1", week=1, dimension=Dimension.STATE, text="How has work been?")]} ) settings = Settings( anthropic_api_key=SecretStr("k"), - line_snapshot_export_enabled=True, - line_snapshot_export_user_ids="u1", - snapshot_export_dir=str(tmp_path / "snapshots"), - ) - await db.save_anchor( - "u1", - Dimension.SKILL, - Layer.PRINCIPLE, - "uses project triangle language around budget scope and schedule", - [1], - ["Q1"], + persona_export_dir=str(tmp_path / "personas"), + persona_download_base_url="https://vm.example.com", ) + for index in range(3): + await db.save_anchor("u1", Dimension.VOICE, Layer.FACT, f"voice {index}", [1], ["Q1"]) + await db.save_anchor( + "u1", Dimension.BOUNDARIES, Layer.PRINCIPLE, f"boundary {index}", [1], ["Q1"] + ) reply = await process_turn("u1", "產生人格檔", object(), db, selector, settings) - snapshot_dir = tmp_path / "snapshots" / "u1" / "snapshot" - assert "行為模式檔 v0" in reply - assert "讀完之後" in reply - assert "construct-cards" not in reply - assert "###" not in reply - assert (snapshot_dir / "construct-cards.md").is_file() - assert (snapshot_dir / "SOUL-lite.md").is_file() + assert "VirtualMe 訪談機器人 【目前訪談收集進度(八維 × 三層)】" in reply # noqa: RUF001 + assert "你目前訪談總完成度約" in reply + assert reply.file_name.startswith("VirtualMe_人格檔_") + assert reply.caption == "你的人格檔 zip 已準備好" + assert "https://vm.example.com/download/persona/" in reply + assert "下載連結有效 60 分鐘" in reply + assert (tmp_path / "personas" / "u1" / "本次匯出說明.txt").is_file() + assert (tmp_path / "personas" / "u1" / "VOICE.md").is_file() + assert (tmp_path / "personas" / "_packages" / "u1" / reply.file_name).is_file() turns = await db.load_session_turns(1) assert [turn.role for turn in turns] == ["user", "assistant"] anchors = await db.load_anchors_summary("u1") - assert len(anchors[Dimension.SKILL]) == 1 + assert len(anchors[Dimension.VOICE]) == 3 -async def test_process_turn_generate_profile_owner_is_allowed_when_flag_enabled(tmp_path): +async def test_process_turn_generate_profile_ignores_legacy_whitelist_when_insufficient(tmp_path): db = await _new_db(tmp_path) selector = QuestionSelector( {1: [Question(id="Q1", week=1, dimension=Dimension.STATE, text="How has work been?")]} @@ -213,11 +211,8 @@ async def test_process_turn_generate_profile_owner_is_allowed_when_flag_enabled( reply = await process_turn("owner-user", "export profile", object(), db, selector, settings) - assert "行為模式檔 v0" in reply - assert "讀完之後" in reply - assert "construct-cards" not in reply - assert "###" not in reply - assert (tmp_path / "snapshots" / "owner-user" / "snapshot" / "SOUL-lite.md").is_file() + assert reply == "目前VM對你的認識還不夠,請多與VM再訪談一陣子喔" # noqa: RUF001 + assert not (tmp_path / "snapshots").exists() def test_consent_accepted_reply_operator_mode_omits_api_key(tmp_path): @@ -486,7 +481,7 @@ async def test_status_query_after_pause_does_not_advance_question(tmp_path): reply = await process_turn("u1", "目前訪談的進度如何?", _Claude(), db, selector, settings) - assert "總完成度" in reply + assert "目前訪談收集進度" in reply assert "People question" not in reply assert await db.get_current_question_id(session.id) == "Q1" diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 864d4a6..27cc1e3 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -6,3 +6,29 @@ def test_adaptive_extraction_defaults_are_disabled(): assert settings.adaptive_extraction is False assert settings.max_extraction_rounds == 3 + + +def test_reasoner_model_name_accepts_env_alias(monkeypatch): + monkeypatch.setenv("REASONER_MODEL_NAME", "claude-haiku-4-5") + + settings = Settings(anthropic_api_key="test") + + assert settings.reasoner_model_name == "claude-haiku-4-5" + + +def test_reasoner_prompt_file_accepts_env_alias(monkeypatch): + monkeypatch.setenv("REASONER_PROMPT_FILE", ".private/reasoner-system-prompt.txt") + + settings = Settings(anthropic_api_key="test") + + assert settings.reasoner_prompt_file == ".private/reasoner-system-prompt.txt" + + +def test_persona_download_settings_accept_env_aliases(monkeypatch): + monkeypatch.setenv("VIRTUALME_PERSONA_DOWNLOAD_BASE_URL", "https://vm.example.com") + monkeypatch.setenv("VIRTUALME_PERSONA_DOWNLOAD_EXPIRY_MINUTES", "45") + + settings = Settings(anthropic_api_key="test") + + assert settings.persona_download_base_url == "https://vm.example.com" + assert settings.persona_download_expiry_minutes == 45 diff --git a/tests/unit/test_domain_packs_v2.py b/tests/unit/test_domain_packs_v2.py deleted file mode 100644 index 21cd3c6..0000000 --- a/tests/unit/test_domain_packs_v2.py +++ /dev/null @@ -1,65 +0,0 @@ -from pathlib import Path - -import yaml - -DOMAIN_PACK_PATH = Path("src/virtualme/data/domain-packs-v2.yaml") - - -def _load_domain_packs() -> dict: - return yaml.safe_load(DOMAIN_PACK_PATH.read_text(encoding="utf-8")) - - -def test_domain_packs_v2_parse_and_include_all_domains(): - data = _load_domain_packs() - - assert data["version"] == 2 - assert data["production_enabled"] is False - assert set(data["packs"]) == { - "engineer_ai_builder", - "sales_bd", - "pm_tpm", - "consultant", - "manager_people_lead", - "creator_writer", - "teacher_coach", - "founder_operator", - } - - -def test_domain_packs_v2_have_required_sections_and_counts(): - data = _load_domain_packs() - - for pack in data["packs"].values(): - assert len(pack["domain_role"]) >= 1 - assert len(pack["core_task"]) >= 1 - assert len(pack["primary_counterparty"]) >= 1 - assert len(pack["decision_partner"]) >= 1 - assert len(pack["skill_questions"]) == 8 - assert len(pack["people_questions"]) == 5 - assert len(pack["voice_roleplays"]) == 5 - assert len(pack["boundaries_questions"]) == 5 - assert len(pack["bad_questions"]) == 5 - assert len(pack["persona_anchor_examples"]) == 12 - - -def test_domain_packs_v2_skill_questions_keep_anchor_metadata(): - data = _load_domain_packs() - - for slug, pack in data["packs"].items(): - for question in pack["skill_questions"]: - assert question["id"].startswith(f"{slug}_skill_") - assert question["text"] - assert question["purpose"] - assert question["expected_anchor"] - assert question["follow_up_max"] in {1, 2} - assert question["stop_condition"] - assert question["risk_level"] in {"low", "medium", "high"} - - -def test_domain_packs_v2_no_template_placeholders_or_appendix_leakage(): - data = _load_domain_packs() - serialized = yaml.safe_dump(data, allow_unicode=True) - - assert "{decision_partner}" not in serialized - assert "泛用 SOUL / HISTORY / JOURNAL / STATE 骨架" not in serialized - assert "反感問法總原則" not in serialized diff --git a/tests/unit/test_download_tokens.py b/tests/unit/test_download_tokens.py new file mode 100644 index 0000000..d5a8803 --- /dev/null +++ b/tests/unit/test_download_tokens.py @@ -0,0 +1,123 @@ +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import aiosqlite +import pytest + +from virtualme.export.download_tokens import ( + DownloadFileUnavailable, + DownloadTokenExpired, + build_download_url, + cleanup_expired_download_tokens, + create_download_token, + hash_token, + resolve_download_token, +) +from virtualme.storage.db import DB + + +async def _new_db(tmp_path: Path) -> DB: + db = DB(str(tmp_path / "virtualme.db")) + await db.init() + return db + + +async def _download_count(db: DB, raw_token: str) -> int: + async with aiosqlite.connect(db.path) as conn: + row = await ( + await conn.execute( + "SELECT download_count FROM persona_download_tokens WHERE token_hash = ?", + (hash_token(raw_token),), + ) + ).fetchone() + return int(row[0]) + + +async def _log_rows(db: DB) -> list[tuple]: + async with aiosqlite.connect(db.path) as conn: + rows = await ( + await conn.execute( + "SELECT success, failure_reason, ip_address, user_agent FROM persona_download_logs" + ) + ).fetchall() + return rows + + +async def test_create_and_resolve_download_token_allows_repeat_downloads(tmp_path): + db = await _new_db(tmp_path) + export_dir = tmp_path / "personas" + zip_path = export_dir / "_packages" / "u1" / "VirtualMe_人格檔_20260520.zip" + zip_path.parent.mkdir(parents=True) + zip_path.write_bytes(b"zip") + + raw_token = await create_download_token(db, "u1", zip_path) + first = await resolve_download_token( + db, + raw_token, + persona_export_dir=str(export_dir), + ip_address="127.0.0.1", + user_agent="pytest", + ) + second = await resolve_download_token(db, raw_token, persona_export_dir=str(export_dir)) + + assert first.zip_path == zip_path.resolve() + assert second.interviewee_id == "u1" + assert await _download_count(db, raw_token) == 2 + assert (1, None, "127.0.0.1", "pytest") in await _log_rows(db) + + +async def test_resolve_expired_download_token_records_failure(tmp_path): + db = await _new_db(tmp_path) + export_dir = tmp_path / "personas" + zip_path = export_dir / "_packages" / "u1" / "VirtualMe_人格檔_20260520.zip" + zip_path.parent.mkdir(parents=True) + zip_path.write_bytes(b"zip") + created_at = datetime(2026, 5, 20, 1, 0, tzinfo=UTC) + raw_token = await create_download_token(db, "u1", zip_path, now=created_at) + + with pytest.raises(DownloadTokenExpired): + await resolve_download_token( + db, + raw_token, + persona_export_dir=str(export_dir), + now=created_at + timedelta(minutes=61), + ) + + assert (0, "token_expired", None, None) in await _log_rows(db) + + +async def test_resolve_rejects_zip_outside_export_dir(tmp_path): + db = await _new_db(tmp_path) + export_dir = tmp_path / "personas" + outside_zip = tmp_path / "other" / "secret.zip" + outside_zip.parent.mkdir() + outside_zip.write_bytes(b"zip") + raw_token = await create_download_token(db, "u1", outside_zip) + + with pytest.raises(DownloadFileUnavailable): + await resolve_download_token(db, raw_token, persona_export_dir=str(export_dir)) + + assert (0, "zip_unavailable", None, None) in await _log_rows(db) + + +async def test_cleanup_expired_download_tokens(tmp_path): + db = await _new_db(tmp_path) + export_dir = tmp_path / "personas" + zip_path = export_dir / "_packages" / "u1" / "VirtualMe_人格檔_20260520.zip" + zip_path.parent.mkdir(parents=True) + zip_path.write_bytes(b"zip") + created_at = datetime(2026, 5, 20, 1, 0, tzinfo=UTC) + await create_download_token(db, "u1", zip_path, now=created_at) + + deleted = await cleanup_expired_download_tokens( + db, + now=created_at + timedelta(minutes=61), + ) + + assert deleted == 1 + + +def test_build_download_url_normalizes_base(): + assert build_download_url("https://vm.example.com/", "abc") == ( + "https://vm.example.com/download/persona/abc" + ) diff --git a/tests/unit/test_interview_v2_loader.py b/tests/unit/test_interview_v2_loader.py deleted file mode 100644 index ade7393..0000000 --- a/tests/unit/test_interview_v2_loader.py +++ /dev/null @@ -1,93 +0,0 @@ -from pathlib import Path - -import pytest - -from virtualme.interview.v2_loader import ( - default_domain_packs_path, - default_v2_question_pool_path, - load_domain_packs, - load_merged_v2_question_pool, - load_v2_question_pool, -) -from virtualme.storage.db import Dimension - - -def test_load_v2_question_pool_reads_generic_draft(): - pool = load_v2_question_pool() - - assert default_v2_question_pool_path().name == "question-pool-v2.yaml" - assert pool.version == 2 - assert pool.production_enabled is False - assert len(pool.intake_questions) == 5 - assert len(pool.dimensions) == 8 - assert len(pool.questions) == 64 - first = pool.questions[0] - assert first.dimension == Dimension.STATE - assert first.purpose - assert first.user_explain - assert first.expected_anchor == "fact" - assert first.follow_up_max == 2 - assert first.stop_condition - assert first.risk_level == "low" - - -def test_load_domain_packs_reads_all_structured_packs(): - packs = load_domain_packs() - - assert default_domain_packs_path().name == "domain-packs-v2.yaml" - assert packs.version == 2 - assert set(packs.packs) == { - "engineer_ai_builder", - "sales_bd", - "pm_tpm", - "consultant", - "manager_people_lead", - "creator_writer", - "teacher_coach", - "founder_operator", - } - engineer = packs.packs["engineer_ai_builder"] - assert len(engineer.skill_questions) == 8 - assert len(engineer.people_questions) == 5 - assert len(engineer.voice_roleplays) == 5 - assert len(engineer.boundaries_questions) == 5 - assert len(engineer.bad_questions) == 5 - assert len(engineer.persona_anchor_examples) == 12 - - -def test_load_merged_v2_question_pool_appends_domain_overlay(): - generic = load_v2_question_pool() - merged = load_merged_v2_question_pool(domain_pack="engineer_ai_builder") - - assert len(merged.questions) == len(generic.questions) + 23 - assert any(question.id == "state_01" for question in merged.questions) - domain_questions = [ - question for question in merged.questions if question.source == "domain:engineer_ai_builder" - ] - assert {question.dimension for question in domain_questions} == { - Dimension.SKILL, - Dimension.PEOPLE, - Dimension.VOICE, - Dimension.BOUNDARIES, - } - assert any(question.id == "engineer_ai_builder_skill_01" for question in domain_questions) - voice = next(question for question in domain_questions if question.dimension == Dimension.VOICE) - assert voice.stage == "voice_roleplay" - assert voice.stop_condition == "取得一段真實訊息範本後停止。" - - -def test_load_merged_v2_question_pool_rejects_unknown_domain_pack(): - with pytest.raises(ValueError, match="unknown domain pack"): - load_merged_v2_question_pool(domain_pack="missing_domain") - - -def test_v2_loader_rejects_unresolved_placeholders(tmp_path): - source = Path("src/virtualme/data/question-pool-v2.yaml") - target = tmp_path / "question-pool-v2.yaml" - target.write_text( - source.read_text(encoding="utf-8").replace("最近這陣子", "{domain_role} 最近這陣子", 1), - encoding="utf-8", - ) - - with pytest.raises(ValueError, match="unresolved placeholders"): - load_v2_question_pool(target) diff --git a/tests/unit/test_line_transport.py b/tests/unit/test_line_transport.py index 64c9a38..bc59260 100644 --- a/tests/unit/test_line_transport.py +++ b/tests/unit/test_line_transport.py @@ -236,6 +236,34 @@ async def fake_process_turn(**kwargs): assert result == {"status": "ok", "queued": 0, "duplicate": 0, "skipped": 1} +async def test_text_event_without_stable_event_id_skips(monkeypatch): + body = _line_body() + event = line.WebhookParser("secret").parse(body.decode(), _signature(body))[0] + object.__setattr__(event, "webhook_event_id", None) + object.__setattr__(event.message, "id", None) + + class FakeParser: + def __init__(self, secret): + self.secret = secret + + def parse(self, body_text, signature): + return [event] + + async def fake_process_turn(**kwargs): + raise AssertionError("should not call process_turn") + + monkeypatch.setattr(line, "WebhookParser", FakeParser) + monkeypatch.setattr(line, "process_turn", fake_process_turn) + result = await line.handle_line_webhook( + FakeRequest(body, _signature(body)), + object(), + object(), + object(), + settings=_settings(), + ) + assert result == {"status": "ok", "queued": 0, "duplicate": 0, "skipped": 1} + + async def test_duplicate_line_event_is_not_enqueued(monkeypatch): body = _line_body() background = FakeBackgroundTasks() diff --git a/tests/unit/test_p1_state_trait_separation.py b/tests/unit/test_p1_state_trait_separation.py new file mode 100644 index 0000000..f28e617 --- /dev/null +++ b/tests/unit/test_p1_state_trait_separation.py @@ -0,0 +1,120 @@ +"""P1 State-Trait Separation hard gate - Constitution v1.1 §P1.""" + +import pytest + +from virtualme.export.markdown import export_markdown +from virtualme.snapshot.stability_gate import ( + CORE_TRUTH_DIMENSIONS, + filter_core_truth_candidates, + is_eligible_for_core_truths, +) +from virtualme.storage.db import DB, Anchor, Dimension, Layer + + +class TestStabilityGate: + def test_state_dimension_not_in_core_truth_dimensions(self): + assert Dimension.STATE not in CORE_TRUTH_DIMENSIONS + + def test_soul_voice_skill_boundaries_in_core_truth_dimensions(self): + for dimension in [ + Dimension.SOUL, + Dimension.VOICE, + Dimension.SKILL, + Dimension.BOUNDARIES, + ]: + assert dimension in CORE_TRUTH_DIMENSIONS + + def test_is_eligible_for_state_anchor_returns_false(self, sample_state_anchor): + assert is_eligible_for_core_truths(sample_state_anchor) is False + + def test_is_eligible_for_soul_anchor_returns_true(self, sample_soul_anchor): + assert is_eligible_for_core_truths(sample_soul_anchor) is True + + def test_filter_drops_state_anchors(self, sample_state_anchor, sample_soul_anchor): + result = filter_core_truth_candidates([sample_state_anchor, sample_soul_anchor]) + + assert sample_state_anchor not in result + assert sample_soul_anchor in result + + +async def test_state_anchor_not_in_soul_md_core_truths(tmp_path): + """STATE source anchors must not render as SOUL.md Core Truths.""" + db = await _new_db(tmp_path) + await db.save_anchor( + "u1", + Dimension.STATE, + Layer.PRINCIPLE, + "最近很累", + [1, 2, 3], + ["Q1", "Q2", "Q3"], + ) + await db.save_anchor( + "u1", + Dimension.SOUL, + Layer.PRINCIPLE, + "values direct truth under delivery pressure", + [4, 5, 6], + ["Q4", "Q5", "Q6"], + ) + + await export_markdown(db, "u1", tmp_path / "exports") + soul_text = (tmp_path / "exports" / "u1" / "SOUL.md").read_text(encoding="utf-8") + core_truths = _section(soul_text, "## Core Truths", "## Emerging Patterns") + + assert "values direct truth under delivery pressure" in core_truths + assert "最近很累" not in core_truths + + +async def test_state_dimension_still_exported(tmp_path): + """STATE.md should still be exported as the current-state snapshot.""" + db = await _new_db(tmp_path) + await db.save_anchor( + "u1", + Dimension.STATE, + Layer.PRINCIPLE, + "最近很累", + [1, 2, 3], + ["Q1", "Q2", "Q3"], + ) + + paths = await export_markdown(db, "u1", tmp_path / "exports") + state_path = tmp_path / "exports" / "u1" / "STATE.md" + + assert "STATE.md" in {path.name for path in paths} + assert state_path.exists() + assert "最近很累" in state_path.read_text(encoding="utf-8") + + +@pytest.fixture +def sample_state_anchor(): + return Anchor( + interviewee_id="u1", + dimension=Dimension.STATE, + layer=Layer.FACT, + content="最近很累", + source_turn_ids=[1], + source_question_ids=["Q1"], + ) + + +@pytest.fixture +def sample_soul_anchor(): + return Anchor( + interviewee_id="u1", + dimension=Dimension.SOUL, + layer=Layer.PRINCIPLE, + content="values direct truth under delivery pressure", + triangulated=True, + source_turn_ids=[2, 3, 4], + source_question_ids=["Q2", "Q3", "Q4"], + ) + + +async def _new_db(tmp_path) -> DB: + db = DB(str(tmp_path / "virtualme.db")) + await db.init() + return db + + +def _section(text: str, start: str, end: str) -> str: + return text.split(start, 1)[1].split(end, 1)[0] diff --git a/tests/unit/test_p3_reflective_restraint.py b/tests/unit/test_p3_reflective_restraint.py new file mode 100644 index 0000000..94996dd --- /dev/null +++ b/tests/unit/test_p3_reflective_restraint.py @@ -0,0 +1,103 @@ +"""P3 Reflective Restraint hard gate - Constitution v1.1 §P3.""" + +from virtualme.interview.guardrail import Guardrail +from virtualme.interview.turn_reasoner_schema import ( + BoundaryStatus, + EngagementState, + NextMove, + TurnReasonerOutput, +) + + +def _output(**overrides): + """Build a baseline TurnReasonerOutput for tests.""" + defaults = dict( + read="test", + boundary_status=BoundaryStatus.NONE, + engagement_state=EngagementState.ENGAGED, + next_move=NextMove.ADVANCE, + next_question_id="q1", + should_echo=False, + echo_content=None, + reflection_note=None, + reply="ok", + ) + defaults.update(overrides) + return TurnReasonerOutput(**defaults) + + +def test_explicit_refusal_sets_skip_stop_reason(): + """違規 -> reply 不能解讀; HONOR_SKIP + skip_stop_reason=refusal.""" + g = Guardrail() + out = g.apply( + _output( + boundary_status=BoundaryStatus.EXPLICIT_REFUSAL, + next_move=NextMove.PROBE, + ), + current_probe_count=0, + ) + assert out.next_move == NextMove.HONOR_SKIP + assert out.skip_stop_reason == "refusal" + + +def test_strong_reluctance_with_probe_softens_with_reason(): + g = Guardrail() + out = g.apply( + _output( + boundary_status=BoundaryStatus.STRONG_RELUCTANCE, + next_move=NextMove.PROBE, + ), + current_probe_count=0, + ) + assert out.next_move == NextMove.SOFTEN + assert out.skip_stop_reason == "reluctance" + + +def test_fatigued_state_with_probe_softens_with_reason(): + g = Guardrail() + out = g.apply( + _output( + engagement_state=EngagementState.FATIGUED, + next_move=NextMove.PROBE, + ), + current_probe_count=0, + ) + assert out.next_move == NextMove.SOFTEN + assert out.skip_stop_reason == "fatigue" + + +def test_probe_cap_reached_advances_with_reason(): + g = Guardrail(max_probes_per_question=2) + out = g.apply( + _output(next_move=NextMove.PROBE), + current_probe_count=2, + ) + assert out.next_move == NextMove.ADVANCE + assert out.skip_stop_reason == "probe_cap_reached" + + +def test_no_fork_keeps_default_reason(): + g = Guardrail() + out = g.apply( + _output(next_move=NextMove.ADVANCE), + current_probe_count=0, + ) + assert out.next_move == NextMove.ADVANCE + assert out.skip_stop_reason == "none" + + +def test_reflection_note_not_leaked_into_reply(): + """P3 §M1 bullet 3: reflection_note defaults to internal-only. + + This test confirms Guardrail does not concatenate reflection_note into reply. + """ + g = Guardrail() + out = g.apply( + _output( + reflection_note="使用者顯示 X 心理 pattern (內部 audit only)", + reply="好的我們繼續下一題。", + ), + current_probe_count=0, + ) + assert "心理 pattern" not in out.reply + assert out.reflection_note is not None diff --git a/tests/unit/test_p4_multi_session_validation.py b/tests/unit/test_p4_multi_session_validation.py new file mode 100644 index 0000000..415d8ce --- /dev/null +++ b/tests/unit/test_p4_multi_session_validation.py @@ -0,0 +1,129 @@ +"""P4 Multi-Session Validation hard gate -- Constitution v1.1 §P4. + +Three categories: +1. unique_session_count / is_single_session unit tests +2. can_be_validated negative constraint +3. Gap-surfacing regression: save_anchor PRINCIPLE with 3 question_ids in + single session creates triangulated=True, but can_be_validated=False + (documents the M1->M2 gap) +""" + +import pytest + +from virtualme.snapshot.multi_session_validator import ( + can_be_validated, + is_single_session, + unique_session_count, +) +from virtualme.storage.db import DB, Dimension, Layer + + +async def _new_db(tmp_path) -> DB: + db = DB(str(tmp_path / "virtualme.db")) + await db.init() + return db + + +# === Category 1: unique_session_count / is_single_session === + + +@pytest.mark.asyncio +async def test_empty_turn_ids_returns_zero_sessions(tmp_path): + db = await _new_db(tmp_path) + assert await unique_session_count(db, []) == 0 + + +@pytest.mark.asyncio +async def test_three_turns_same_session_count_one(tmp_path): + db = await _new_db(tmp_path) + sess = await db.get_or_create_session("u1", 1) + t1 = await db.save_turn(sess.id, "user", "msg1") + t2 = await db.save_turn(sess.id, "user", "msg2") + t3 = await db.save_turn(sess.id, "user", "msg3") + count = await unique_session_count(db, [t1.id, t2.id, t3.id]) + assert count == 1 + + +@pytest.mark.asyncio +async def test_turns_across_two_sessions_count_two(tmp_path): + db = await _new_db(tmp_path) + s1 = await db.get_or_create_session("u1", 1) + s2 = await db.get_or_create_session("u1", 2) + t1 = await db.save_turn(s1.id, "user", "a") + t2 = await db.save_turn(s2.id, "user", "b") + assert await unique_session_count(db, [t1.id, t2.id]) == 2 + + +@pytest.mark.asyncio +async def test_is_single_session_for_same_session(tmp_path): + db = await _new_db(tmp_path) + sess = await db.get_or_create_session("u1", 1) + t1 = await db.save_turn(sess.id, "user", "a") + t2 = await db.save_turn(sess.id, "user", "b") + anchor = await db.save_anchor( + "u1", Dimension.SOUL, Layer.PRINCIPLE, "c", [t1.id, t2.id], ["Q1", "Q2"] + ) + assert await is_single_session(db, anchor) is True + + +@pytest.mark.asyncio +async def test_is_single_session_for_cross_session(tmp_path): + db = await _new_db(tmp_path) + s1 = await db.get_or_create_session("u1", 1) + s2 = await db.get_or_create_session("u1", 2) + t1 = await db.save_turn(s1.id, "user", "a") + t2 = await db.save_turn(s2.id, "user", "b") + anchor = await db.save_anchor( + "u1", Dimension.SOUL, Layer.PRINCIPLE, "c", [t1.id, t2.id], ["Q1", "Q2"] + ) + assert await is_single_session(db, anchor) is False + + +# === Category 2: can_be_validated === + + +@pytest.mark.asyncio +async def test_single_session_anchor_cannot_be_validated(tmp_path): + db = await _new_db(tmp_path) + sess = await db.get_or_create_session("u1", 1) + t = await db.save_turn(sess.id, "user", "msg") + anchor = await db.save_anchor( + "u1", Dimension.SOUL, Layer.PRINCIPLE, "x", [t.id], ["Q1"] + ) + assert await can_be_validated(db, anchor) is False + + +@pytest.mark.asyncio +async def test_cross_session_anchor_can_be_validated(tmp_path): + db = await _new_db(tmp_path) + s1 = await db.get_or_create_session("u1", 1) + s2 = await db.get_or_create_session("u1", 2) + t1 = await db.save_turn(s1.id, "user", "a") + t2 = await db.save_turn(s2.id, "user", "b") + anchor = await db.save_anchor( + "u1", Dimension.SOUL, Layer.PRINCIPLE, "c", [t1.id, t2.id], ["Q1", "Q2"] + ) + assert await can_be_validated(db, anchor) is True + + +# === Category 3: Gap-surfacing regression === + + +@pytest.mark.asyncio +async def test_single_session_triangulated_anchor_still_cannot_validated(tmp_path): + """P4 M1 contract: triangulated != validated for same-session evidence.""" + db = await _new_db(tmp_path) + sess = await db.get_or_create_session("u1", 1) + t1 = await db.save_turn(sess.id, "user", "a") + t2 = await db.save_turn(sess.id, "user", "b") + t3 = await db.save_turn(sess.id, "user", "c") + anchor = await db.save_anchor( + "u1", + Dimension.SOUL, + Layer.PRINCIPLE, + "I value direct truth over peace", + [t1.id, t2.id, t3.id], + ["Q1", "Q2", "Q3"], + ) + assert anchor.triangulated is True + assert await can_be_validated(db, anchor) is False diff --git a/tests/unit/test_p5_self_correction_agency.py b/tests/unit/test_p5_self_correction_agency.py new file mode 100644 index 0000000..7bd424d --- /dev/null +++ b/tests/unit/test_p5_self_correction_agency.py @@ -0,0 +1,145 @@ +"""P5 Self-Correction & Agency hard gate — Constitution v1.1 §P5.""" + +from __future__ import annotations + +from typing import get_args + +from virtualme.snapshot.core import ( + ConstructCard, + ConstructCardReview, + EvidenceItem, + ReviewVerdict, + SnapshotBundle, + apply_construct_card_reviews, +) +from virtualme.snapshot.hedge_validator import ( + find_unhedged_assertions, + has_hedge_marker, +) +from virtualme.storage.db import Dimension, Layer + + +class TestHedgeValidator: + def test_rejects_unhedged_english_assertion(self): + text = "You are an introvert." + violations = find_unhedged_assertions(text) + assert len(violations) == 1 + assert "You are" in violations[0].matched_text + + def test_rejects_unhedged_chinese_assertion(self): + text = "你是內向的人。" + violations = find_unhedged_assertions(text) + assert len(violations) >= 1 + + def test_rejects_essentialist_phrasing(self): + text = "Your true self is risk-averse." + violations = find_unhedged_assertions(text) + assert len(violations) >= 1 + + def test_accepts_hedged_phrasing_chinese(self): + text = "目前觀察到你在 W2-W5 多次提到風險意識。" + violations = find_unhedged_assertions(text) + assert violations == [] + assert has_hedge_marker(text) + + def test_accepts_hedged_phrasing_english(self): + text = "Tentative observation: you tend to favor caution in financial decisions (W2)." + violations = find_unhedged_assertions(text) + assert violations == [] + assert has_hedge_marker(text) + + def test_multiline_violation_tracking(self): + text = "First line: hedged 目前觀察到 X.\nSecond line: You are bold." + violations = find_unhedged_assertions(text) + assert len(violations) == 1 + assert violations[0].line_number == 2 + + +def _reviewed_card(verdict: str) -> ConstructCard: + evidence = EvidenceItem( + kind="anchor", + dimension=Dimension.SOUL, + layer=Layer.PRINCIPLE, + content="chooses careful tradeoffs under pressure", + source_anchor_ids=[1, 2], + source_turn_ids=[10, 11], + source_question_ids=["Q1", "Q2"], + confidence=0.9, + ) + card = ConstructCard( + id="C1", + title="Careful Tradeoffs", + decision_rule="protect downside risk by choosing reversible options", + trigger_context="high-uncertainty decisions", + protected_value="downside risk", + traded_value="speed", + default_action="choose reversible options", + refused_action="commit before evidence appears", + exception_rule="can move faster when rollback is cheap", + register=None, + falsifier="Would repeatedly choose irreversible speed over risk control.", + supporting_evidence=[evidence], + disconfirming_evidence=[], + source_anchor_ids=[1, 2], + source_turn_ids=[10, 11], + source_question_ids=["Q1", "Q2"], + dimension_tags=[Dimension.SOUL], + confidence_level="validated", + confidence_reason="multi-session behavioral support", + confidence_checks={"multi_anchor_support": True, "human_reviewed": False}, + missing_evidence=[], + blind_test_probe=None, + feedback_routes=["review C1"], + extraction_method="rule_based", + policy_status="validated", + stability_scope="multi-session", + context_dependence="under pressure", + exception_archetype=None, + ) + review = ConstructCardReview( + card_id="C1", + verdict=verdict, + reviewer="Maki", + notes="This does not match me.", + counterexample_note="I often choose speed in this context.", + evidence_quality="high", + ) + bundle = SnapshotBundle( + interviewee_id="u1", + generated_at="2026-05-20T00:00:00+00:00", + construct_cards=[card], + hypotheses=[], + mini_blind_test=[], + feedback_routes=[], + ) + return apply_construct_card_reviews(bundle, [review]).construct_cards[0] + + +def test_unlike_me_review_sets_confidence_insufficient(): + """P5: unlike_me blocks promotion even after prior validated status.""" + card = _reviewed_card("unlike_me") + assert card.confidence_level == "insufficient" + assert card.confidence_checks["human_confirmed"] is False + assert card.disconfirming_evidence[-1].kind == "human_review" + + +def test_unlike_me_review_sets_policy_contradicted(): + """P5: unlike_me review sets policy_status to contradicted.""" + card = _reviewed_card("unlike_me") + assert card.policy_status == "contradicted" + + +def test_restart_commands_present(): + """Smoke test: restart_interview / restart_dimension entrypoints still exist.""" + from virtualme.interview import commands + from virtualme.storage.db import DB + + assert hasattr(DB, "restart_interview") + assert hasattr(DB, "restart_dimension") + assert hasattr(commands, "format_restart_reply") + + +def test_construct_card_review_verdicts_exist(): + """Smoke test: ReviewVerdict keeps all review states.""" + verdicts = set(get_args(ReviewVerdict)) + assert {"like_me", "unlike_me", "unsure", "missing_context"} <= verdicts diff --git a/tests/unit/test_persona_export_request.py b/tests/unit/test_persona_export_request.py new file mode 100644 index 0000000..7763dd6 --- /dev/null +++ b/tests/unit/test_persona_export_request.py @@ -0,0 +1,71 @@ +from datetime import datetime +from zoneinfo import ZoneInfo + +from virtualme.export.persona_package import render_export_note +from virtualme.interview.progress_card import ( + calculate_weighted_completion, + render_8x3_progress_card, +) +from virtualme.interview.turn_state import ( + CoverageSnapshot, + DimensionProgress, + LayerProgress, +) +from virtualme.storage.db import Dimension, Layer + + +def _snapshot(score: float) -> CoverageSnapshot: + return CoverageSnapshot( + per_dimension={ + dimension: DimensionProgress( + dimension=dimension, + layers={ + Layer.FACT: LayerProgress(quality_score=score), + Layer.PATTERN: LayerProgress(quality_score=score), + Layer.PRINCIPLE: LayerProgress(quality_score=score), + }, + ) + for dimension in Dimension + } + ) + + +def test_weighted_completion_boundaries(): + assert calculate_weighted_completion(CoverageSnapshot()) == 0 + assert calculate_weighted_completion(_snapshot(0.0)) == 0 + assert calculate_weighted_completion(_snapshot(1.0)) == 100 + + +def test_weighted_completion_applies_deep_middle_shallow_weights(): + snapshot = CoverageSnapshot( + per_dimension={ + dimension: DimensionProgress( + dimension=dimension, + layers={ + Layer.FACT: LayerProgress(quality_score=1.0), + Layer.PATTERN: LayerProgress(quality_score=0.0), + Layer.PRINCIPLE: LayerProgress(quality_score=1.0), + }, + ) + for dimension in Dimension + } + ) + + assert calculate_weighted_completion(snapshot) == 65 + + +def test_progress_card_header_and_layers(): + card = render_8x3_progress_card(_snapshot(0.8)) + + assert card.startswith("VirtualMe 訪談機器人 【目前訪談收集進度(八維 × 三層)】") # noqa: RUF001 + assert "聲音/表達" in card + assert "淺層:●●● 中層:●●● 深層:●●●" in card + + +def test_export_note_contains_required_sections(): + note = render_export_note(_snapshot(0.0), datetime(2026, 5, 20, tzinfo=ZoneInfo("Asia/Taipei"))) + + assert "匯出時間" in note + assert "目前 8 維 × 3 層覆蓋情況" in note # noqa: RUF001 + assert "較弱維度提醒" in note + assert "這是階段性版本,隨著訪談繼續會越來越成熟" in note # noqa: RUF001 diff --git a/tests/unit/test_schema_migrations.py b/tests/unit/test_schema_migrations.py index d24b857..26534e4 100644 --- a/tests/unit/test_schema_migrations.py +++ b/tests/unit/test_schema_migrations.py @@ -200,3 +200,17 @@ async def test_migration_adds_current_question_id_to_sessions(tmp_path): await conn.commit() assert await _column_exists(str(db), "sessions", "current_question_id") + + +async def test_migration_creates_persona_download_tables(tmp_path): + db = tmp_path / "download-tables.db" + async with aiosqlite.connect(str(db)) as conn: + await _apply_schema_migrations(conn) + await conn.commit() + cursor = await conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name IN " + "('persona_download_tokens', 'persona_download_logs')" + ) + tables = {row[0] for row in await cursor.fetchall()} + + assert tables == {"persona_download_tokens", "persona_download_logs"} diff --git a/tests/unit/test_turn_reasoner_prompt.py b/tests/unit/test_turn_reasoner_prompt.py new file mode 100644 index 0000000..185a963 --- /dev/null +++ b/tests/unit/test_turn_reasoner_prompt.py @@ -0,0 +1,12 @@ +from virtualme.interview.turn_reasoner import BASELINE_SYSTEM_PROMPT, load_system_prompt + + +def test_load_system_prompt_defaults_to_public_baseline(): + assert load_system_prompt() == BASELINE_SYSTEM_PROMPT + + +def test_load_system_prompt_reads_private_prompt_file(tmp_path): + prompt = tmp_path / "prompt.txt" + prompt.write_text("private prompt\n", encoding="utf-8") + + assert load_system_prompt(str(prompt)) == "private prompt" diff --git a/tests/unit/test_turn_state.py b/tests/unit/test_turn_state.py new file mode 100644 index 0000000..2bf7ee6 --- /dev/null +++ b/tests/unit/test_turn_state.py @@ -0,0 +1,210 @@ +"""Unit tests for L1 TurnState assembly (read-only snapshot). + +Covers: +- Basic construction from populated DB state +- restart path (fresh week-1 session, probe=0, candidates start from first) +- retalk path (current pinned to dimension's first question) +- light-greeting / resume path (current may be unset -> resolves to default) +- frozen immutability +- goal fallback vs explicit subject.goal +- candidate filtering (excludes asked) +""" + +import tempfile +from pathlib import Path + +import pytest + +from virtualme.interview.question_selector import QuestionSelector, load_question_pool +from virtualme.interview.turn_state import ( + DEFAULT_GOAL, + TurnState, + build_turn_state, +) +from virtualme.storage.db import DB, Dimension, SubjectDomain + + +@pytest.fixture +def tmp_db_path() -> Path: + """Temporary SQLite file for each test (auto-cleaned).""" + with tempfile.TemporaryDirectory() as tmpdir: + yield Path(tmpdir) / "virtualme-test.db" + + +@pytest.fixture +async def fresh_db(tmp_db_path: Path) -> DB: + """Initialized DB with schema.""" + db = DB(str(tmp_db_path)) + await db.init() + return db + + +@pytest.fixture +def selector() -> QuestionSelector: + """Real question pool (from src/virtualme/data/question-pool.yaml).""" + pool = load_question_pool() + assert pool, "question pool must not be empty for tests" + return QuestionSelector(pool) + + +async def _setup_minimal_session( + db: DB, + interviewee_id: str = "u-test-turnstate", + week: int = 1, + goal: str | None = None, +) -> tuple: + """Create subject + session. Return (session, first_q_of_week).""" + subject = await db.get_or_create_subject( + interviewee_id, goal=goal, domain=SubjectDomain.HR_HRBP + ) + session = await db.get_or_create_session(interviewee_id, week=week) + # pick first question of the week as "current" + first_q = QuestionSelector(load_question_pool()).question_pool.get( + week, [None] + )[0] + if first_q: + await db.set_current_question_id(session.id, first_q.id) + await db.record_question_asked(interviewee_id, first_q.id, week) + return session, first_q, subject + + +@pytest.mark.asyncio +async def test_build_basic_fields_populated(fresh_db: DB, selector: QuestionSelector): + """Happy path: all core fields present and types correct.""" + session, current_q, _ = await _setup_minimal_session(fresh_db, week=1) + + # add a bit of history and one anchor + await fresh_db.save_turn(session.id, "user", "我最近工作很忙") + await fresh_db.save_turn(session.id, "assistant", "那你覺得忙在哪裡?") + # fake anchor + await fresh_db.save_anchor( + "u-test-turnstate", + Dimension.VOICE, + "fact", + "工作很忙", + source_turn_ids=[1], + source_question_ids=[current_q.id if current_q else "Q1"], + ) + + state = await build_turn_state( + "u-test-turnstate", fresh_db, selector, session, adaptive=False + ) + + assert isinstance(state, TurnState) + assert state.goal == DEFAULT_GOAL # no explicit goal passed + assert state.current_question.id == current_q.id if current_q else "STATE-OPEN" + assert state.last_prompt_text is None or isinstance(state.last_prompt_text, str) + assert isinstance(state.recent_history, list) + assert len(state.recent_history) >= 2 + assert Dimension.VOICE in state.anchors_summary + assert isinstance(state.coverage_gaps, dict) + assert state.probe_count == 0 + assert isinstance(state.candidate_questions, list) + assert len(state.candidate_questions) >= 1 + + +@pytest.mark.asyncio +async def test_build_goal_from_subject(fresh_db: DB, selector: QuestionSelector): + """Explicit subject.goal should override DEFAULT_GOAL.""" + session, _, _ = await _setup_minimal_session( + fresh_db, goal="專門萃取工程師的技術決策模式" + ) + + state = await build_turn_state( + "u-test-turnstate", fresh_db, selector, session, adaptive=False + ) + assert state.goal == "專門萃取工程師的技術決策模式" + + +@pytest.mark.asyncio +async def test_build_restart_path(fresh_db: DB, selector: QuestionSelector): + """After restart: new session week=1, first question, probe=0, candidates include first of week.""" + # simulate restart flow (see bot._handle_restart) + session, first_q, _ = await _setup_minimal_session(fresh_db, week=1) + + state = await build_turn_state( + "u-test-turnstate", fresh_db, selector, session, adaptive=False + ) + + assert state.current_question.week == 1 + assert state.probe_count == 0 + # at least the first of week 1 should be in candidates (or all if none excluded) + ids = {q.id for q in state.candidate_questions} + assert first_q.id in ids or len(state.candidate_questions) > 0 + + +@pytest.mark.asyncio +async def test_build_retalk_path(fresh_db: DB, selector: QuestionSelector): + """Retalk pins current to a dimension's first question; build reflects it.""" + session, _, _ = await _setup_minimal_session(fresh_db, week=2) + + # pick a dimension that has questions in the pool (VOICE or STATE usually does) + target_dim = Dimension.VOICE + dim_questions = [ + q for q in selector.question_pool.get(2, []) if q.dimension == target_dim + ] or [q for qs in selector.question_pool.values() for q in qs if q.dimension == target_dim] + if not dim_questions: + dim_questions = [q for qs in selector.question_pool.values() for q in qs] + + target_q = dim_questions[0] + await fresh_db.set_current_question_id(session.id, target_q.id) + await fresh_db.record_question_asked("u-test-turnstate", target_q.id, 2) + + state = await build_turn_state( + "u-test-turnstate", fresh_db, selector, session, adaptive=False + ) + + assert state.current_question.id == target_q.id + assert state.current_question.dimension == target_q.dimension + + +@pytest.mark.asyncio +async def test_build_light_greeting_unset_current(fresh_db: DB, selector: QuestionSelector): + """Light-greeting path before set_current: build still succeeds using default resolution.""" + # create session but do NOT set current_question_id + session = await fresh_db.get_or_create_session("u-test-turnstate", week=1) + + state = await build_turn_state( + "u-test-turnstate", fresh_db, selector, session, adaptive=False + ) + + # should resolve to some question (default of week 1) + assert state.current_question is not None + assert state.current_question.week == 1 + # probe may be 0, candidates non-empty + assert state.probe_count >= 0 + assert len(state.candidate_questions) >= 1 + + +@pytest.mark.asyncio +async def test_turn_state_is_frozen(fresh_db: DB, selector: QuestionSelector): + """TurnState must be immutable (frozen pydantic model).""" + session, _, _ = await _setup_minimal_session(fresh_db, week=1) + state = await build_turn_state( + "u-test-turnstate", fresh_db, selector, session, adaptive=False + ) + + with pytest.raises((AttributeError, TypeError, ValueError)): + state.probe_count = 99 # frozen + + +@pytest.mark.asyncio +async def test_build_filters_candidates_by_asked(fresh_db: DB, selector: QuestionSelector): + """candidate_questions should exclude questions with asked_count > 0 (except current edge).""" + session, _first_q, _ = await _setup_minimal_session(fresh_db, week=1) + + # mark one more as asked + all_q = [q for qs in selector.question_pool.values() for q in qs] + if len(all_q) > 1: + second = all_q[1] + await fresh_db.record_question_asked("u-test-turnstate", second.id, 1) + + state = await build_turn_state( + "u-test-turnstate", fresh_db, selector, session, adaptive=False + ) + + asked_ids = await fresh_db.load_asked_question_ids("u-test-turnstate") + for cand in state.candidate_questions: + # current may still be present; others should not be re-listed if asked + if cand.id in asked_ids and cand.id != state.current_question.id: + pytest.fail(f"asked question {cand.id} leaked into candidates")