diff --git a/.github/workflows/plugin-api-v3.yml b/.github/workflows/plugin-api-v3.yml index 7139e00..b4ae1de 100644 --- a/.github/workflows/plugin-api-v3.yml +++ b/.github/workflows/plugin-api-v3.yml @@ -36,11 +36,13 @@ jobs: - uses: actions/checkout@v4 with: repository: kachofugetsu09/akashic-agent - # 5624 lacks the v3 domain-effect lookup export. Keep this exact - # Core commit until it is published; checkout failure is intentional - # release blocking, not permission to weaken the plugin oracle. - ref: 3005f838bcd96e2cbc58616aede46e4f39df4523 + ref: 9da3a988a2bf62b0f550bd4f6bb98c4eeb1f56f5 path: .akashic-core + - uses: actions/checkout@v4 + with: + repository: akashic-plugins/proactive_feedback + ref: 531eae4e4ac4714aad5417b8257a724007728345 + path: .proactive-feedback - uses: actions/setup-python@v5 with: python-version: "3.13" @@ -53,6 +55,7 @@ jobs: - name: Run focused plugin tests env: AKASHIC_AGENT_ROOT: .akashic-core + AKASHIC_PROACTIVE_FEEDBACK_ROOT: .proactive-feedback PYTHONPATH: .akashic-core run: python -m pytest -q tests - uses: actions/setup-node@v4 @@ -64,8 +67,12 @@ jobs: env: AKASHIC_AGENT_ROOT: .akashic-core PYTHONPATH: .akashic-core - run: pyright --level error plugin.py dashboard.py db.py tests + run: >- + pyright --level error plugin.py runtime.py feedback_history.py + dashboard.py db.py tests - name: Compile Python sources - run: python -m compileall -q plugin.py dashboard.py db.py drift tests + run: >- + python -m compileall -q plugin.py runtime.py feedback_history.py + dashboard.py db.py drift tests - name: Check diff formatting run: git diff --check diff --git a/README.md b/README.md index 03227fb..c42ec5b 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,85 @@ # emotion -Akashic emotion and proactive tuning plugin. - -## v3 接入 - -入口是 module-level `api_version = 3` 与 `apply(ctx, config)`。Emotion 通过 Core -声明以下能力: - -- `PROACTIVE_COMPONENTS`:在 exact generation 中形成 VAD prompt projection;formal - 运行由 `emotion.state` domain effect 提交 SQLite,candidate 不打开数据库。 -- `BACKGROUND_JOBS`:`feedback-preference-context` Drift 完成后,使用 Core 的 LLM - lease 和窄 documents port 合并 `PROACTIVE_CONTEXT.md` / `proactive_pending.md`。 -- `AFTER_TURN_COMMITTED`:消费 Core 已提交的 typed Turn。上游若提供 - `extra.proactive_feedback`,按其稳定 identity 幂等写入;显式引用消息则按 Turn - 自带标记写入 gold feedback。 -- `UI_SLOTS` 与 C09 Dashboard:移动端和桌面端只读 Emotion 自有投影,不读取 - `sessions.db`,不取得任意 workspace 句柄。 - -插件不再声明 v2 `Plugin`、EventBus listener、固定 `proactive_modules()` / `jobs()` -或旧 mobile/dashboard ABI。旧数据库不会在 import/apply 时自动迁移;切换前应先 -停用旧 runtime 并使用独立迁移脚本(尚未将旧源删除)。 - -CI 的 Core pin 是 20062a715d2c5822228b327863b51c8d036119b3,因为旧 pin -5624a059348406c1f97993612adfec886b158158 没有 domain_effect_lookup_export。 -该 commit 尚未发布到 Core 的公共默认分支前,CI checkout 失败属于明确的发布阻塞; -本插件必须继续在 integration Core exact worktree 上验证,不得删除 lookup seam 或放宽 -candidate/formal oracle。 - -## 移动端看板 - -插件通过通用移动 UI 生命周期注册“主动状态”入口,说明用户反馈如何改变 Agent 的语气 -与主动发送把握。移动端只列真正产生状态增量的反馈,不复制桌面端每个 proactive tick -的 effect 表;原始 VAD 指标默认折叠,需要时再查看。 +Akashic 的 Emotion 状态、反馈历史和主动偏好插件。 + +## v3 普通能力组合 + +Emotion 不拥有 React,也不是 proactive 特权模块。它只组合 Core 已有原子: + +```text +RUNTIME_STARTED + └─ TIMERS ──刷新──> emotion_context_current(可覆盖 current) + └────> drift.proposals.v1(普通 proposal) + +CONTEXT_PREPARED(channel=wake) + └─ fresh current ──append──> extra_hints + +Wake Turn 选择 proposal + ├─ 普通 tool ──事务提交──> 完整 drift result + current preference + cursor + └─ AFTER_TURN_COMMITTED ──对账──> selected Turn / missing-commit revision + +proactive-feedback.history.v1(可选) + └─ TIMERS ──pull page──> Emotion observation/sample + PF cursor(同一事务) +``` + +插件声明 `TIMERS`、`TOOL_CATALOG`、`UI_SLOTS`、`drift.proposals.v1` 和 +`drift.wake.v1`。candidate 没有 `RUNTIME_STARTED`,因此不会登记 Timer、打开 +Emotion DB 或产生 formal 写入。formal generation 的 current refresh 和可选 feedback +pull 各有一条职责独立的 Fiber-owned one-shot Timer 链;PF service 缺席时 pull child +保持 pending。reload 会先停止旧 child,再从 Emotion 自有 cursor 启动新 child。 + +当前 hint 只在 `channel=wake` 且刷新时间不超过 10 分钟时追加。它不 abort、 +不 replace,也不污染 passive Turn。外部暂态 `OSError` 会记录 incident 并等待 +下一 Timer;配置、schema、Content/Tool 合同错误保持 fail-loud。 + +## 事实 owner 与保留 + +| 事实 | owner | 正常语义 | +|---|---|---| +| `emotion_events` | Emotion | 真实情绪观察历史,全量追加保留 | +| `emotion_feedback_samples` | Emotion | 真实主动反馈样本,全量追加保留 | +| `emotion_drift_runs` | Emotion | 每个 proposal revision、选择和结果,全量追加保留 | +| `emotion_state` | Emotion | 当前 VAD singleton,可原位覆盖 | +| `emotion_context_current` | Emotion | 当前 VAD/presence/hint,可原位覆盖 | +| `emotion_preference_state` | Emotion | 当前偏好与已提交 cursor,可原位覆盖 | +| `pf_history_cursor` | Emotion | 已原子应用的 PF history current cursor,可原位覆盖 | +| `emotion_effects` | legacy Emotion | 冻结保留,不再新增、不删除 | +| `emotion_domain_effects` | legacy Emotion | 冻结保留,不再新增、不删除 | + +空 tick 只允许刷新 current singleton,不创建 proposal/history。反馈批次先冻结进 +`emotion_drift_runs`,普通 Drift 接受后由 `emotion_commit_preference_context` +在一个 SQLite transaction 中保存完整结果、替换 current preference、推进 cursor。 +Turn 已提交但工具没提交时,该 revision 记为 `completed_without_commit`;下次为 +同一批证据生成新 revision。重复提交相同结果幂等,identity 漂移会明确失败。 + +PF accepted history 由 Feedback 插件全量持有;Emotion 的 event/sample 是“这条反馈已被 +Emotion 接纳、改变了什么、是否进入 Drift evidence”的独立应用账本。每页 observation、 +可选 sample、VAD current 和 `pf_history_cursor` 同事务提交。普通非引用反馈在 PF accepted +前不会进入 Emotion,下一 Timer 后恰好应用一次。显式引用由 Emotion direct rule 立即 +应用;PF 后续同 user message receipt 仍追加零 delta terminal 并推进 cursor/hash,但不再 +增加 VAD 或重复写 Drift sample。跨插件边界会按 PF v1 完整稳定字段(包括 `session_key`) +重算 canonical JSON SHA-256;字段、类型、有限数值或 hash 漂移都会在 cursor 前 fail-loud。 +旧 Emotion 已应用但没有 hash 的 `proactive_feedback:` 事实保持原样;identity 相符时另 +追加 `pf_history_import:` 零 delta/零 sample terminal receipt,再同事务推进 cursor, +identity 冲突则整页回滚。 + +## 旧 proactive island 交接 + +旧版本曾参与两个 workspace 根文件,但它们是 proactive island 的共享事实,不归 +Emotion 独占: + +- `PROACTIVE_CONTEXT.md`:旧 Core `ProactiveDocuments` 读取并成对替换的当前主动规则。 +- `proactive_pending.md`:旧 Drift skill 追加、旧 merge job 清空的共享候选队列。 + +本版本不声明 `workspace_files`,不读取、写入或清空这两个文件,也不保留 +`PROACTIVE_COMPONENTS`、`BACKGROUND_JOBS`、`DRIFT_FINISHED` 或 private proactive +module。最终 Core island archive PR 应对旧文件做只读归档 receipt,记录完整 bytes、 +SHA-256、原路径和时间后再 supersede;原文件不由 Emotion 删除。 + +## 验证边界 + +测试使用真实 Core DriftStore、Plugin Timer/Tool/UI composition 与隔离 SQLite,覆盖 +candidate 零副作用、fresh wake hint、被动链零污染、proposal 重放、未提交重提、 +工具幂等提交、TurnCommitted selection 时序、PF 双顺序组合、分页事务、显式引用单计数、 +暂态 Timer 重臂和 reload 单 Timer。 +这些是隔离 E2E fixture,不声称 hua-home formal activation 或线上 provider E2E。 diff --git a/db.py b/db.py index 7e9288d..3e20679 100644 --- a/db.py +++ b/db.py @@ -1,8 +1,10 @@ from __future__ import annotations +import hashlib import json import math import sqlite3 +from collections.abc import Mapping from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path @@ -56,36 +58,23 @@ class EmotionBehavior: expected_effect: str -@dataclass(frozen=True) -class EmotionDomainEffect: - """表示一次已由 Emotion SQLite 提交的幂等领域效果。""" - - semantic_job_id: str - event_id: str - invocation_id: str - effect_id: str - idempotency_key: str - attempt: int - result_digest: str - - -def open_db(path: Path) -> sqlite3.Connection: - path.parent.mkdir(parents=True, exist_ok=True) - conn = sqlite3.connect(path) - conn.row_factory = sqlite3.Row - _ = conn.execute("PRAGMA journal_mode = WAL") - _ = conn.execute("PRAGMA synchronous = NORMAL") - _ = conn.executescript( - """ - CREATE TABLE IF NOT EXISTS emotion_state ( +_SCHEMA_VERSION = 2 +_PF_FEEDBACK_TYPES = frozenset( + {"explicit_quote", "topic_follow", "no_topic_follow", "unscored"} +) +_PF_CONFIDENCE = frozenset({"gold", "high", "medium", "low"}) +_TABLE_SQL = { + "emotion_state": """ + CREATE TABLE emotion_state ( id INTEGER PRIMARY KEY CHECK (id = 1), valence REAL NOT NULL, arousal REAL NOT NULL, dominance REAL NOT NULL, updated_at TEXT NOT NULL - ); - - CREATE TABLE IF NOT EXISTS emotion_events ( + ) + """, + "emotion_events": """ + CREATE TABLE emotion_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at TEXT NOT NULL DEFAULT (datetime('now')), source_plugin TEXT NOT NULL, @@ -103,9 +92,10 @@ def open_db(path: Path) -> sqlite3.Connection: dominance_after REAL NOT NULL, reason TEXT NOT NULL, payload_json TEXT NOT NULL - ); - - CREATE TABLE IF NOT EXISTS emotion_feedback_samples ( + ) + """, + "emotion_feedback_samples": """ + CREATE TABLE emotion_feedback_samples ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at TEXT NOT NULL DEFAULT (datetime('now')), source_event_id TEXT NOT NULL UNIQUE, @@ -124,9 +114,10 @@ def open_db(path: Path) -> sqlite3.Connection: user_content_preview TEXT, assistant_content_preview TEXT, proactive_content_preview TEXT - ); - - CREATE TABLE IF NOT EXISTS emotion_effects ( + ) + """, + "emotion_effects": """ + CREATE TABLE emotion_effects ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at TEXT NOT NULL DEFAULT (datetime('now')), tick_id TEXT NOT NULL UNIQUE, @@ -141,9 +132,10 @@ def open_db(path: Path) -> sqlite3.Connection: expected_effect TEXT NOT NULL, prompt_section TEXT NOT NULL, metadata_json TEXT NOT NULL - ); - - CREATE TABLE IF NOT EXISTS emotion_domain_effects ( + ) + """, + "emotion_domain_effects": """ + CREATE TABLE emotion_domain_effects ( semantic_job_id TEXT NOT NULL, event_id TEXT NOT NULL, invocation_id TEXT NOT NULL, @@ -154,137 +146,583 @@ def open_db(path: Path) -> sqlite3.Connection: created_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (invocation_id, effect_id, idempotency_key), UNIQUE (semantic_job_id, event_id, effect_id) - ); + ) + """, + "emotion_context_current": """ + CREATE TABLE emotion_context_current ( + id INTEGER PRIMARY KEY CHECK (id = 1), + last_user_at TEXT, + presence TEXT NOT NULL, + prompt_section TEXT NOT NULL, + source_state_updated_at TEXT NOT NULL, + refreshed_at TEXT NOT NULL + ) + """, + "emotion_preference_state": """ + CREATE TABLE emotion_preference_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + context_text TEXT NOT NULL, + processed_feedback_sample_id INTEGER NOT NULL CHECK ( + processed_feedback_sample_id >= 0 + ), + updated_at TEXT NOT NULL + ) + """, + "emotion_drift_runs": """ + CREATE TABLE emotion_drift_runs ( + proposal_id TEXT NOT NULL, + revision TEXT NOT NULL, + sample_first_id INTEGER NOT NULL, + sample_last_id INTEGER NOT NULL, + attempt INTEGER NOT NULL CHECK (attempt >= 1), + status TEXT NOT NULL, + payload_json TEXT NOT NULL, + context_before TEXT NOT NULL, + result_json TEXT, + selected_session_id TEXT, + selected_turn_id TEXT, + created_at TEXT NOT NULL, + completed_at TEXT, + PRIMARY KEY (proposal_id, revision) + ) + """, + "pf_history_cursor": """ + CREATE TABLE pf_history_cursor ( + source TEXT PRIMARY KEY, + row_id INTEGER NOT NULL CHECK (row_id >= 0), + updated_at TEXT NOT NULL + ) + """, +} +_VERSION_ONE_TABLES = frozenset(_TABLE_SQL).difference({"pf_history_cursor"}) +_LEGACY_TABLE_SETS = frozenset( + { + frozenset(), + frozenset({"emotion_state", "emotion_events", "emotion_effects"}), + frozenset( + { + "emotion_state", + "emotion_events", + "emotion_effects", + "emotion_domain_effects", + } + ), + frozenset( + { + "emotion_state", + "emotion_events", + "emotion_feedback_samples", + "emotion_effects", + "emotion_domain_effects", + } + ), + _VERSION_ONE_TABLES, + frozenset(_TABLE_SQL), + } +) + + +def open_db(path: Path) -> sqlite3.Connection: + """Validate, atomically upgrade, and open the exact Emotion database.""" + + # 1. Existing bytes are inspected read-only before any directory, pragma, or DDL write. + if path.exists(): + existing = _connect_read_only(path) + try: + _validate_schema(existing) + finally: + existing.close() + + # 2. Revalidate under the write lock, then create all missing tables in one transaction. + path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(path) + conn.row_factory = sqlite3.Row + try: + conn.execute("BEGIN IMMEDIATE") + _validate_schema(conn) + existing_tables = _owned_tables(conn) + for table_name, table_sql in _TABLE_SQL.items(): + if table_name not in existing_tables: + conn.execute(table_sql) + now = datetime.now(timezone.utc).isoformat() + _ = conn.execute( + """ + INSERT OR IGNORE INTO emotion_state( + id, valence, arousal, dominance, updated_at + ) VALUES(1, 0.0, 0.0, 0.0, ?) + """, + (now,), + ) + _ = conn.execute( + """ + INSERT OR IGNORE INTO pf_history_cursor(source, row_id, updated_at) + VALUES('proactive_feedback', 0, ?) + """, + (now,), + ) + _ = conn.execute( + """ + INSERT OR IGNORE INTO emotion_preference_state( + id, context_text, processed_feedback_sample_id, updated_at + ) VALUES(1, '', 0, ?) + """, + (now,), + ) + conn.execute(f"PRAGMA user_version = {_SCHEMA_VERSION}") + conn.commit() + except BaseException: + conn.rollback() + conn.close() + raise + _ = conn.execute("PRAGMA synchronous = NORMAL") + return conn + + +def _connect_read_only(path: Path) -> sqlite3.Connection: + connection = sqlite3.connect(f"{path.resolve().as_uri()}?mode=ro", uri=True) + connection.row_factory = sqlite3.Row + return connection + + +def _validate_schema(conn: sqlite3.Connection) -> None: + """Accept only exact historical or current Emotion table topologies.""" + + version = int(conn.execute("PRAGMA user_version").fetchone()[0]) + if version not in {0, 1, _SCHEMA_VERSION}: + raise RuntimeError(f"不支持的 Emotion schema version: {version}") + tables = _owned_tables(conn) + unknown = set(tables).difference(_TABLE_SQL) + if unknown: + raise RuntimeError("Emotion table set 不匹配") + for table_name, actual_sql in tables.items(): + if _normalize_sql(actual_sql) != _normalize_sql(_TABLE_SQL[table_name]): + raise RuntimeError(f"Emotion table schema 不匹配: {table_name}") + if version == 0 and frozenset(tables) not in _LEGACY_TABLE_SETS: + raise RuntimeError("Emotion legacy table set 不匹配") + if version == 1 and frozenset(tables) != _VERSION_ONE_TABLES: + raise RuntimeError("Emotion v1 table set 不匹配") + if version == _SCHEMA_VERSION and frozenset(tables) != frozenset(_TABLE_SQL): + raise RuntimeError("Emotion current table set 不匹配") + check = conn.execute("PRAGMA quick_check").fetchone() + if check is None or check[0] != "ok": + raise RuntimeError("Emotion SQLite quick_check failed") + + +def _owned_tables(conn: sqlite3.Connection) -> dict[str, str]: + rows = conn.execute( + "SELECT name, sql FROM sqlite_master " + "WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name" + ).fetchall() + return {str(row["name"]): str(row["sql"]) for row in rows} + + +def _normalize_sql(sql: str) -> str: + return "".join(sql.lower().split()) + + +def record_user_activity(conn: sqlite3.Connection, at: datetime) -> None: + """Update only the current user-presence input without creating history.""" + + instant = _aware_utc(at) + _ = conn.execute( """ + INSERT INTO emotion_context_current( + id, last_user_at, presence, prompt_section, + source_state_updated_at, refreshed_at + ) VALUES(1, ?, 'active', '', ?, ?) + ON CONFLICT(id) DO UPDATE SET last_user_at = excluded.last_user_at + """, + (instant, instant, instant), ) - now = datetime.now(timezone.utc).isoformat() + conn.commit() + + +def refresh_current_context( + conn: sqlite3.Connection, + *, + now: datetime, +) -> dict[str, object]: + """Refresh the overwriteable VAD/presence card without appending history.""" + + # 1. Decay the current state and derive presence from the latest committed user turn. + instant = _aware_datetime(now) + state = _decay(get_state(conn), instant.isoformat()) + row = conn.execute( + "SELECT last_user_at FROM emotion_context_current WHERE id = 1" + ).fetchone() + last_user_at = _optional_datetime(None if row is None else row["last_user_at"]) + energy = compute_energy(last_user_at, instant) + state = EmotionState( + valence=state.valence, + arousal=_clamp((1.0 - energy) * 2.0 - 1.0), + dominance=state.dominance, + updated_at=instant.isoformat(), + ) + _save_state(conn, state) + behavior = describe_behavior(state) + presence = _presence(last_user_at, instant) + + # 2. Project the current preference singleton into the fresh Wake hint. + preference = conn.execute( + "SELECT context_text FROM emotion_preference_state WHERE id = 1" + ).fetchone() + context_text = "" if preference is None else str(preference["context_text"]) + prompt = ( + f"当前情绪: valence={state.valence:.2f}, arousal={state.arousal:.2f}, " + f"dominance={state.dominance:.2f}; presence={presence}.\n" + f"语气约束: {behavior.tone_instruction}" + ) + if context_text.strip(): + prompt += "\n稳定的主动偏好:\n" + context_text.strip() + refreshed_at = instant.isoformat() _ = conn.execute( """ - INSERT OR IGNORE INTO emotion_state(id, valence, arousal, dominance, updated_at) - VALUES(1, 0.0, 0.0, 0.0, ?) + INSERT INTO emotion_context_current( + id, last_user_at, presence, prompt_section, + source_state_updated_at, refreshed_at + ) VALUES(1, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + last_user_at = excluded.last_user_at, + presence = excluded.presence, + prompt_section = excluded.prompt_section, + source_state_updated_at = excluded.source_state_updated_at, + refreshed_at = excluded.refreshed_at """, - (now,), + ( + last_user_at.isoformat() if last_user_at is not None else None, + presence, + prompt, + state.updated_at, + refreshed_at, + ), ) conn.commit() - return conn + return { + "presence": presence, + "prompt_section": prompt, + "refreshed_at": refreshed_at, + } -def commit_domain_effect( +def read_fresh_context( + path: Path, + *, + now: datetime, + max_age_seconds: int, +) -> str | None: + """Read a fresh current card without creating or mutating the database.""" + + if not path.is_file() or path.is_symlink(): + return None + conn = sqlite3.connect(f"{path.resolve().as_uri()}?mode=ro", uri=True) + conn.row_factory = sqlite3.Row + try: + table = conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' " + "AND name='emotion_context_current'" + ).fetchone() + if table is None: + return None + row = conn.execute( + "SELECT prompt_section, refreshed_at FROM emotion_context_current WHERE id=1" + ).fetchone() + if row is None: + return None + refreshed = _aware_datetime(datetime.fromisoformat(str(row["refreshed_at"]))) + age = (_aware_datetime(now) - refreshed).total_seconds() + if age < 0 or age > max_age_seconds: + return None + prompt = str(row["prompt_section"]) + return prompt if prompt else None + finally: + conn.close() + + +def prepare_drift_proposal( conn: sqlite3.Connection, *, - semantic_job_id: str, - event_id: str, - invocation_id: str, - effect_id: str, - idempotency_key: str, - attempt: int, - result_digest: str, -) -> EmotionDomainEffect: - """在 Emotion 事务内幂等提交一次 job 领域效果及其 durable receipt。""" - - effect = EmotionDomainEffect( - semantic_job_id=_required_text(semantic_job_id, "semantic_job_id"), - event_id=_required_text(event_id, "event_id"), - invocation_id=_required_text(invocation_id, "invocation_id"), - effect_id=_required_text(effect_id, "effect_id"), - idempotency_key=_required_text(idempotency_key, "idempotency_key"), - attempt=_required_attempt(attempt), - result_digest=_required_text(result_digest, "result_digest"), + now: datetime, + limit: int = 10, +) -> dict[str, object] | None: + """Create or replay one Emotion-owned ordinary Drift proposal receipt.""" + + # 1. Replay a locally prepared/submitted proposal before admitting new evidence. + existing = conn.execute( + """ + SELECT proposal_id, revision, payload_json + FROM emotion_drift_runs + WHERE status IN ('prepared', 'submitted') + ORDER BY created_at, proposal_id, revision LIMIT 1 + """ + ).fetchone() + if existing is not None: + return _drift_proposal_from_row(existing) + + # 2. Freeze the next bounded feedback batch and append its proposal attempt. + state = conn.execute( + """ + SELECT context_text, processed_feedback_sample_id + FROM emotion_preference_state WHERE id=1 + """ + ).fetchone() + if state is None: + raise RuntimeError("Emotion preference singleton 缺失") + processed = int(state["processed_feedback_sample_id"]) + rows = conn.execute( + """ + SELECT id, created_at, feedback_type, confidence, + user_message_id, proactive_message_id, + user_content_preview, proactive_content_preview + FROM emotion_feedback_samples + WHERE id > ? AND feedback_type IN ('topic_follow', 'explicit_quote') + ORDER BY id LIMIT ? + """, + (processed, limit), + ).fetchall() + if not rows: + return None + first_id = int(rows[0]["id"]) + last_id = int(rows[-1]["id"]) + proposal_id = f"emotion-feedback:{first_id}-{last_id}" + previous = conn.execute( + "SELECT count(1) FROM emotion_drift_runs WHERE proposal_id=?", + (proposal_id,), + ).fetchone() + attempt = int(previous[0]) + 1 if previous is not None else 1 + revision = f"attempt-{attempt}" + context_before = str(state["context_text"]) + payload: dict[str, object] = { + "owner": "emotion", + "kind": "feedback_preference_context", + "proposal_id": proposal_id, + "revision": revision, + "wake_action": "select", + "instruction": ( + "Review this bounded feedback batch, preserve only stable proactive " + "preferences, then call emotion_commit_preference_context exactly once." + ), + "current_context": context_before, + "events": [ + { + "id": int(row["id"]), + "created_at": str(row["created_at"]), + "feedback_type": str(row["feedback_type"]), + "confidence": str(row["confidence"]), + "user_message_id": row["user_message_id"], + "proactive_message_id": row["proactive_message_id"], + "user": row["user_content_preview"], + "proactive": row["proactive_content_preview"], + } + for row in rows + ], + } + payload_json = json.dumps( + payload, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), ) + created_at = _aware_utc(now) + _ = conn.execute( + """ + INSERT INTO emotion_drift_runs( + proposal_id, revision, sample_first_id, sample_last_id, attempt, + status, payload_json, context_before, created_at + ) VALUES(?, ?, ?, ?, ?, 'prepared', ?, ?, ?) + """, + ( + proposal_id, + revision, + first_id, + last_id, + attempt, + payload_json, + context_before, + created_at, + ), + ) + conn.commit() + return { + "proposal_id": proposal_id, + "revision": revision, + "payload": payload, + } - # 1. 锁定同一语义事件,避免新 invocation 重复提交领域效果。 - # 若调用方已经开启事务,receipt 必须加入该事务,不能提前提交。 - owns_transaction = not conn.in_transaction - if owns_transaction: - conn.execute("BEGIN IMMEDIATE") + +def mark_drift_proposal_submitted( + conn: sqlite3.Connection, + *, + proposal_id: str, + revision: str, +) -> None: + """Mark the local half of an idempotently submitted Drift proposal.""" + + changed = conn.execute( + """ + UPDATE emotion_drift_runs SET status='submitted' + WHERE proposal_id=? AND revision=? AND status='prepared' + """, + (proposal_id, revision), + ) + if changed.rowcount == 0: + row = conn.execute( + "SELECT status FROM emotion_drift_runs WHERE proposal_id=? AND revision=?", + (proposal_id, revision), + ).fetchone() + if row is None or row["status"] != "submitted": + raise RuntimeError("Emotion Drift proposal 本地状态不一致") + conn.commit() + + +def commit_drift_result( + conn: sqlite3.Connection, + *, + proposal_id: str, + revision: str, + result: Mapping[str, object], +) -> dict[str, object]: + """Atomically append one Drift result and replace the current preference card.""" + + proposal = _required_text(proposal_id, "proposal_id") + proposal_revision = _required_text(revision, "revision") + context_text = _required_text(result.get("context"), "context") + result_json = json.dumps( + result, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + conn.execute("BEGIN IMMEDIATE") try: row = conn.execute( """ - SELECT * FROM emotion_domain_effects - WHERE semantic_job_id = ? AND event_id = ? AND effect_id = ? + SELECT status, sample_last_id, result_json, payload_json + FROM emotion_drift_runs WHERE proposal_id=? AND revision=? """, - (effect.semantic_job_id, effect.event_id, effect.effect_id), + (proposal, proposal_revision), ).fetchone() - if row is not None: - existing = _domain_effect_from_row(row) - if existing != effect: - raise RuntimeError("Emotion domain effect 幂等 identity 漂移") - if owns_transaction: - conn.commit() - return existing - - # 2. receipt 与该 effect 的领域写集在同一 SQLite transaction 提交。 - conn.execute( + if row is None: + raise ValueError("Emotion Drift proposal 不存在") + if row["status"] == "completed": + if str(row["result_json"]) != result_json: + raise RuntimeError("Emotion Drift result identity conflict") + conn.commit() + return {"committed": False, "duplicate": True} + if row["status"] != "submitted": + raise RuntimeError(f"Emotion Drift proposal 不能提交: {row['status']}") + _validate_result_evidence(result, str(row["payload_json"])) + now = datetime.now(timezone.utc).isoformat() + _ = conn.execute( """ - INSERT INTO emotion_domain_effects ( - semantic_job_id, event_id, invocation_id, effect_id, - idempotency_key, attempt, result_digest - ) VALUES (?, ?, ?, ?, ?, ?, ?) + UPDATE emotion_drift_runs + SET status='completed', result_json=?, completed_at=? + WHERE proposal_id=? AND revision=? """, - ( - effect.semantic_job_id, - effect.event_id, - effect.invocation_id, - effect.effect_id, - effect.idempotency_key, - effect.attempt, - effect.result_digest, - ), + (result_json, now, proposal, proposal_revision), ) - if owns_transaction: - conn.commit() + _ = conn.execute( + """ + UPDATE emotion_preference_state + SET context_text=?, processed_feedback_sample_id=?, updated_at=? + WHERE id=1 + """, + (context_text, int(row["sample_last_id"]), now), + ) + conn.commit() except BaseException: - if owns_transaction: - conn.rollback() + conn.rollback() raise - return effect + return {"committed": True, "duplicate": False} -def lookup_domain_effect( +def record_drift_turn( conn: sqlite3.Connection, *, - invocation_id: str, - effect_id: str, - idempotency_key: str, -) -> EmotionDomainEffect | None: - """按 Core 固定的 invocation identity 读取 Emotion durable receipt。""" + proposal_id: str, + revision: str, + session_id: str, + turn_id: str, + completed_at: datetime, +) -> str: + """Record the selected Turn and expose a completed run that omitted commit.""" row = conn.execute( """ - SELECT * FROM emotion_domain_effects - WHERE invocation_id = ? AND effect_id = ? AND idempotency_key = ? + SELECT status, selected_session_id, selected_turn_id + FROM emotion_drift_runs + WHERE proposal_id=? AND revision=? + """, + (proposal_id, revision), + ).fetchone() + if row is None: + raise RuntimeError("Emotion selected Drift proposal 缺少本地 receipt") + status = str(row["status"]) + if status in {"completed", "completed_without_commit"}: + existing = (row["selected_session_id"], row["selected_turn_id"]) + if existing == (session_id, turn_id): + return status + if existing != (None, None): + raise RuntimeError("Emotion Drift selected Turn identity conflict") + terminal = "completed" if status == "completed" else "completed_without_commit" + if status not in {"submitted", "completed"}: + raise RuntimeError(f"Emotion selected Drift proposal 状态无效: {status}") + _ = conn.execute( + """ + UPDATE emotion_drift_runs + SET status=?, selected_session_id=?, selected_turn_id=?, + completed_at=COALESCE(completed_at, ?) + WHERE proposal_id=? AND revision=? """, ( - _required_text(invocation_id, "invocation_id"), - _required_text(effect_id, "effect_id"), - _required_text(idempotency_key, "idempotency_key"), + terminal, + session_id, + turn_id, + _aware_utc(completed_at), + proposal_id, + revision, ), - ).fetchone() - return None if row is None else _domain_effect_from_row(row) + ) + conn.commit() + return terminal -def lookup_domain_effect_path( - path: Path, - *, - invocation_id: str, - effect_id: str, - idempotency_key: str, -) -> EmotionDomainEffect | None: - """只读查询既有 Emotion DB,不因恢复扫描创建任何文件。""" - - if not path.is_file() or path.is_symlink(): - return None - conn = sqlite3.connect(f"{path.resolve().as_uri()}?mode=ro", uri=True) - conn.row_factory = sqlite3.Row - try: - return lookup_domain_effect( - conn, - invocation_id=invocation_id, - effect_id=effect_id, - idempotency_key=idempotency_key, - ) - finally: - conn.close() +def _validate_result_evidence( + result: Mapping[str, object], + payload_json: str, +) -> None: + """Require every committed evidence id to belong to the frozen proposal batch.""" + + payload = json.loads(payload_json) + if not isinstance(payload, dict) or not isinstance(payload.get("events"), list): + raise RuntimeError("Emotion Drift proposal payload 缺少 events") + event_ids = { + int(event["id"]) + for event in payload["events"] + if isinstance(event, dict) and type(event.get("id")) is int + } + candidates = result.get("candidates") + if not isinstance(candidates, list): + raise ValueError("Emotion Drift result candidates 必须是 array") + for candidate in candidates: + if not isinstance(candidate, Mapping): + raise ValueError("Emotion Drift result candidate 必须是 object") + evidence = candidate.get("evidence") + if not isinstance(evidence, list) or any( + type(sample_id) is not int or sample_id not in event_ids + for sample_id in evidence + ): + raise ValueError("Emotion Drift evidence 不属于冻结 proposal batch") + + +def _drift_proposal_from_row(row: sqlite3.Row) -> dict[str, object]: + payload = json.loads(str(row["payload_json"])) + if not isinstance(payload, dict): + raise RuntimeError("Emotion Drift proposal payload 必须是 object") + return { + "proposal_id": str(row["proposal_id"]), + "revision": str(row["revision"]), + "payload": payload, + } def classify_feedback_delta(feedback_type: str, confidence: str) -> FeedbackDelta: @@ -308,7 +746,227 @@ def apply_feedback( confidence: str, payload: dict[str, Any], ) -> EmotionState: + """Apply one Emotion-owned direct feedback signal and commit its history.""" + + state, _ = _apply_feedback( + conn, + source_plugin="emotion", + source_event_id=source_event_id, + session_key=session_key, + feedback_type=feedback_type, + confidence=confidence, + payload=payload, + ) + conn.commit() + return state + + +def apply_feedback_history_page( + conn: sqlite3.Connection, + records: list[dict[str, Any]], +) -> int: + """Atomically apply one ordered PF page and advance its local cursor.""" + + # 1. Freeze the current cursor and validate the complete incoming page. + state = conn.execute( + "SELECT row_id FROM pf_history_cursor WHERE source='proactive_feedback'" + ).fetchone() + if state is None: + raise RuntimeError("Emotion PF history cursor 缺失") + current = int(state["row_id"]) + previous = current + validated: list[tuple[int, str, str, dict[str, Any]]] = [] + for record in records: + cursor = _history_cursor(record) + event_id = _history_text(record, "event_id") + if event_id != f"proactive_feedback:{cursor}": + raise ValueError("PF history event_id 与 cursor 不一致") + if cursor <= previous: + raise ValueError("PF history page cursor 必须严格递增且晚于本地 cursor") + payload_hash = _history_hash(record) + payload = _canonical_history_payload(record) + if _canonical_history_hash(payload) != payload_hash: + raise RuntimeError(f"PF history payload hash 漂移: {event_id}") + validated.append((cursor, event_id, payload_hash, payload)) + previous = cursor + + # 2. Apply every accepted fact and its derived sample inside one transaction. + try: + conn.execute("BEGIN IMMEDIATE") + for cursor, event_id, payload_hash, canonical in validated: + payload = _history_payload(canonical, cursor, payload_hash) + receipt_id = f"pf_history_import:{cursor}" + receipt = conn.execute( + "SELECT payload_json FROM emotion_events WHERE source_event_id=?", + (receipt_id,), + ).fetchone() + if receipt is not None: + _verify_import_receipt(receipt, event_id, payload_hash) + current = cursor + continue + existing = conn.execute( + """ + SELECT source_plugin, source_type, session_key, payload_json + FROM emotion_events + WHERE source_event_id=? + """, + (event_id,), + ).fetchone() + if existing is not None: + stored = json.loads(str(existing["payload_json"])) + if not isinstance(stored, dict): + raise RuntimeError(f"PF history legacy payload 无效: {event_id}") + stored_hash = stored.get("source_payload_hash") + if stored_hash is None: + _validate_legacy_history_event( + existing, stored, canonical, event_id + ) + receipt_payload = { + **payload, + "disposition": "legacy_event_already_applied", + "legacy_event_id": event_id, + } + _record_feedback_terminal( + conn, + source_plugin="emotion", + source_event_id=receipt_id, + source_type="pf_history_import_terminal", + session_key=str(canonical["session_key"]), + reason="legacy_event_already_applied", + payload=receipt_payload, + ) + else: + _verify_stored_history_event( + existing, stored, canonical, payload_hash, event_id + ) + elif ( + canonical["feedback_type"] == "explicit_quote" + and _direct_quote_already_applied( + conn, + str(canonical["user_message_id"]), + ) + ): + _record_feedback_terminal( + conn, + source_plugin="proactive_feedback", + source_event_id=event_id, + source_type="explicit_quote_already_applied", + session_key=str(canonical["session_key"]), + reason="direct_quote_already_applied", + payload=payload, + ) + else: + _apply_feedback( + conn, + source_plugin="proactive_feedback", + source_event_id=event_id, + session_key=str(canonical["session_key"]), + feedback_type=str(canonical["feedback_type"]), + confidence=str(canonical["confidence"]), + payload=payload, + ) + current = cursor + if records: + _ = conn.execute( + """ + UPDATE pf_history_cursor + SET row_id=?, updated_at=? + WHERE source='proactive_feedback' + """, + (current, datetime.now(timezone.utc).isoformat()), + ) + conn.commit() + except BaseException: + conn.rollback() + raise + return current + + +def read_feedback_history_cursor(conn: sqlite3.Connection) -> int: + row = conn.execute( + "SELECT row_id FROM pf_history_cursor WHERE source='proactive_feedback'" + ).fetchone() + if row is None: + raise RuntimeError("Emotion PF history cursor 缺失") + return int(row["row_id"]) + + +def _direct_quote_already_applied( + conn: sqlite3.Connection, + user_message_id: str, +) -> bool: + row = conn.execute( + """ + SELECT 1 + FROM emotion_events + WHERE source_plugin='emotion' + AND source_event_id LIKE 'emotion_explicit_quote:%' + AND source_type='explicit_quote' + AND json_extract(payload_json, '$.user_message_id') = ? + LIMIT 1 + """, + (user_message_id,), + ).fetchone() + return row is not None + + +def _record_feedback_terminal( + conn: sqlite3.Connection, + *, + source_plugin: str, + source_event_id: str, + source_type: str, + session_key: str, + reason: str, + payload: dict[str, Any], +) -> None: + """Record an applied-without-new-effect terminal in Emotion history.""" + + state = get_state(conn) + _ = conn.execute( + """ + INSERT INTO emotion_events ( + source_plugin, source_event_id, source_type, session_key, + valence_before, arousal_before, dominance_before, + valence_delta, arousal_delta, dominance_delta, + valence_after, arousal_after, dominance_after, + reason, payload_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, 0.0, 0.0, 0.0, ?, ?, ?, ?, ?) + """, + ( + source_plugin, + source_event_id, + source_type, + session_key, + state.valence, + state.arousal, + state.dominance, + state.valence, + state.arousal, + state.dominance, + reason, + json.dumps(payload, ensure_ascii=False), + ), + ) + + +def _apply_feedback( + conn: sqlite3.Connection, + *, + source_plugin: str, + source_event_id: str, + session_key: str, + feedback_type: str, + confidence: str, + payload: dict[str, Any], +) -> tuple[EmotionState, bool]: before = get_state(conn) + existing = conn.execute( + "SELECT 1 FROM emotion_events WHERE source_event_id=?", + (source_event_id,), + ).fetchone() + if existing is not None: + return before, False delta = classify_feedback_delta(feedback_type, confidence) now = datetime.now(timezone.utc).isoformat() decayed = _decay(before, now) @@ -318,9 +976,8 @@ def apply_feedback( dominance=_clamp(decayed.dominance + delta.dominance), updated_at=now, ) - try: - _ = conn.execute( - """ + _ = conn.execute( + """ INSERT INTO emotion_events ( source_plugin, source_event_id, source_type, session_key, valence_before, arousal_before, dominance_before, @@ -329,27 +986,25 @@ def apply_feedback( reason, payload_json ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - "proactive_feedback", - source_event_id, - feedback_type, - session_key, - before.valence, - before.arousal, - before.dominance, - delta.valence, - 0.0, - delta.dominance, - after.valence, - after.arousal, - after.dominance, - delta.reason, - json.dumps(payload, ensure_ascii=False), - ), - ) - except sqlite3.IntegrityError: - return before + """, + ( + source_plugin, + source_event_id, + feedback_type, + session_key, + before.valence, + before.arousal, + before.dominance, + delta.valence, + 0.0, + delta.dominance, + after.valence, + after.arousal, + after.dominance, + delta.reason, + json.dumps(payload, ensure_ascii=False), + ), + ) if feedback_type in {"topic_follow", "explicit_quote"}: _insert_feedback_sample( conn, @@ -360,8 +1015,192 @@ def apply_feedback( payload=payload, ) _save_state(conn, after) - conn.commit() - return after + return after, True + + +def _history_cursor(record: dict[str, Any]) -> int: + value = record.get("cursor") + if isinstance(value, bool) or not isinstance(value, int) or value < 1: + raise ValueError("PF history cursor 必须是正整数") + return value + + +def _history_text(record: dict[str, Any], field: str) -> str: + value = record.get(field) + if not isinstance(value, str) or not value: + raise ValueError(f"PF history {field} 必须是非空字符串") + return value + + +def _history_hash(record: dict[str, Any]) -> str: + value = _history_text(record, "payload_hash") + if len(value) != 64 or any( + character not in "0123456789abcdef" for character in value + ): + raise ValueError("PF history payload_hash 必须是小写 sha256") + return value + + +def _canonical_history_payload(record: dict[str, Any]) -> dict[str, Any]: + """Validate and freeze exactly the fields owned by PF history v1.""" + + return { + "session_key": _history_text(record, "session_key"), + "user_message_id": _history_text(record, "user_message_id"), + "assistant_message_id": _history_text(record, "assistant_message_id"), + "proactive_message_id": _history_optional_text( + record, "proactive_message_id" + ), + "feedback_type": _history_enum( + record, "feedback_type", _PF_FEEDBACK_TYPES + ), + "confidence": _history_enum( + record, "confidence", _PF_CONFIDENCE + ), + "pa_score": _history_optional_score(record, "pa_score"), + "pua_score": _history_optional_score(record, "pua_score"), + "lag_seconds": _history_optional_nonnegative_int(record, "lag_seconds"), + "candidate_count": _history_nonnegative_int(record, "candidate_count"), + "matched_by": _history_text(record, "matched_by"), + "reason": _history_text(record, "reason"), + "user_content_preview": _history_optional_text( + record, "user_content_preview" + ), + "assistant_content_preview": _history_optional_text( + record, "assistant_content_preview" + ), + "proactive_content_preview": _history_optional_text( + record, "proactive_content_preview" + ), + } + + +def _canonical_history_hash(payload: dict[str, Any]) -> str: + encoded = json.dumps( + payload, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def _history_optional_text(record: dict[str, Any], field: str) -> str | None: + value = record.get(field) + if value is None: + return None + if not isinstance(value, str): + raise TypeError(f"PF history {field} 必须是字符串或 null") + return value + + +def _history_enum( + record: dict[str, Any], + field: str, + allowed: frozenset[str], +) -> str: + value = _history_text(record, field) + if value not in allowed: + raise ValueError(f"PF history {field} 不支持: {value}") + return value + + +def _history_nonnegative_int(record: dict[str, Any], field: str) -> int: + value = record.get(field) + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise ValueError(f"PF history {field} 必须是非负整数") + return value + + +def _history_optional_nonnegative_int( + record: dict[str, Any], field: str +) -> int | None: + value = record.get(field) + if value is None: + return None + return _history_nonnegative_int(record, field) + + +def _history_optional_score(record: dict[str, Any], field: str) -> float | None: + value = record.get(field) + if value is None: + return None + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise TypeError(f"PF history {field} 必须是数字或 null") + score = float(value) + if not math.isfinite(score) or score < -1.0 or score > 1.0: + raise ValueError(f"PF history {field} 必须在 -1..1 之间") + return score + + +def _verify_import_receipt( + receipt: sqlite3.Row, + event_id: str, + payload_hash: str, +) -> None: + payload = json.loads(str(receipt["payload_json"])) + if ( + not isinstance(payload, dict) + or payload.get("disposition") != "legacy_event_already_applied" + or payload.get("legacy_event_id") != event_id + or payload.get("source_payload_hash") != payload_hash + ): + raise RuntimeError(f"PF history import receipt 漂移: {event_id}") + + +def _validate_legacy_history_event( + event: sqlite3.Row, + stored: dict[str, Any], + canonical: dict[str, Any], + event_id: str, +) -> None: + """Match a v1 applied event by its preserved identity without inventing a hash.""" + + if ( + event["source_plugin"] != "proactive_feedback" + or event["source_type"] != canonical["feedback_type"] + or event["session_key"] != canonical["session_key"] + ): + raise RuntimeError(f"PF history legacy identity 冲突: {event_id}") + for field in ( + "user_message_id", + "assistant_message_id", + "proactive_message_id", + "feedback_type", + ): + if field not in stored or stored[field] != canonical[field]: + raise RuntimeError(f"PF history legacy identity 冲突: {event_id}") + + +def _verify_stored_history_event( + event: sqlite3.Row, + stored: dict[str, Any], + canonical: dict[str, Any], + payload_hash: str, + event_id: str, +) -> None: + if ( + stored.get("source_payload_hash") != payload_hash + or event["session_key"] != canonical["session_key"] + ): + raise RuntimeError(f"PF history payload hash 漂移: {event_id}") + for field, value in canonical.items(): + if field == "session_key": + continue + if field not in stored or stored[field] != value: + raise RuntimeError(f"PF history payload hash 漂移: {event_id}") + + +def _history_payload( + canonical: dict[str, Any], + cursor: int, + payload_hash: str, +) -> dict[str, Any]: + payload = dict(canonical) + payload["source_cursor"] = cursor + payload["source_payload_hash"] = payload_hash + return payload def _insert_feedback_sample( @@ -411,121 +1250,6 @@ def _payload_text(payload: dict[str, Any], field: str) -> str | None: return value if isinstance(value, str) else None -def build_effect( - conn: sqlite3.Connection, - *, - tick_id: str, - session_key: str, - now_utc: datetime, - last_user_at: datetime | None, - base_threshold: float, - commit: bool = True, -) -> dict[str, Any]: - stored = _decay(get_state(conn), now_utc.isoformat()) - energy = compute_energy(last_user_at, now_utc) - arousal = _clamp((1.0 - energy) * 2.0 - 1.0) - state = EmotionState( - valence=stored.valence, - arousal=arousal, - dominance=stored.dominance, - updated_at=now_utc.isoformat(), - ) - _save_state(conn, state) - behavior = describe_behavior(state) - tone_label = behavior.tone_label - tone_instruction = behavior.tone_instruction - threshold_delta = behavior.threshold_delta - final_threshold = _clamp_threshold(base_threshold + threshold_delta) - expected_effect = behavior.expected_effect - prompt_section = ( - f"当前 VAD: valence={state.valence:.2f}, arousal={state.arousal:.2f}, dominance={state.dominance:.2f}。\n" - f"语气约束: {tone_instruction}\n" - f"发送克制程度: base_threshold={base_threshold:.2f}, effective_threshold={final_threshold:.2f}。" - " effective_threshold 越高,越需要确认内容确实值得打扰用户。" - ) - metadata: dict[str, object] = { - "valence": round(state.valence, 4), - "arousal": round(state.arousal, 4), - "dominance": round(state.dominance, 4), - "base_threshold": round(base_threshold, 4), - "final_threshold": round(final_threshold, 4), - "tone_label": tone_label, - "expected_effect": expected_effect, - } - _ = conn.execute( - """ - INSERT INTO emotion_effects ( - tick_id, session_key, valence, arousal, dominance, - base_threshold, final_threshold, threshold_delta, - tone_label, expected_effect, prompt_section, metadata_json - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(tick_id) DO UPDATE SET - session_key = excluded.session_key, - valence = excluded.valence, - arousal = excluded.arousal, - dominance = excluded.dominance, - base_threshold = excluded.base_threshold, - final_threshold = excluded.final_threshold, - threshold_delta = excluded.threshold_delta, - tone_label = excluded.tone_label, - expected_effect = excluded.expected_effect, - prompt_section = excluded.prompt_section, - metadata_json = excluded.metadata_json - """, - ( - tick_id, - session_key, - state.valence, - state.arousal, - state.dominance, - base_threshold, - final_threshold, - threshold_delta, - tone_label, - expected_effect, - prompt_section, - json.dumps(metadata, ensure_ascii=False), - ), - ) - if commit: - conn.commit() - return { - "provider_name": "emotion", - "prompt_section": prompt_section, - "threshold_delta": threshold_delta, - "metadata": metadata, - } - - -def lookup_effect( - conn: sqlite3.Connection, - *, - tick_id: str, -) -> dict[str, Any] | None: - """Read one previously committed proactive projection without recomputing it.""" - - row = conn.execute( - """ - SELECT prompt_section, threshold_delta, metadata_json - FROM emotion_effects - WHERE tick_id = ? - """, - (tick_id,), - ).fetchone() - if row is None: - return None - metadata = json.loads(str(row["metadata_json"])) - if not isinstance(metadata, dict): - raise RuntimeError("Emotion effect metadata 必须是 object") - return { - "provider_name": "emotion", - "prompt_section": str(row["prompt_section"]), - "threshold_delta": float(row["threshold_delta"]), - "metadata": metadata, - } - - def get_state(conn: sqlite3.Connection) -> EmotionState: row = conn.execute( """ @@ -545,18 +1269,6 @@ def get_state(conn: sqlite3.Connection) -> EmotionState: ) -def _domain_effect_from_row(row: sqlite3.Row) -> EmotionDomainEffect: - return EmotionDomainEffect( - semantic_job_id=str(row["semantic_job_id"]), - event_id=str(row["event_id"]), - invocation_id=str(row["invocation_id"]), - effect_id=str(row["effect_id"]), - idempotency_key=str(row["idempotency_key"]), - attempt=int(row["attempt"]), - result_digest=str(row["result_digest"]), - ) - - def _required_text(value: object, field: str) -> str: if not isinstance(value, str): raise TypeError(f"{field} 必须是字符串") @@ -565,14 +1277,6 @@ def _required_text(value: object, field: str) -> str: return value -def _required_attempt(value: object) -> int: - if isinstance(value, bool) or not isinstance(value, int): - raise TypeError("attempt 必须是整数") - if value < 1: - raise ValueError("attempt 必须是正整数") - return value - - def _save_state(conn: sqlite3.Connection, state: EmotionState) -> None: _ = conn.execute( """ @@ -653,5 +1357,30 @@ def _clamp(value: float) -> float: return max(-1.0, min(1.0, value)) -def _clamp_threshold(value: float) -> float: - return max(0.54, min(0.78, value)) +def _aware_datetime(value: datetime) -> datetime: + if value.tzinfo is None: + raise ValueError("Emotion timestamp 必须带时区") + return value.astimezone(timezone.utc) + + +def _aware_utc(value: datetime) -> str: + return _aware_datetime(value).isoformat() + + +def _optional_datetime(value: object) -> datetime | None: + if value is None: + return None + if not isinstance(value, str): + raise TypeError("Emotion last_user_at 必须是 ISO 字符串或 null") + return _aware_datetime(datetime.fromisoformat(value)) + + +def _presence(last_user_at: datetime | None, now: datetime) -> str: + if last_user_at is None: + return "unknown" + seconds = max(0.0, (now - last_user_at).total_seconds()) + if seconds <= 30 * 60: + return "active" + if seconds <= 4 * 60 * 60: + return "idle" + return "away" diff --git a/drift/skills/feedback-preference-context/SKILL.md b/drift/skills/feedback-preference-context/SKILL.md index d5aa80f..0e81662 100644 --- a/drift/skills/feedback-preference-context/SKILL.md +++ b/drift/skills/feedback-preference-context/SKILL.md @@ -1,191 +1,51 @@ --- name: feedback-preference-context -description: 从 proactive 正反馈中归纳待审核推送偏好候选,追加到 proactive_pending.md 队列。 +description: 从当前普通 Drift proposal 的 Emotion 反馈批次归纳稳定主动偏好,并通过普通 Emotion tool 原子提交。 --- -# Feedback Preference Pending +# Feedback Preference Context -## 目标 +## 输入与结果 -读取尚未处理过的 `topic_follow` 和 `explicit_quote` 反馈,最多一批 50 条,把会影响主动推送决策的候选规则追加到 workspace 根目录的 `proactive_pending.md`。 +当前 Wake Turn 已携带一个 Emotion-owned Drift proposal。它包含稳定的 +`proposal_id`、`revision`、当前偏好上下文和最多 10 条冻结反馈。不要重新读取 +workspace、数据库、记忆文件或旧 proactive 文档。 -这个 skill 只写 pending 队列。它的职责类似普通记忆里的 `PENDING.md`:先把候选沉淀成可审核队列,后续再由独立流程合并到正式规则。 - -## 流程 - -```text -Emotion feedback samples -├─ evidence bundle -│ ├─ proactive text -│ ├─ user text -│ ├─ feedback id -│ └─ message id -├─ infer topic attitude by prompt -├─ append proactive_pending.md -└─ advance latest cursor only after write -``` - -## 固定脚本 - -```bash -python3 skills/feedback-preference-context/scripts/sample_feedback_context.py sample --drift-dir . --chunk-index 0 --chunk-size 10 -``` - -脚本只负责取数: - -```text -sample -├─ 读取 drift.db 中 cursor.latest_processed_emotion_sample_id -├─ 查询 ../emotion/emotion.db 的 emotion_feedback_samples -│ └─ feedback_type IN ('topic_follow', 'explicit_quote') -├─ WHERE id > latest_processed_emotion_sample_id -├─ ORDER BY id DESC LIMIT 50 -├─ 使用 typed Turn 已写入的 bounded proactive/user 文本 -├─ 不读取 sessions.db 或其他插件数据库 -├─ 每次只返回 chunk-size 条 events -└─ 返回 cursor_tail_emotion_sample_id -``` - -脚本不判断 topic、不判断 effect、不生成 pending 内容。 - -## 执行规则 - -1. 先调用 `select_skill`。 -2. 调固定脚本读取第 0 个 chunk: - -```json -{ - "command": "python3 skills/feedback-preference-context/scripts/sample_feedback_context.py sample --drift-dir . --chunk-index 0 --chunk-size 10", - "cwd": ".", - "description": "读取 Emotion feedback sample 第 0 个 chunk", - "timeout": 30 -} -``` - -3. 如果 `found=false`,用 `finish_drift(status="completed", message_result="silent")` 静默结束,不推进 cursor;无新数据表示本轮检查已闭环,不是暂停。 -4. 有样本时先读取 `../proactive_pending.md`,确认现有文件内容和末尾位置;文件不存在就用空内容处理。 - - 必须使用路径 `../proactive_pending.md`。 - - 禁止使用 `proactive_pending.md` 或 `./proactive_pending.md`,那会写到 drift 目录而不是 workspace 根目录。 -5. 读取 `../memory/MEMORY.md` 作为长期偏好参考;文件不存在就用空内容处理。 -6. 不要调用 `fetch_messages`。 -7. 本轮只允许使用三类输入:固定脚本返回的 chunk、`../proactive_pending.md`、`../memory/MEMORY.md`。其他 workspace 文档不是证据来源,不要读取。 -8. `shell` 只用于运行固定脚本;不要用它执行 `cat`、`cp`、`ls`、`sed`、`grep` 或读取/复制文件。 -9. 只处理当前 chunk 的 `events`,自己追加到 `../proactive_pending.md` 尾部。 -10. 每个 chunk 写一次:写完当前 chunk 的 pending section 后,才能读取下一个 chunk。 -11. 如果当前 chunk 没有合格候选,也写一个 `no_candidate` section,说明这个 chunk 已审核。 -12. 如果 `has_more=true`,按 `next_chunk_index` 调同一个脚本读取下一段,再重复“判断 -> 追加 pending section”。 -13. 如果当前 chunk 返回 `has_more=true`,禁止调用 `finish_drift`。 -14. 只有处理并写入所有 chunk,直到某个 chunk 返回 `has_more=false` 后,才能调用 `finish_drift`。 -15. 不要等 50 条全部看完再写,也不要为每条 event 单独写文件。 -16. 只追加新候选,不修改、不删除已有队列项;写文件时必须保留原文完整前缀,只在末尾增加新 batch/chunk 内容。 -17. 只看 typed Turn sample 中的 proactive 消息和 user 回复文本。assistant 后续回答不是证据来源。 -18. MEMORY 只用于理解长期兴趣边界和查重;新增候选必须由当前 chunk 的 feedback 证据支撑。 -19. `signal_hints` 只是弱提示;最终 topic 粒度、用户态度、effect 都必须由你结合当前 chunk 和 MEMORY 推断。 -20. 不要求用户显式说喜欢或讨厌。追问、纠错、补充背景、切换关注对象、持续互动都可以作为态度证据,但必须解释它对推送决策有什么影响。 -21. topic 粒度由证据决定: - - 同一大类中用户态度不同,必须拆成不同 topic。 - - 不同大类中用户态度相同,也不要为了合并而合并。 - - 如果 topic 只能写成一段原文摘要,说明粒度还没想清楚,宁可不写。 - - 如果只有单条弱信号且无法推断稳定推送决策,不写。 -22. 粒度判定遵守这些规则: - - `topic` 不能用斜杠拼接两个不同对象;如果想写 `A/B`,通常说明应该拆成两条,或改成一个单一父 topic。 - - `topic` 和 `action` 必须同宽。topic 写某个对象时,action 不得扩到同类所有对象;action 想覆盖同类所有对象时,topic 也必须写成该同类,并有多条证据支持。 - - 单条证据可以进入 pending,但 `effect` 优先用 `verify` 或 `tone`;只有用户态度强、或 MEMORY 已支持该方向,才用 `boost/block/timing`。 - - 追问不等于喜欢。追问只能说明“这个 topic 的某个侧面值得继续观察”,除非同 chunk 或 MEMORY 中还有更强证据。 - - `block` 只能用于明确低兴趣、反感、纠错、无实际价值;弱负反馈优先写 `verify`,action 写“降低/收窄/先验证”。 - - `boost` 的 action 用“提高优先级/更容易入选/可继续观察”,不要写“持续推送/优先推送所有相关内容/彻底保留”。 - - `timing` 只处理时机、频率、静默、ack,不承载长期兴趣判断。 - - 不要在 action 中新增 evidence 没出现的对象;比如 topic 只证明某个队伍,就不要把 action 写到其它队伍。 -23. 每条 pending 必须包含 `granularity`,说明为什么这个 topic 这样切,并明确“不扩大到哪里”或“不缩小到哪里”。 -24. 每条 pending 必须包含 `inference`,说明你从哪些用户回应推断出该态度。 -25. 只写会影响具体推送决策的候选。每条必须能回答: - - 什么候选内容会触发? - - 对推送决策有什么动作? - - 证据是哪条 feedback 和哪条 user message? -26. `effect` 只能是: - - `block`:阻止某类候选推送。 - - `boost`:提高某类候选优先级。 - - `verify`:推送前必须额外核验或限定匹配方式。 - - `timing`:改变推送时机、静默、ack 或打扰条件。 - - `tone`:改变同一候选内容的表达方式。 -27. 不写普通生活事实、人设事实、一次性寒暄、测试消息。 -28. evidence 必须包含完整 `feedback#id` 和完整 user message id。 -29. 全部 chunk 都写入成功后,`cursor_update.latest_processed_emotion_sample_id` 必须等于第 0 个 chunk 返回的 `cursor_tail_emotion_sample_id`。 -30. `finish_drift.briefing` 必须使用实际处理结果,写清总样本数、chunk 数、pending 候选条数,不要估算。 -31. 如果只处理了部分 chunk,必须 `status="paused"`,不要推进 `latest_processed_emotion_sample_id`。 - -## proactive_pending.md 格式 - -如果文件为空,先写标题: - -```text -# Proactive Pending - -``` - -每批追加一个 section: - -```text -## Batch feedback#1-feedback#50 - -### Chunk feedback#50-feedback#41 - -- [ ] effect=boost confidence=medium topic="..." granularity="..." inference="..." action="..." evidence=feedback#12 user_message_id=... -- [ ] effect=verify confidence=low topic="..." granularity="..." inference="..." action="..." evidence=feedback#18 user_message_id=... -``` - -要求: - -- 每行只表达一个 topic 级候选规则。 -- 每个 chunk 使用一个 `### Chunk feedback#高-feedback#低` 小标题。 -- `topic` 写可用于未来推送匹配的短名称,不写整段原文。 -- `granularity` 写为什么不放大或缩小 topic。 -- `inference` 写用户对该 topic 的态度如何被推断出来。 -- `action` 写 proactive 决策动作,不写聊天总结。 -- `confidence` 必须写: - - `low`:单条弱信号、追问、短回应、或推断需要后续验证。 - - `medium`:同一 topic 有多条一致证据,或单条 evidence 很明确。 - - `high`:同一 topic 有多条一致证据,并且 MEMORY 也支持。 -- 如果本批没有合格候选,仍追加 section,并写: - -```text -- [ ] no_candidate evidence=feedback#1-feedback#50 reason="本批没有足够明确且会影响推送决策的候选。" -``` - -## 收尾 - -成功追加队列: +审核完整批次后,必须调用普通工具 `emotion_commit_preference_context` 恰好一次: ```json { - "skill_used": "feedback-preference-context", - "status": "completed", - "briefing": "根据 proactive 正反馈样本追加 proactive_pending.md 队列", - "message_result": "silent", - "cursor_update": { - "latest_processed_emotion_sample_id": 123, - "active_cursor_tail_emotion_sample_id": null, - "active_emotion_sample_ids": null - }, - "journal_append": [ + "proposal_id": "proposal 中的原值", + "revision": "proposal 中的原值", + "context": "完整、简短、可直接用于以后主动判断的偏好上下文", + "candidates": [ { - "entry_type": "proactive_pending_appended", - "key": "1-123", - "payload": { - "feedback_ids": [1, 2, 3], - "cursor_tail_emotion_sample_id": 123 - } + "effect": "boost", + "confidence": "medium", + "topic": "明确的单一主题", + "action": "提高同一主题候选的优先级", + "evidence": [12] } ] } ``` -## 约束 +工具会在 Emotion 自有 SQLite 的一个事务中同时保存完整结果、替换 current +context、推进 feedback cursor。工具失败时让本 Turn 失败暴露;不要伪造成功。 + +## 判断规则 + +1. `effect` 只能是 `block`、`boost`、`verify`、`timing`、`tone`。 +2. `confidence` 只能是 `low`、`medium`、`high`。 +3. `evidence` 只能引用当前 proposal `events[].id`;不得引用批次外事实。 +4. 追问不自动等于喜欢。单条弱信号优先用 `verify` 或 `tone`。 +5. `block` 需要明确反感、纠错或无价值;弱负反馈用 `verify` 收窄。 +6. `timing` 只描述时机、频率和打扰条件,不冒充兴趣判断。 +7. topic 和 action 必须同宽,不把单一对象扩大成整个类别。 +8. 没有稳定候选时,`candidates` 传空数组,`context` 原样保留。 +9. 不调用 `message_push`,不编辑任何 Markdown,不读写 `state.json` 或 + `history.json`,不提交另一套 Drift terminal。 -- 一次最多处理 50 条反馈。 -- `explicit_quote` 必须包含,且视为更强证据,但不是自动规则。 -- 只有 `proactive_pending.md` 成功尾部追加后才能推进 `latest_processed_emotion_sample_id`。 -- 写入前必须检查旧内容;禁止用新生成内容覆盖整个 pending 文件。 -- 不打扰用户,不调用 `message_push`。 -- 不读取或写入 `state.json`、`history.json`。 -- 只读 Emotion 数据库;不修改 `emotion.db` 或任何其他插件数据库。 +TurnCommit 后,Emotion 会用普通 Drift selection 对账本次 revision。若本 Turn +没有成功调用工具,完整 proposal receipt 会保留为 `completed_without_commit`, +下一轮产生同一批次的新 revision,而不是丢掉证据或偷偷推进 cursor。 diff --git a/drift/skills/feedback-preference-context/scripts/sample_feedback_context.py b/drift/skills/feedback-preference-context/scripts/sample_feedback_context.py index 8707abc..4f78805 100644 --- a/drift/skills/feedback-preference-context/scripts/sample_feedback_context.py +++ b/drift/skills/feedback-preference-context/scripts/sample_feedback_context.py @@ -3,40 +3,23 @@ import argparse import json import sqlite3 +from collections.abc import Iterator +from contextlib import contextmanager from pathlib import Path -from typing import Any, cast +from typing import Any -SKILL_NAME = "feedback-preference-context" PROACTIVE_TEXT_LIMIT = 100 QUESTION_MARKERS = ("吗", "么", "为什么", "怎么", "谁", "哪") -def _connect(path: Path) -> sqlite3.Connection: +@contextmanager +def _connect(path: Path) -> Iterator[sqlite3.Connection]: conn = sqlite3.connect(f"{path.resolve().as_uri()}?mode=ro", uri=True) conn.row_factory = sqlite3.Row - return conn - - -def _load_cursor(drift_dir: Path) -> dict[str, Any]: - db_path = drift_dir / "drift.db" - if not db_path.exists(): - return {} - with _connect(db_path) as conn: - row = conn.execute( - """ - SELECT cursor_json - FROM skill_continuum - WHERE skill_name = ? - """, - (SKILL_NAME,), - ).fetchone() - if row is None: - return {} try: - data = json.loads(str(row["cursor_json"] or "{}")) - except json.JSONDecodeError: - return {} - return cast(dict[str, Any], data) if isinstance(data, dict) else {} + yield conn + finally: + conn.close() def _clip_text(text: str, limit: int) -> str: @@ -75,47 +58,41 @@ def sample( if not emotion_db.is_file() or emotion_db.is_symlink(): return _empty_result(0, "emotion_db_missing", chunk_index, chunk_size) - cursor = _load_cursor(drift_dir) - last_sample_id = int(cursor.get("latest_processed_emotion_sample_id") or 0) safe_limit = max(1, min(int(limit), 50)) - try: - with _connect(emotion_db) as conn: - rows = conn.execute( - """ - SELECT - id, - created_at, - session_key, - user_message_id, - proactive_message_id, - feedback_type, - confidence, - pa_score, - pua_score, - lag_seconds, - candidate_count, - matched_by, - reason, - user_content_preview, - assistant_content_preview, - proactive_content_preview - FROM emotion_feedback_samples - WHERE id > ? - AND feedback_type IN ('topic_follow', 'explicit_quote') - ORDER BY id DESC - LIMIT ? - """, - (last_sample_id, safe_limit), - ).fetchall() - except sqlite3.OperationalError as exc: - if "no such table: emotion_feedback_samples" not in str(exc): - raise - return _empty_result( - last_sample_id, - "emotion_feedback_samples_missing", - chunk_index, - chunk_size, - ) + with _connect(emotion_db) as conn: + state = conn.execute( + "SELECT processed_feedback_sample_id FROM emotion_preference_state WHERE id=1" + ).fetchone() + if state is None: + raise RuntimeError("Emotion preference singleton 缺失") + last_sample_id = int(state["processed_feedback_sample_id"]) + rows = conn.execute( + """ + SELECT + id, + created_at, + session_key, + user_message_id, + proactive_message_id, + feedback_type, + confidence, + pa_score, + pua_score, + lag_seconds, + candidate_count, + matched_by, + reason, + user_content_preview, + assistant_content_preview, + proactive_content_preview + FROM emotion_feedback_samples + WHERE id > ? + AND feedback_type IN ('topic_follow', 'explicit_quote') + ORDER BY id DESC + LIMIT ? + """, + (last_sample_id, safe_limit), + ).fetchall() if not rows: return _empty_result( diff --git a/feedback_history.py b/feedback_history.py new file mode 100644 index 0000000..bb13c48 --- /dev/null +++ b/feedback_history.py @@ -0,0 +1,205 @@ +from __future__ import annotations + +import asyncio +import sqlite3 +from collections.abc import Callable, Sequence +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Protocol + +from agent.control.timer import TimerHandle, TimerStatus +from agent.plugin_composition import Context, PluginTimers, ServiceKey + +from .db import ( + apply_feedback_history_page, + open_db, + read_feedback_history_cursor, +) + + +_POLL_INTERVAL = timedelta(minutes=1) +_PAGE_SIZE = 50 +_RETRYABLE_SQLITE_CODES = frozenset( + { + sqlite3.SQLITE_BUSY, + sqlite3.SQLITE_LOCKED, + sqlite3.SQLITE_IOERR, + } +) + + +class FeedbackHistoryRecord(Protocol): + cursor: int + event_id: str + payload_hash: str + session_key: str + user_message_id: str + assistant_message_id: str + proactive_message_id: str | None + feedback_type: str + confidence: str + pa_score: float | None + pua_score: float | None + lag_seconds: int | None + candidate_count: int + matched_by: str + reason: str + user_content_preview: str | None + assistant_content_preview: str | None + proactive_content_preview: str | None + + +class FeedbackHistoryPage(Protocol): + after_cursor: int + records: Sequence[FeedbackHistoryRecord] + + +class FeedbackHistory(Protocol): + def page(self, *, after_cursor: int, max_items: int) -> FeedbackHistoryPage: ... + + +PROACTIVE_FEEDBACK_HISTORY = ServiceKey[FeedbackHistory]( + "proactive-feedback.history.v1" +) + + +class FeedbackHistoryConsumer: + """Pull PF accepted history into Emotion through one ordinary Timer chain.""" + + def __init__( + self, + ctx: Context, + root: Path, + timers: PluginTimers, + history: FeedbackHistory, + *, + now: Callable[[], datetime] = lambda: datetime.now(UTC), + ) -> None: + self._ctx = ctx + self._db_path = root / "emotion.db" + self._timers = timers + self._history = history + self._now = now + self._runner: asyncio.Task[None] | None = None + self._handle: TimerHandle | None = None + self._closed = False + + async def start(self) -> None: + if self._closed: + raise RuntimeError("Emotion PF history consumer 已关闭") + if self._runner is not None: + return + self._runner = await self._ctx.spawn( + self._run(), + name="emotion-feedback-history", + ) + + async def close(self) -> None: + self._closed = True + handle = self._handle + runner = self._runner + self._handle = None + self._runner = None + if handle is not None: + _ = await handle.cancel() + if runner is not None and runner is not asyncio.current_task(): + _ = await asyncio.gather(runner, return_exceptions=True) + if handle is not None: + await handle.cleanup() + + def tick_once(self) -> bool: + """Pull and atomically apply one page, returning whether it was full.""" + + # 1. A missing Emotion DB begins at zero without creating state for an empty page. + cursor = 0 + if self._db_path.exists(): + connection = open_db(self._db_path) + try: + cursor = read_feedback_history_cursor(connection) + finally: + connection.close() + page = self._history.page(after_cursor=cursor, max_items=_PAGE_SIZE) + if page.after_cursor != cursor: + raise RuntimeError("PF history page after_cursor 与请求不一致") + if not page.records: + return False + + # 2. Convert the structural cross-plugin DTO at the trust boundary. + records = [_record_payload(record) for record in page.records] + connection = open_db(self._db_path) + try: + applied_cursor = apply_feedback_history_page(connection, records) + finally: + connection.close() + if applied_cursor != records[-1]["cursor"]: + raise RuntimeError("Emotion PF history cursor 未推进到页尾") + return len(records) == _PAGE_SIZE + + async def _run(self) -> None: + """Use Timer receipts for every pull and retry only explicit transient I/O.""" + + deadline = self._aware_now() + while not self._closed: + handle = self._timers.schedule(deadline) + self._handle = handle + try: + receipt = await handle.result() + finally: + await handle.cleanup() + if self._handle is handle: + self._handle = None + if receipt.status is TimerStatus.CANCELLED: + continue + try: + full_page = self.tick_once() + except OSError as error: + self._report_transient(error) + full_page = False + except sqlite3.OperationalError as error: + if getattr(error, "sqlite_errorcode", None) not in _RETRYABLE_SQLITE_CODES: + raise + self._report_transient(error) + full_page = False + deadline = ( + self._aware_now() + if full_page + else self._aware_now() + _POLL_INTERVAL + ) + + def _report_transient(self, error: BaseException) -> None: + _ = self._ctx.report_incident( + "emotion_feedback_history_transient", + f"Emotion feedback history 暂态失败: {error}", + ) + + def _aware_now(self) -> datetime: + value = self._now() + if value.tzinfo is None: + raise ValueError("Emotion feedback history clock 必须带时区") + return value.astimezone(UTC) + + +def _record_payload(record: FeedbackHistoryRecord) -> dict[str, object]: + return { + field: getattr(record, field) + for field in ( + "cursor", + "event_id", + "payload_hash", + "session_key", + "user_message_id", + "assistant_message_id", + "proactive_message_id", + "feedback_type", + "confidence", + "pa_score", + "pua_score", + "lag_seconds", + "candidate_count", + "matched_by", + "reason", + "user_content_preview", + "assistant_content_preview", + "proactive_content_preview", + ) + } diff --git a/plugin.py b/plugin.py index dc45022..18cf7d3 100644 --- a/plugin.py +++ b/plugin.py @@ -1,152 +1,109 @@ from __future__ import annotations -import hashlib -import json from pathlib import Path from collections.abc import Mapping -from typing import Any, cast +from typing import Any +from agent.lifecycle.composition import CONTEXT_PREPARED_EVENT from agent.plugin_composition import ( - BACKGROUND_JOBS, - BackgroundJobDefinition, + RUNTIME_STARTED, + RUNTIME_STOPPING, + TIMERS, + TOOL_CATALOG, Context, - CoreEvent, - CoreEventTrigger, MobileUiDefinition, MobileUiNavigation, MobileUiRpcInvalidRequest, - PROACTIVE_COMPONENTS, - ProactiveModuleDefinition, + PluginToolDefinition, + ServiceKey, UI_SLOTS, ) from agent.turn_events.after_turn import AFTER_TURN_COMMITTED -from bus.events_lifecycle import DriftFinished, TurnCommitted -from agent.plugins.generation_proactive_host import ( - ProactiveModuleContext, - ProactiveModuleOutcome, -) +from bus.events_lifecycle import TurnCommitted -from .db import ( - apply_feedback, - build_effect, - commit_domain_effect, - lookup_effect, - lookup_domain_effect, - lookup_domain_effect_path, - open_db, -) +from .db import apply_feedback, open_db from .dashboard import EmotionDashboardReader +from .feedback_history import ( + PROACTIVE_FEEDBACK_HISTORY, + FeedbackHistoryConsumer, +) +from .runtime import DriftProposalServices, DriftWakeServices, EmotionRuntime api_version = 3 name = "emotion" version = "3.0.0" -desc = "Proactive VAD state and feedback preference projection." -inject = (BACKGROUND_JOBS, PROACTIVE_COMPONENTS, UI_SLOTS) +desc = "Timer-refreshed Emotion context and ordinary Drift preference projection." +DRIFT_PROPOSALS = ServiceKey[DriftProposalServices]("drift.proposals.v1") +DRIFT_WAKE = ServiceKey[DriftWakeServices]("drift.wake.v1") +inject = (TIMERS, TOOL_CATALOG, UI_SLOTS, DRIFT_PROPOSALS, DRIFT_WAKE) workspace_roots = ("emotion",) drift_skill_roots = ("drift/skills",) dashboard_module = "dashboard.py" _v3_emotion_root: Path | None = None -_v3_emotion_module: "EmotionProjectionModule | None" = None +_v3_emotion_runtime: EmotionRuntime | None = None -_FEEDBACK_CONTEXT_SKILL = "feedback-preference-context" _FEEDBACK_PREVIEW_MAX_CHARS = 2400 -_PROACTIVE_CONTEXT_TEMPLATE = """# Proactive Context - -在这里写会影响未来主动推送取舍的稳定偏好。 - -- 主 agent 负责维护这份文件。 -- proactive agent 每轮都会读取它作为额外上下文。 -- 优先写短规则和倾向,避免写流程文档。 -- 这里不提供新闻事实,不提供候选内容。 -- 写结论即可,不要写冗长过程。 -""" - -_MERGE_PROACTIVE_CONTEXT_SYSTEM = ( - "你是 proactive context editor。" - "你的职责是把候选反馈保守蒸馏成短规则,帮助未来主动推送更合适。" - "不要把一次反馈扩写成策略文档,也不要制造新的硬约束。" -) - -_MERGE_PROACTIVE_CONTEXT_PROMPT = """\ -你的任务是把「待合并推送偏好候选」融合进「当前 Proactive Context」。 - -## 合并原则 -- 只保留会稳定影响未来主动推送决策的偏好。 -- 多条反馈支持同一方向时,可以写成规则;单条或弱证据只能写成轻量倾向。 -- 把 pending 聚类成少量稳定主题;37 条候选也最多沉淀成 8 条新增或修改规则。 -- pending 中 effect=boost/block/verify/timing/tone 分别对应提高优先级、降低/屏蔽、推送前核验、时机、表达方式。 -- 合并同类项,删除重复、过窄、证据不足、no_candidate、临时状态和一次性事件。 -- 优先修改现有相关 bullet,不要为轻量倾向新建 section。 -- 保留当前 Proactive Context 中仍然有效的规则,但可以压缩被 pending 触及的冗长段落。 - -## 禁止事项 -- 不写 evidence id、feedback id、message id、chunk、计数、推理过程或审核状态。 -- 不写“数据来源”“触发条件”“执行注意”“计算逻辑”“白名单每周更新”这类流程说明。 -- 不因为一条反馈就写“仅推送”“禁止”“一律过滤”“必须查询”这类硬规则。 -- 不扩写成用户画像、聊天总结、新闻事实或配置文档。 -- 不新增带“新增”“其他倾向”“通用规则”这类兜底标题的小节。 - -## 输出格式 -- 直接输出完整 `# Proactive Context` markdown。 -- 新增或修改的规则尽量一行一个 bullet,必要时最多两行。 -- 全文长度不要超过当前 Proactive Context 的 1.25 倍。 -- 优先使用“优先/降权/避免/保持/可适度”这类运行时上下文表达。 -- 不要代码块,不要解释。 - ---- -当前 Proactive Context: -{current_context} - -待合并推送偏好候选: -{pending} -""" async def apply(ctx: Context, config: object) -> None: - """登记 Emotion 的 proactive module、typed Turn observer、job 与 UI。""" + """Compose Emotion from Timer, lifecycle, Drift, Tool, and UI atoms.""" - # 1. 只冻结 Core 投影的 generation-local 数据根,不打开数据库或调用模型。 + # 1. 冻结 exact Root;apply/candidate 不打开数据库、不登记真实 Timer。 del config - global _v3_emotion_module, _v3_emotion_root + global _v3_emotion_root, _v3_emotion_runtime _v3_emotion_root = ctx.workspace_root("emotion") emotion_root = _v3_emotion_root - _v3_emotion_module = EmotionProjectionModule(emotion_root) - - # 2. Proactive module 只在 formal domain-effect facade 中提交 SQLite 状态。 - await ctx.require(PROACTIVE_COMPONENTS).register( + runtime = EmotionRuntime( ctx, - ProactiveModuleDefinition( - slot="proactive.prompt.emotion", - lifecycle_id="default.proactive.frame.v1", - produces=( - "proactive:prompt:system_bottom:emotion", - "proactive:effect:emotion", - ), - handler_export="run_emotion_prompt_v3", - domain_effect="emotion.state", - domain_effect_lookup_export="lookup_emotion_domain_effect_v3", - ), + emotion_root, + ctx.require(TIMERS), + ctx.require(DRIFT_PROPOSALS), + ctx.require(DRIFT_WAKE), ) + _v3_emotion_runtime = runtime - # 3. JobHost 独占 event admission、LLM lease、effect receipt 与文档提交。 - await ctx.require(BACKGROUND_JOBS).register( + # 2. Drift 结果只经普通 Tool 进入 Emotion 自有事务。 + await ctx.require(TOOL_CATALOG).register( ctx, - BackgroundJobDefinition( - name="merge_proactive_pending", - triggers=(CoreEventTrigger(CoreEvent.DRIFT_FINISHED),), - handler_export="merge_proactive_pending_v3", - documents_scope=("emotion",), - domain_effect="emotion.state", - domain_effect_lookup_export="lookup_emotion_domain_effect_v3", - model_role="agent", + PluginToolDefinition( + name="emotion_commit_preference_context", + description=( + "提交当前 Emotion Drift proposal 的稳定主动偏好;" + "完整结果进入 Emotion history,current context 原位替换。" + ), + parameters=_commit_tool_schema(), + handler_export="emotion_commit_preference_context", + risk="read-write", + search_hint="emotion drift preference feedback", ), + handler=runtime.commit_preference_context, ) - # 4. TurnCommitted 是反馈唯一的 typed owner;listener 固定当前 Root。 + # 3. Timer/lifecycle/Turn observer 都属于同一个 generation Fiber。 def on_turn_committed(event: TurnCommitted) -> None: - _on_turn_committed(event, root=emotion_root) + _on_turn_committed(event, root=emotion_root, runtime=runtime) await ctx.on(AFTER_TURN_COMMITTED, on_turn_committed) + await ctx.on(CONTEXT_PREPARED_EVENT, runtime.prepare_context) + await ctx.on(RUNTIME_STARTED, lambda _: runtime.start()) + await ctx.on(RUNTIME_STOPPING, lambda _: runtime.close()) + + # 4. PF history is an optional pull composition with its own Timer/Fiber. + async def apply_feedback_history(child: Context) -> None: + consumer = FeedbackHistoryConsumer( + child, + emotion_root, + child.require(TIMERS), + child.require(PROACTIVE_FEEDBACK_HISTORY), + ) + await child.on(RUNTIME_STARTED, lambda _: consumer.start()) + await child.on(RUNTIME_STOPPING, lambda _: consumer.close()) + + _ = await ctx.inject( + (TIMERS, PROACTIVE_FEEDBACK_HISTORY), + apply_feedback_history, + name="proactive-feedback-history", + ) # 5. Mobile 只读查询与静态资源绑定到同一个 generation Root。 def mobile_query( @@ -178,122 +135,17 @@ def mobile_query( ) -class EmotionProjectionModule: - """Build one proactive emotion projection and submit its domain receipt.""" - - def __init__(self, root: Path) -> None: - self._root = root - - async def run( - self, - context: ProactiveModuleContext, - frame: ProactiveModuleOutcome, - ) -> ProactiveModuleOutcome: - """Persist one formal frame effect through Core's exact domain facade.""" - - # 1. Resolve the only allowed domain effect and derive stable frame inputs. - effects = getattr(context, "domain_effects", None) - if effects is None or not callable(getattr(effects, "run", None)): - raise RuntimeError( - "emotion proactive module 缺少 Core-owned domain effects facade" - ) - session_key = str( - frame.slots.get("proactive:session_key") or frame.input.session_key - ) - base_threshold = float( - frame.slots.get("proactive:base_judge_send_threshold") or 0.60 - ) - last_user_at = frame.slots.get("proactive:last_user_at") - if last_user_at is not None and not hasattr(last_user_at, "tzinfo"): - raise TypeError("emotion last_user_at 必须是 datetime 或 None") - tick_id = f"{frame.input.session_key}:{frame.input.started_at.isoformat()}" - result: dict[str, Any] = {} - - # 2. Core signs the effect only after this SQLite transaction has a durable receipt. - def transaction(effect_context: object) -> None: - exact_context = cast(Any, effect_context) - conn = open_db(self._root / "emotion.db") - try: - conn.execute("BEGIN IMMEDIATE") - if ( - exact_context.event_id != tick_id - or exact_context.tick_id != tick_id - ): - raise RuntimeError("Emotion proactive tick identity 与 Core 不一致") - effect: dict[str, Any] - result["effect"] = build_effect( - conn, - tick_id=tick_id, - session_key=session_key, - now_utc=frame.input.started_at, - last_user_at=cast(Any, last_user_at), - base_threshold=base_threshold, - commit=False, - ) - effect = cast(dict[str, Any], result["effect"]) - _ = commit_domain_effect( - conn, - semantic_job_id=exact_context.semantic_job_id, - event_id=exact_context.event_id, - invocation_id=exact_context.invocation_id, - effect_id=exact_context.effect_id, - idempotency_key=exact_context.idempotency_key, - attempt=exact_context.attempt, - result_digest=_effect_digest(effect), - ) - conn.commit() - except BaseException: - conn.rollback() - raise - finally: - conn.close() - - await effects.run("emotion.state", transaction) - effect = result.get("effect") - if not isinstance(effect, dict): - conn = open_db(self._root / "emotion.db") - try: - effect = lookup_effect(conn, tick_id=tick_id) - finally: - conn.close() - if not isinstance(effect, dict): - raise RuntimeError("emotion domain effect 未返回 frame projection") - frame.slots["proactive:prompt:system_bottom:emotion"] = str( - effect.get("prompt_section") or "" - ) - frame.slots["proactive:effect:emotion"] = effect - return frame - - -def _effect_digest(effect: Mapping[str, object]) -> str: - """Return the stable digest Core records for one committed projection.""" - - payload = json.dumps( - effect, - ensure_ascii=False, - sort_keys=True, - separators=(",", ":"), - allow_nan=False, - ).encode("utf-8") - return hashlib.sha256(payload).hexdigest() - - -async def run_emotion_prompt_v3( - context: ProactiveModuleContext, - frame: ProactiveModuleOutcome, -) -> ProactiveModuleOutcome: - """Run the exact-generation emotion proactive module.""" - - module = _v3_emotion_module - if module is None: - raise RuntimeError("emotion v3 generation 尚未完成 apply") - return await module.run(context, frame) - - -def _on_turn_committed(event: TurnCommitted, *, root: Path | None = None) -> None: +def _on_turn_committed( + event: TurnCommitted, + *, + root: Path | None = None, + runtime: EmotionRuntime | None = None, +) -> None: """Project one typed committed Turn into Emotion's idempotent SQLite state.""" - feedback = _feedback_from_turn(event) + if runtime is not None: + runtime.observe_turn(event) + feedback = _explicit_quote_feedback_from_turn(event) if feedback is None: return root = _require_v3_emotion_root() if root is None else root @@ -311,50 +163,11 @@ def _on_turn_committed(event: TurnCommitted, *, root: Path | None = None) -> Non conn.close() -def _feedback_from_turn(event: TurnCommitted) -> dict[str, Any] | None: - """Read an optional typed feedback result, with explicit quote as the local fallback.""" - - raw = event.extra.get("proactive_feedback") - if isinstance(raw, Mapping): - feedback_type = raw.get("feedback_type") - confidence = raw.get("confidence") - if isinstance(feedback_type, str) and isinstance(confidence, str): - source = raw.get("event_id") or event.turn_id or event.persisted_user_message_id - if isinstance(source, str) and source: - payload = { - "feedback_event_id": str(source), - "user_message_id": event.persisted_user_message_id, - "assistant_message_id": event.assistant_message_id, - "proactive_message_id": raw.get("proactive_message_id"), - "feedback_type": feedback_type, - "confidence": confidence, - "pua_score": raw.get("pua_score"), - "lag_seconds": raw.get("lag_seconds"), - "matched_by": raw.get("matched_by", "typed_turn"), - "candidate_count": raw.get("candidate_count"), - "pa_score": raw.get("pa_score"), - "reason": raw.get("reason", "typed_turn"), - "user_content_preview": _feedback_preview( - raw.get("user_content_preview") - or event.persisted_user_message - or event.input_message - ), - "assistant_content_preview": _feedback_preview( - raw.get("assistant_content_preview") or event.assistant_response - ), - "proactive_content_preview": _feedback_preview( - raw.get("proactive_content_preview") - or _quoted_proactive_text(event.input_message) - ), - } - return { - "source_event_id": f"proactive_feedback:{source}", - "feedback_type": feedback_type, - "confidence": confidence, - "payload": payload, - } - - # A quote is already an explicit feedback signal carried by the committed user message. +def _explicit_quote_feedback_from_turn( + event: TurnCommitted, +) -> dict[str, Any] | None: + """Project Emotion's direct explicit-quote signal in its own namespace.""" + marker = "【你当前新消息】" source = event.turn_id or event.persisted_user_message_id if marker not in event.input_message or not isinstance(source, str) or not source: @@ -381,7 +194,7 @@ def _feedback_from_turn(event: TurnCommitted) -> dict[str, Any] | None: ), } return { - "source_event_id": f"proactive_feedback:{source}", + "source_event_id": f"emotion_explicit_quote:{source}", "feedback_type": "explicit_quote", "confidence": "gold", "payload": payload, @@ -428,74 +241,62 @@ def _mobile_ui_query( ) -async def merge_proactive_pending_v3(ctx: Any) -> None: - """形成 merge 内容,并经 Core receipt fence 发布两份 proactive 文档。""" +async def emotion_commit_preference_context( + tool_context: object, + arguments: Mapping[str, object], +) -> Mapping[str, object]: + """Delegate legacy export lookup to the exact Root runtime.""" - # 1. 非目标 Drift completion 不读取文档、不调用模型。 - event = ctx.event - if not isinstance(event, DriftFinished): - return - if event.skill_name != _FEEDBACK_CONTEXT_SKILL or event.status != "completed": - return - if ctx.documents is None or ctx.domain_effects is None: - raise RuntimeError("emotion merge job 缺少 Core documents/domain effects") + runtime = _v3_emotion_runtime + if runtime is None: + raise RuntimeError("emotion v3 generation 尚未完成 apply") + return await runtime.commit_preference_context(tool_context, arguments) - # 2. 从窄 port 读取脱离 bytes,生成新文档并先持久化完整 intent。 - expected, current = ctx.documents.read_pair() - pending = current.pending.decode("utf-8").strip() - if not pending or "- [ ]" not in pending: - return - current_context = current.context.decode("utf-8").strip() - if not current_context: - current_context = _PROACTIVE_CONTEXT_TEMPLATE.strip() - prompt = _MERGE_PROACTIVE_CONTEXT_PROMPT.format( - current_context=current_context, - pending=pending, - ) - merged = await ctx.llm.generate_text( - system=_MERGE_PROACTIVE_CONTEXT_SYSTEM, - prompt=prompt, - max_tokens=4096, - ) - if not merged: - return - pair = { - "context": merged.strip().encode("utf-8") + b"\n", - "pending": b"", + +def _commit_tool_schema() -> dict[str, object]: + return { + "type": "object", + "properties": { + "proposal_id": {"type": "string"}, + "revision": {"type": "string"}, + "context": { + "type": "string", + "description": "完整且简短的当前主动偏好上下文。", + }, + "candidates": { + "type": "array", + "items": { + "type": "object", + "properties": { + "effect": { + "type": "string", + "enum": ["block", "boost", "timing", "tone", "verify"], + }, + "confidence": { + "type": "string", + "enum": ["high", "low", "medium"], + }, + "topic": {"type": "string"}, + "action": {"type": "string"}, + "evidence": { + "type": "array", + "items": {"type": "integer", "minimum": 1}, + }, + }, + "required": [ + "effect", + "confidence", + "topic", + "action", + "evidence", + ], + "additionalProperties": False, + }, + }, + }, + "required": ["proposal_id", "revision", "context", "candidates"], + "additionalProperties": False, } - intent = await ctx.documents.prepare_pair(expected, pair) - result_digest = hashlib.sha256(pair["context"] + b"\0" + pair["pending"]).hexdigest() - - # 3. Emotion SQLite 提交领域 receipt 后,Core 才能向前提交文档。 - async def transaction(effect_ctx: Any) -> None: - db = open_db(_require_v3_emotion_root() / "emotion.db") - try: - _ = commit_domain_effect( - db, - semantic_job_id=effect_ctx.semantic_job_id, - event_id=effect_ctx.event_id or event.event_id, - invocation_id=effect_ctx.invocation_id, - effect_id=effect_ctx.effect_id, - idempotency_key=effect_ctx.idempotency_key, - attempt=effect_ctx.attempt, - result_digest=result_digest, - ) - finally: - db.close() - - receipt = await ctx.domain_effects.run("emotion.state", transaction) - _ = await ctx.documents.commit_after(intent, receipt) - - -def lookup_emotion_domain_effect_v3(effect_ctx: Any) -> object | None: - """只读返回 Emotion durable receipt,供 Core 重签 exact capability。""" - - return lookup_domain_effect_path( - _require_v3_emotion_root() / "emotion.db", - invocation_id=effect_ctx.invocation_id, - effect_id=effect_ctx.effect_id, - idempotency_key=effect_ctx.idempotency_key, - ) def _require_v3_emotion_root() -> Path: diff --git a/runtime.py b/runtime.py new file mode 100644 index 0000000..1aac0c8 --- /dev/null +++ b/runtime.py @@ -0,0 +1,274 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Callable, Mapping +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Protocol, cast + +from agent.control.timer import TimerHandle, TimerStatus +from agent.lifecycle.types import BeforeTurnCtx +from agent.plugin_composition import Context, PluginTimers +from bus.events_lifecycle import TurnCommitted + +from .db import ( + commit_drift_result, + mark_drift_proposal_submitted, + open_db, + prepare_drift_proposal, + read_fresh_context, + record_drift_turn, + record_user_activity, + refresh_current_context, +) + + +_REFRESH_INTERVAL = timedelta(minutes=5) +_FRESH_SECONDS = int(_REFRESH_INTERVAL.total_seconds() * 2) +_CANDIDATE_EFFECTS = frozenset({"boost", "block", "verify", "timing", "tone"}) +_CANDIDATE_CONFIDENCE = frozenset({"low", "medium", "high"}) + + +class DriftProposalServices(Protocol): + def propose( + self, + proposal_id: str, + revision: str, + payload: Mapping[str, object], + due_at: datetime, + *, + next_due: datetime | None = None, + ) -> Mapping[str, object]: ... + + +class DriftWakeServices(Protocol): + def selection( + self, accepted_turn: Mapping[str, object] + ) -> Mapping[str, object] | None: ... + + +class EmotionRuntime: + """Refresh current context and bridge Emotion evidence into ordinary Drift.""" + + def __init__( + self, + ctx: Context, + root: Path, + timers: PluginTimers, + proposals: DriftProposalServices, + drift: DriftWakeServices, + *, + now: Callable[[], datetime] = lambda: datetime.now(UTC), + ) -> None: + self._ctx = ctx + self._db_path = root / "emotion.db" + self._timers = timers + self._proposals = proposals + self._drift = drift + self._now = now + self._runner: asyncio.Task[None] | None = None + self._handle: TimerHandle | None = None + self._closed = False + + async def start(self) -> None: + """Start one Fiber-owned immediate tick and recurring one-shot chain.""" + + if self._closed: + raise RuntimeError("Emotion runtime 已关闭") + if self._runner is not None: + return + self._runner = await self._ctx.spawn( + self._run(), + name="emotion-current-refresh", + ) + + async def close(self) -> None: + """Cancel the owned Timer/task without changing durable Emotion facts.""" + + self._closed = True + handle = self._handle + runner = self._runner + self._handle = None + self._runner = None + if handle is not None: + _ = await handle.cancel() + if runner is not None and runner is not asyncio.current_task(): + _ = await asyncio.gather(runner, return_exceptions=True) + if handle is not None: + await handle.cleanup() + + async def tick_once(self) -> None: + """Refresh the singleton, then idempotently submit one pending proposal.""" + + now = self._aware_now() + db = open_db(self._db_path) + try: + _ = refresh_current_context(db, now=now) + proposal = prepare_drift_proposal(db, now=now) + finally: + db.close() + if proposal is None: + return + proposal_id = cast(str, proposal["proposal_id"]) + revision = cast(str, proposal["revision"]) + payload = cast(Mapping[str, object], proposal["payload"]) + _ = self._proposals.propose( + proposal_id, + revision, + payload, + now, + next_due=now + _REFRESH_INTERVAL, + ) + db = open_db(self._db_path) + try: + mark_drift_proposal_submitted( + db, + proposal_id=proposal_id, + revision=revision, + ) + finally: + db.close() + + def prepare_context(self, ctx: BeforeTurnCtx) -> None: + """Append only a fresh current hint to Wake Turns.""" + + if ctx.channel != "wake": + return + prompt = read_fresh_context( + self._db_path, + now=ctx.timestamp, + max_age_seconds=_FRESH_SECONDS, + ) + if prompt is not None: + ctx.extra_hints.append("Emotion current:\n" + prompt) + + def observe_turn(self, event: TurnCommitted) -> None: + """Record user activity and the selected ordinary Drift terminal.""" + + # 1. Only committed non-Wake user messages update the current presence input. + if event.channel != "wake" and event.persisted_user_message is not None: + at = event.timestamp or self._aware_now() + db = open_db(self._db_path) + try: + record_user_activity(db, at) + finally: + db.close() + + # 2. TurnCommitted runs before Wake settles selection, so the selected ref is readable. + if event.channel != "wake" or not event.turn_id: + return + selected = self._drift.selection( + {"session_id": event.session_key, "turn_id": event.turn_id} + ) + if selected is None: + return + payload = selected.get("payload") + ref = selected.get("ref") + if not isinstance(payload, Mapping) or payload.get("owner") != "emotion": + return + if not isinstance(ref, Mapping): + raise RuntimeError("Emotion Drift selection 缺少 ref") + db = open_db(self._db_path) + try: + _ = record_drift_turn( + db, + proposal_id=_text(ref.get("proposal_id"), "proposal_id"), + revision=_text(ref.get("revision"), "revision"), + session_id=event.session_key, + turn_id=event.turn_id, + completed_at=event.timestamp or self._aware_now(), + ) + finally: + db.close() + + async def commit_preference_context( + self, + _tool_context: object, + arguments: Mapping[str, object], + ) -> Mapping[str, object]: + """Validate and atomically commit one Emotion-owned Drift result.""" + + proposal_id = _text(arguments.get("proposal_id"), "proposal_id") + revision = _text(arguments.get("revision"), "revision") + context = _text(arguments.get("context"), "context") + candidates = _candidates(arguments.get("candidates")) + result: Mapping[str, object] = { + "context": context, + "candidates": candidates, + } + db = open_db(self._db_path) + try: + return commit_drift_result( + db, + proposal_id=proposal_id, + revision=revision, + result=result, + ) + finally: + db.close() + + async def _run(self) -> None: + """Retry only explicit transient OS failures on the next ordinary tick.""" + + while not self._closed: + try: + await self.tick_once() + except OSError as exc: + _ = self._ctx.report_incident( + "emotion_tick_transient", + f"Emotion refresh/proposal 暂态失败: {exc}", + ) + if self._closed: + return + handle = self._timers.schedule(self._aware_now() + _REFRESH_INTERVAL) + self._handle = handle + try: + receipt = await handle.result() + finally: + await handle.cleanup() + if self._handle is handle: + self._handle = None + if receipt.status is TimerStatus.CANCELLED: + continue + + def _aware_now(self) -> datetime: + now = self._now() + if now.tzinfo is None: + raise ValueError("Emotion clock 必须带时区") + return now.astimezone(UTC) + + +def _text(value: object, field: str) -> str: + if not isinstance(value, str) or not value or value.strip() != value: + raise ValueError(f"{field} 必须是无首尾空白的非空字符串") + return value + + +def _candidates(value: object) -> list[dict[str, object]]: + if not isinstance(value, list): + raise ValueError("candidates 必须是 array") + result: list[dict[str, object]] = [] + for item in value: + if not isinstance(item, Mapping): + raise ValueError("candidate 必须是 object") + effect = _text(item.get("effect"), "candidate.effect") + confidence = _text(item.get("confidence"), "candidate.confidence") + if effect not in _CANDIDATE_EFFECTS: + raise ValueError(f"未知 candidate.effect: {effect}") + if confidence not in _CANDIDATE_CONFIDENCE: + raise ValueError(f"未知 candidate.confidence: {confidence}") + evidence = item.get("evidence") + if not isinstance(evidence, list) or any( + type(sample_id) is not int or sample_id <= 0 for sample_id in evidence + ): + raise ValueError("candidate.evidence 必须是正整数 array") + result.append( + { + "effect": effect, + "confidence": confidence, + "topic": _text(item.get("topic"), "candidate.topic"), + "action": _text(item.get("action"), "candidate.action"), + "evidence": list(evidence), + } + ) + return result diff --git a/tests/test_feedback_sample.py b/tests/test_feedback_sample.py index d203a57..ccfa5d7 100644 --- a/tests/test_feedback_sample.py +++ b/tests/test_feedback_sample.py @@ -1,7 +1,9 @@ from __future__ import annotations +import gc import importlib.util import sys +import warnings from datetime import datetime, timezone from pathlib import Path @@ -73,9 +75,9 @@ def test_typed_turn_feedback_is_readable_without_legacy_databases(tmp_path: Path assert result["found"] is True assert result["count"] == 1 event = result["events"][0] - assert event["feedback_type"] == "topic_follow" + assert event["feedback_type"] == "explicit_quote" assert event["message_ids"] == { - "proactive": "proactive-sample-1", + "proactive": "", "user": "user-sample-1", } assert event["texts"] == { @@ -106,3 +108,17 @@ def test_feedback_sample_empty_is_closed_without_creating_legacy_reads(tmp_path: assert result["found"] is False assert result["reason"] == "emotion_feedback_samples_empty" assert not (tmp_path / "sessions.db").exists() + + +def test_feedback_sample_closes_read_only_connection(tmp_path: Path) -> None: + db = PLUGIN.open_db(tmp_path / "emotion" / "emotion.db") + db.close() + _ = gc.collect() + + with warnings.catch_warnings(record=True) as seen: + warnings.simplefilter("always", ResourceWarning) + for _ in range(4): + _ = SAMPLE.sample(tmp_path / "drift", 50, 10, 0) + _ = gc.collect() + + assert [warning for warning in seen if warning.category is ResourceWarning] == [] diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 14a29fe..e52f4d2 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -1,52 +1,42 @@ from __future__ import annotations import asyncio +import gc import hashlib import importlib.util import inspect -import os -import shutil -import subprocess +import json +import sqlite3 import sys -from datetime import datetime, timedelta, timezone +import warnings +from contextlib import closing +from datetime import UTC, datetime, timedelta from pathlib import Path from types import SimpleNamespace from typing import Any, cast import pytest +from agent.control.timer import TimerReceipt, TimerStatus +from agent.lifecycle.types import BeforeTurnCtx from agent.plugin_composition import ( - BACKGROUND_JOBS, - PROACTIVE_COMPONENTS, + TIMERS, + TOOL_CATALOG, UI_SLOTS, CompositionRoot, PluginRuntime, + PluginTimers, + RUNTIME_STARTED, + RUNTIME_STOPPING, + RuntimeStarted, + RuntimeStopping, ) -from agent.plugin_composition.background_jobs import ( - PluginBackgroundJobs, - _freeze_plugin_background_jobs, -) -from agent.plugin_composition.proactive import ( - PluginProactiveComponents, - _freeze_plugin_proactive_components, -) +from agent.plugin_composition.tool_catalog import PluginTools, _freeze_plugin_tools from agent.plugin_composition.ui_slots import PluginUiSlots -from agent.plugins.generation_activity_host import ActivityHost -from agent.plugins.generation_proactive_bridge import CommittedProactiveBridge -from agent.plugins.generation_proactive_host import ( - ProactiveActivityAdapter, - ProactiveModuleOutcome, -) -from agent.plugins.manager import PluginManager -from agent.plugins.proactive_documents import ( - ProactiveDocumentDigests, - ProactiveDocumentPair, -) -from bus.events_lifecycle import DriftFinished, TurnCommitted -from bus.event_bus import EventBus -from proactive_v2.frame import ProactiveTickInput +from bus.events_lifecycle import TurnCommitted +from plugins.drift.store import DriftStore -ProactiveFrame = ProactiveModuleOutcome +NOW = datetime(2026, 8, 23, 8, tzinfo=UTC) def _load_plugin_module(): @@ -65,108 +55,50 @@ def _load_plugin_module(): module = _load_plugin_module() -async def _mount_runtime(tmp_path: Path) -> tuple[CompositionRoot, Path]: - workspace = tmp_path / "workspace" - emotion_root = workspace / "emotion" - emotion_root.mkdir(parents=True) - root = CompositionRoot("emotion-v3") - jobs = PluginBackgroundJobs(root.instance_token) - proactive = PluginProactiveComponents(root.instance_token) - ui = PluginUiSlots() - _ = await root.context.provide(BACKGROUND_JOBS, jobs) - _ = await root.context.provide(PROACTIVE_COMPONENTS, proactive) - _ = await root.context.provide(UI_SLOTS, ui) - return root, emotion_root +refresh_current_context = sys.modules[ + "test_emotion_plugin.db" +].refresh_current_context -def _copy_emotion_plugin(tmp_path: Path) -> Path: - """Copy the plugin into an isolated discovery root for Manager tests.""" +class DriftServices: + """Expose both ordinary Drift ports over the real Core store.""" - source = Path(__file__).parents[1] - target = tmp_path / "plugins" / "emotion" - shutil.copytree( - source, - target, - ignore=shutil.ignore_patterns( - ".git", ".akashic-core", "__pycache__", ".pytest_cache" - ), - ) - return target + def __init__(self, path: Path) -> None: + self.store = DriftStore(path) + self.store.initialize() + def propose(self, *args: object, **kwargs: object) -> dict[str, object]: + return self.store.propose(*args, **kwargs) # pyright: ignore[reportArgumentType] -async def _start_emotion_manager( - tmp_path: Path, - plugin_dir: Path | None = None, -) -> PluginManager: - """Boot one real Manager generation with Core's ActivityHost owner.""" - - plugin_dir = plugin_dir or _copy_emotion_plugin(tmp_path) - manager = PluginManager( - plugin_dirs=[plugin_dir.parent], - event_bus=EventBus(), - tool_registry=None, - workspace=tmp_path / "workspace", - installed_cache_root=tmp_path / "cache", - ) - manager.bind_activity_host(ActivityHost((ProactiveActivityAdapter(),))) - await manager.load_all() - return manager - - -async def _run_manager_tick( - manager: PluginManager, - frame: ProactiveFrame, -) -> ProactiveFrame: - """Run one proactive module through the exact stable Root and lease.""" - - snapshot = manager.current_snapshot - activity = manager.activity_host - if snapshot is None or activity is None: - raise AssertionError("Manager 未发布 stable snapshot/activity") - lease = await manager.snapshot_store.acquire(snapshot.snapshot_id) - admission = activity.acquire(lease) - bridge = CommittedProactiveBridge(activity) - token = bridge.bind_execution(lease) - try: - runtime = bridge.runtime_for(snapshot) - modules = bridge.lifecycle_modules( - runtime, - lifecycle_id="default.proactive.frame.v1", - ) - if len(modules) != 1: - raise AssertionError(f"Emotion proactive module 数量异常: {len(modules)}") - return await cast(Any, modules[0]).run(frame) - finally: - bridge.reset_execution(token) - await admission.release() - await lease.release() + def selection(self, accepted_turn: dict[str, object]) -> dict[str, object] | None: + return self.store.selection(accepted_turn) -def test_module_exports_pure_v3_contract() -> None: - assert module.api_version == 3 - assert module.name == "emotion" - assert module.version == "3.0.0" - assert inspect.signature(module.apply).parameters.keys() == {"ctx", "config"} - module_file = module.__file__ - assert isinstance(module_file, str) - source = Path(module_file).read_text(encoding="utf-8") - db_source = Path(module_file).with_name("db.py").read_text(encoding="utf-8") - assert "class EmotionPlugin" not in source - assert "ProactiveFeedbackRecorded" not in source - production_source = f"{source}\n{db_source}" - assert "proactive_v2.frame" not in production_source - assert "proactive_v2.energy" not in production_source - assert "EventBus" not in source - assert "from agent.plugins import" not in source +class EmptyDrift: + def propose(self, *args: object, **kwargs: object) -> dict[str, object]: + return {"inserted": True} + def selection(self, accepted_turn: object) -> None: + return None -@pytest.mark.asyncio -async def test_v3_apply_freezes_all_catalogs_without_opening_db(tmp_path: Path) -> None: - root, emotion_root = await _mount_runtime(tmp_path) + +async def _mount_candidate(tmp_path: Path) -> tuple[CompositionRoot, Path]: + """Mount the real plugin with candidate Timer and ordinary service atoms.""" + + root = CompositionRoot("emotion-candidate") + tools = PluginTools(root.instance_token) + ui = PluginUiSlots() + drift = EmptyDrift() + _ = await root.context.provide(TIMERS, PluginTimers.candidate_validation()) + _ = await root.context.provide(TOOL_CATALOG, tools) + _ = await root.context.provide(UI_SLOTS, ui) + _ = await root.context.provide(module.DRIFT_PROPOSALS, drift) + _ = await root.context.provide(module.DRIFT_WAKE, drift) + emotion_root = tmp_path / "workspace" / "emotion" _ = await root.mount( lambda ctx: module.apply(ctx, object()), name="emotion", - inject=(BACKGROUND_JOBS, PROACTIVE_COMPONENTS, UI_SLOTS), + inject=module.inject, runtime=PluginRuntime( plugin_id="emotion", plugin_dir=Path(__file__).parents[1], @@ -174,43 +106,19 @@ async def test_v3_apply_freezes_all_catalogs_without_opening_db(tmp_path: Path) workspace=emotion_root.parent, config=None, workspace_roots=("emotion",), + data_access="read_only", ), ) - jobs = root.context.get(BACKGROUND_JOBS) - proactive = root.context.get(PROACTIVE_COMPONENTS) - assert jobs is not None and proactive is not None - job_catalog = _freeze_plugin_background_jobs(jobs, root.instance_token) - proactive_catalog = _freeze_plugin_proactive_components( - proactive, + _ = _freeze_plugin_tools( + tools, root.instance_token, + {"emotion": root.generation_id}, ) - assert job_catalog.job("emotion:merge_proactive_pending") is not None - module_binding = proactive_catalog.module("emotion:proactive.prompt.emotion") - assert module_binding is not None - assert module_binding.definition.domain_effect == "emotion.state" - assert not (emotion_root / "emotion.db").exists() - await root.dispose() + return root, emotion_root -@pytest.mark.asyncio -async def test_typed_turn_feedback_is_idempotent_and_mobile_read_only( - tmp_path: Path, -) -> None: - root, emotion_root = await _mount_runtime(tmp_path) - _ = await root.mount( - lambda ctx: module.apply(ctx, object()), - name="emotion", - inject=(BACKGROUND_JOBS, PROACTIVE_COMPONENTS, UI_SLOTS), - runtime=PluginRuntime( - plugin_id="emotion", - plugin_dir=Path(__file__).parents[1], - data_dir=tmp_path / "plugin-data", - workspace=emotion_root.parent, - config=None, - workspace_roots=("emotion",), - ), - ) - event = TurnCommitted( +def _feedback_turn(turn_id: str = "turn-feedback-1") -> TurnCommitted: + return TurnCommitted( session_key="mobile:test", channel="test", chat_id="chat", @@ -218,570 +126,1177 @@ async def test_typed_turn_feedback_is_idempotent_and_mobile_read_only( persisted_user_message="被回复消息:主动提醒某个主题\n\n【你当前新消息】继续这个主题", assistant_response="继续回答", tools_used=[], - turn_id="turn-1", + turn_id=turn_id, persisted_user_message_id="u1", assistant_message_id="a1", + timestamp=NOW, ) - module._on_turn_committed(event) - module._on_turn_committed(event) - before = sorted(path.relative_to(tmp_path).as_posix() for path in tmp_path.rglob("*") if path.is_file()) - bootstrap = module._mobile_ui_query( + + +def _before_turn(channel: str, at: datetime) -> BeforeTurnCtx: + return BeforeTurnCtx( + session_key="wake:default", + channel=channel, + chat_id="chat", + content="tick", + timestamp=at, + retrieved_memory_block="", + retrieval_trace_raw=None, + history_messages=(), + ) + + +def test_module_uses_only_ordinary_v3_atoms() -> None: + assert module.api_version == 3 + assert inspect.signature(module.apply).parameters.keys() == {"ctx", "config"} + assert module.inject == ( + TIMERS, + TOOL_CATALOG, + UI_SLOTS, + module.DRIFT_PROPOSALS, + module.DRIFT_WAKE, + ) + source = "\n".join( + (Path(__file__).parents[1] / name).read_text(encoding="utf-8") + for name in ("plugin.py", "runtime.py") + ) + for removed in ( + "PROACTIVE_COMPONENTS", + "BACKGROUND_JOBS", + "DRIFT_FINISHED", + "ProactiveModule", + "ProactiveDocuments", + 'event.extra.get("proactive_feedback")', + ): + assert removed not in source + db_source = (Path(__file__).parents[1] / "db.py").read_text(encoding="utf-8") + for removed_write in ( + "class EmotionDomainEffect", + "def build_effect(", + "def commit_domain_effect(", + "def lookup_domain_effect(", + "def lookup_domain_effect_path(", + "INSERT INTO emotion_effects", + "INSERT INTO emotion_domain_effects", + ): + assert removed_write not in db_source + + +@pytest.mark.asyncio +async def test_candidate_mount_has_zero_timer_and_zero_workspace_write(tmp_path: Path) -> None: + root, emotion_root = await _mount_candidate(tmp_path) + assert not emotion_root.exists() + assert not list(tmp_path.rglob("*.db")) + await root.dispose() + + +def test_feedback_history_is_idempotent_and_mobile_is_read_only(tmp_path: Path) -> None: + emotion_root = tmp_path / "emotion" + event = _feedback_turn() + module._on_turn_committed(event, root=emotion_root) + module._on_turn_committed(event, root=emotion_root) + before = (emotion_root / "emotion.db").read_bytes() + projection = module._mobile_ui_query( "emotion.bootstrap", {"limit": 10}, session_id=None, turn_id=None, + root=emotion_root, ) - after = sorted(path.relative_to(tmp_path).as_posix() for path in tmp_path.rglob("*") if path.is_file()) + after = (emotion_root / "emotion.db").read_bytes() + assert projection["overview"]["event_count"] == 1 + assert projection["overview"]["influence_count"] == 1 assert before == after - assert bootstrap["overview"]["event_count"] == 1 - assert bootstrap["overview"]["influence_count"] == 1 - assert bootstrap["items"][0]["source_type"] == "explicit_quote" - await root.dispose() + + +def test_open_db_closes_every_read_only_validation_connection(tmp_path: Path) -> None: + path = tmp_path / "emotion.db" + created = module.open_db(path) + created.close() + _ = gc.collect() + + with warnings.catch_warnings(record=True) as seen: + warnings.simplefilter("always", ResourceWarning) + for _ in range(4): + connection = module.open_db(path) + connection.close() + _ = gc.collect() + + assert [warning for warning in seen if warning.category is ResourceWarning] == [] @pytest.mark.asyncio -async def test_proactive_projection_requires_and_uses_domain_effect_facade( - tmp_path: Path, -) -> None: - emotion_root = tmp_path / "emotion" - emotion_root.mkdir() - projection = module.EmotionProjectionModule(emotion_root) - frame = ProactiveFrame( - input=ProactiveTickInput( - session_key="proactive:test", - started_at=datetime(2026, 8, 17, tzinfo=timezone.utc), - ) +async def test_empty_tick_overwrites_current_without_appending_history(tmp_path: Path) -> None: + drift = EmptyDrift() + runtime = module.EmotionRuntime( + cast(Any, object()), + tmp_path, + PluginTimers.candidate_validation(), + drift, + drift, + now=lambda: NOW, ) - calls: list[str] = [] - - class Effects: - async def run(self, effect_id: str, transaction): - calls.append(effect_id) - effect_context = SimpleNamespace( - semantic_job_id="emotion:proactive.prompt.emotion", - event_id="proactive:test:2026-08-17T00:00:00+00:00", - invocation_id=( - "proactive:emotion:proactive.prompt.emotion:" - "proactive:test:2026-08-17T00:00:00+00:00" - ), - effect_id=effect_id, - idempotency_key=( - "proactive:test:2026-08-17T00:00:00+00:00:" - "emotion:proactive.prompt.emotion" - ), - attempt=1, - tick_id="proactive:test:2026-08-17T00:00:00+00:00", + await runtime.tick_once() + await runtime.tick_once() + + conn = sqlite3.connect(tmp_path / "emotion.db") + assert conn.execute("SELECT count(*) FROM emotion_context_current").fetchone()[0] == 1 + assert conn.execute("SELECT count(*) FROM emotion_drift_runs").fetchone()[0] == 0 + assert conn.execute("SELECT count(*) FROM emotion_events").fetchone()[0] == 0 + assert conn.execute("SELECT count(*) FROM emotion_feedback_samples").fetchone()[0] == 0 + conn.close() + + +def _create_formal_legacy_fixture(path: Path) -> None: + """Create the exact original three-table formal schema without new migration code.""" + + conn = sqlite3.connect(path) + conn.executescript( + """ + CREATE TABLE emotion_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + valence REAL NOT NULL, + arousal REAL NOT NULL, + dominance REAL NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE emotion_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + source_plugin TEXT NOT NULL, + source_event_id TEXT NOT NULL UNIQUE, + source_type TEXT NOT NULL, + session_key TEXT NOT NULL, + valence_before REAL NOT NULL, + arousal_before REAL NOT NULL, + dominance_before REAL NOT NULL, + valence_delta REAL NOT NULL, + arousal_delta REAL NOT NULL, + dominance_delta REAL NOT NULL, + valence_after REAL NOT NULL, + arousal_after REAL NOT NULL, + dominance_after REAL NOT NULL, + reason TEXT NOT NULL, + payload_json TEXT NOT NULL + ); + CREATE TABLE emotion_effects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + tick_id TEXT NOT NULL UNIQUE, + session_key TEXT NOT NULL, + valence REAL NOT NULL, + arousal REAL NOT NULL, + dominance REAL NOT NULL, + base_threshold REAL NOT NULL, + final_threshold REAL NOT NULL, + threshold_delta REAL NOT NULL, + tone_label TEXT NOT NULL, + expected_effect TEXT NOT NULL, + prompt_section TEXT NOT NULL, + metadata_json TEXT NOT NULL + ); + INSERT INTO emotion_state VALUES(1, 0.2, -0.1, 0.3, '2026-08-20T00:00:00+00:00'); + """ + ) + for index in range(3): + conn.execute( + """ + INSERT INTO emotion_events( + source_plugin, source_event_id, source_type, session_key, + valence_before, arousal_before, dominance_before, + valence_delta, arousal_delta, dominance_delta, + valence_after, arousal_after, dominance_after, reason, payload_json + ) VALUES('legacy', ?, 'feedback', 'legacy', 0, 0, 0, + 0.1, 0, 0.1, 0.1, 0, 0.1, 'legacy', '{}') + """, + (f"legacy-event-{index}",), + ) + for index in range(5): + conn.execute( + """ + INSERT INTO emotion_effects( + tick_id, session_key, valence, arousal, dominance, + base_threshold, final_threshold, threshold_delta, + tone_label, expected_effect, prompt_section, metadata_json + ) VALUES(?, 'legacy', 0, 0, 0, 0.6, 0.6, 0, + '平静', 'frozen', 'legacy prompt', '{}') + """, + (f"legacy-tick-{index}",), + ) + conn.commit() + conn.close() + + +def _insert_legacy_pf_event(path: Path, *, user_message_id: str = "u1") -> None: + payload = { + "feedback_event_id": "1", + "user_message_id": user_message_id, + "assistant_message_id": "a1", + "proactive_message_id": "p1", + "feedback_type": "topic_follow", + "confidence": "high", + "pa_score": 0.8, + "pua_score": 0.7, + "lag_seconds": 1, + "candidate_count": 1, + "matched_by": "pua", + "reason": "fixture", + "user_content_preview": "继续", + "assistant_content_preview": "回答", + "proactive_content_preview": "提醒", + } + with closing(sqlite3.connect(path)) as connection: + connection.execute( + """ + INSERT INTO emotion_events( + source_plugin, source_event_id, source_type, session_key, + valence_before, arousal_before, dominance_before, + valence_delta, arousal_delta, dominance_delta, + valence_after, arousal_after, dominance_after, reason, payload_json + ) VALUES( + 'proactive_feedback', 'proactive_feedback:1', 'topic_follow', + 'mobile:test', 0.18, -0.1, 0.25, 0.02, 0, 0.05, + 0.2, -0.1, 0.3, 'topic_follow_high', ? ) - result = transaction(effect_context) - if inspect.isawaitable(result): - await result - return object() - - result = await projection.run(SimpleNamespace(domain_effects=Effects()), frame) - assert result.slots["proactive:prompt:system_bottom:emotion"] - assert result.slots["proactive:effect:emotion"]["metadata"]["expected_effect"] == "tone_only" - assert calls == ["emotion.state"] - db = module.open_db(emotion_root / "emotion.db") - try: - assert db.execute("SELECT COUNT(*) FROM emotion_effects").fetchone()[0] == 1 - assert db.execute("SELECT COUNT(*) FROM emotion_domain_effects").fetchone()[0] == 1 - finally: - db.close() - with pytest.raises(RuntimeError, match="domain effects facade"): - await projection.run(SimpleNamespace(domain_effects=None), frame) + """, + (json.dumps(payload, ensure_ascii=False),), + ) + connection.commit() + + +def test_formal_legacy_upgrade_is_atomic_and_preserves_all_rows(tmp_path: Path) -> None: + path = tmp_path / "emotion.db" + _create_formal_legacy_fixture(path) + upgraded = module.open_db(path) + + assert upgraded.execute("PRAGMA user_version").fetchone()[0] == 2 + assert upgraded.execute("SELECT count(*) FROM emotion_events").fetchone()[0] == 3 + assert upgraded.execute("SELECT count(*) FROM emotion_effects").fetchone()[0] == 5 + assert tuple(upgraded.execute( + "SELECT valence, arousal, dominance FROM emotion_state WHERE id=1" + ).fetchone()) == (0.2, -0.1, 0.3) + assert upgraded.execute("SELECT count(*) FROM emotion_context_current").fetchone()[0] == 0 + assert upgraded.execute("SELECT count(*) FROM emotion_preference_state").fetchone()[0] == 1 + assert upgraded.execute("SELECT count(*) FROM emotion_domain_effects").fetchone()[0] == 0 + upgraded.close() + + +def test_malformed_legacy_fails_before_ddl_and_can_retry_after_repair(tmp_path: Path) -> None: + path = tmp_path / "emotion.db" + conn = sqlite3.connect(path) + conn.execute("CREATE TABLE emotion_state(value TEXT)") + conn.execute("INSERT INTO emotion_state VALUES('unmodified')") + conn.commit() + conn.close() + original_bytes = path.read_bytes() + + with pytest.raises(RuntimeError, match="Emotion table schema 不匹配: emotion_state"): + _ = module.open_db(path) + assert path.read_bytes() == original_bytes + check = sqlite3.connect(path) + assert check.execute( + "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" + ).fetchall() == [("emotion_state",)] + assert check.execute("SELECT value FROM emotion_state").fetchall() == [ + ("unmodified",) + ] + check.execute("DROP TABLE emotion_state") + check.commit() + check.close() + + _create_formal_legacy_fixture(path) + repaired = module.open_db(path) + assert repaired.execute("PRAGMA user_version").fetchone()[0] == 2 + assert repaired.execute("SELECT count(*) FROM emotion_events").fetchone()[0] == 3 + repaired.close() + + +def test_removed_legacy_write_api_cannot_mutate_frozen_tables(tmp_path: Path) -> None: + path = tmp_path / "emotion.db" + _create_formal_legacy_fixture(path) + conn = module.open_db(path) + before = ( + conn.execute("SELECT count(*) FROM emotion_effects").fetchone()[0], + conn.execute("SELECT count(*) FROM emotion_domain_effects").fetchone()[0], + ) + conn.close() + + db_module = sys.modules["test_emotion_plugin.db"] + for removed in ( + "EmotionDomainEffect", + "build_effect", + "commit_domain_effect", + "lookup_domain_effect", + "lookup_domain_effect_path", + ): + assert not hasattr(db_module, removed) + + reopened = module.open_db(path) + after = ( + reopened.execute("SELECT count(*) FROM emotion_effects").fetchone()[0], + reopened.execute("SELECT count(*) FROM emotion_domain_effects").fetchone()[0], + ) + reopened.close() + assert after == before == (5, 0) @pytest.mark.asyncio -async def test_manager_proactive_failure_rolls_back_then_retries_idempotently( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - manager = await _start_emotion_manager(tmp_path) - try: - generation = manager.generation("emotion") - assert generation is not None - plugin_module = cast(Any, generation.instance).module - frame = ProactiveFrame( - input=ProactiveTickInput( - session_key="proactive:emotion", - started_at=datetime(2026, 8, 17, tzinfo=timezone.utc), - ) - ) - original_build_effect = plugin_module.build_effect - - def fail_after_writes(conn, **kwargs): - result = original_build_effect(conn, **kwargs) - raise RuntimeError("synthetic precommit failure") - - monkeypatch.setattr(plugin_module, "build_effect", fail_after_writes) - with pytest.raises(RuntimeError, match="synthetic precommit failure"): - await _run_manager_tick(manager, frame) - - db = module.open_db(tmp_path / "workspace" / "emotion" / "emotion.db") - try: - assert db.execute("SELECT COUNT(*) FROM emotion_effects").fetchone()[0] == 0 - assert ( - db.execute("SELECT COUNT(*) FROM emotion_domain_effects").fetchone()[0] - == 0 - ) - state = db.execute( - "SELECT valence, arousal, dominance FROM emotion_state WHERE id = 1" - ).fetchone() - assert state is not None and tuple(state) == (0.0, 0.0, 0.0) - finally: - db.close() - - monkeypatch.setattr(plugin_module, "build_effect", original_build_effect) - first = await _run_manager_tick(manager, frame) - second = await _run_manager_tick(manager, frame) - assert first.slots["proactive:effect:emotion"] == second.slots[ - "proactive:effect:emotion" - ] - - db = module.open_db(tmp_path / "workspace" / "emotion" / "emotion.db") - try: - assert db.execute("SELECT COUNT(*) FROM emotion_effects").fetchone()[0] == 1 - assert ( - db.execute("SELECT COUNT(*) FROM emotion_domain_effects").fetchone()[0] - == 1 - ) - tick_id = "proactive:emotion:2026-08-17T00:00:00+00:00" - receipt = db.execute( - """ - SELECT semantic_job_id, event_id, invocation_id, effect_id, - idempotency_key, attempt - FROM emotion_domain_effects - """ - ).fetchone() - assert receipt is not None - assert tuple(receipt) == ( - "emotion:proactive.prompt.emotion", - tick_id, - f"proactive:emotion:proactive.prompt.emotion:{tick_id}", - "emotion.state", - f"{tick_id}:emotion:proactive.prompt.emotion", - 1, - ) - finally: - db.close() - finally: - await manager.terminate_all() +async def test_fresh_context_appends_only_to_wake(tmp_path: Path) -> None: + drift = EmptyDrift() + runtime = module.EmotionRuntime( + cast(Any, object()), + tmp_path, + PluginTimers.candidate_validation(), + drift, + drift, + now=lambda: NOW, + ) + db = module.open_db(tmp_path / "emotion.db") + refresh_current_context(db, now=NOW) + db.close() + + wake = _before_turn("wake", NOW + timedelta(minutes=1)) + wake.extra_hints.append("existing") + runtime.prepare_context(wake) + assert wake.extra_hints[0] == "existing" + assert wake.extra_hints[1].startswith("Emotion current:\n") + assert wake.abort is False + + passive = _before_turn("mobile", NOW + timedelta(minutes=1)) + runtime.prepare_context(passive) + assert passive.extra_hints == [] + stale = _before_turn("wake", NOW + timedelta(minutes=11)) + runtime.prepare_context(stale) + assert stale.extra_hints == [] @pytest.mark.asyncio -async def test_manager_proactive_commit_survives_cancellation_and_reentry( +async def test_real_drift_replays_then_revises_and_commits_without_history_loss( tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, ) -> None: - manager = await _start_emotion_manager(tmp_path) - try: - from agent.plugins.generation_job_host import ProactiveDomainEffects + emotion_root = tmp_path / "emotion" + module._on_turn_committed(_feedback_turn(), root=emotion_root) + drift = DriftServices(tmp_path / "drift.sqlite3") + runtime = module.EmotionRuntime( + cast(Any, object()), + emotion_root, + PluginTimers.candidate_validation(), + drift, + drift, + now=lambda: NOW, + ) - frame = ProactiveFrame( - input=ProactiveTickInput( - session_key="proactive:emotion-cancel", - started_at=datetime(2026, 8, 17, 0, 1, tzinfo=timezone.utc), - ) + await runtime.tick_once() + await runtime.tick_once() + snapshot = cast( + tuple[dict[str, Any], ...], + drift.store.snapshot(NOW)["proposals"], + ) + assert len(snapshot) == 1 + first = snapshot[0] + first_turn = {"session_id": "wake:default", "turn_id": "wake-1"} + selected = drift.store.select(first["ref"], first_turn, NOW) + runtime.observe_turn( + TurnCommitted( + session_key="wake:default", + channel="wake", + chat_id="chat", + input_message="tick", + persisted_user_message="tick", + assistant_response="没有调用提交工具", + tools_used=[], + turn_id="wake-1", + timestamp=NOW, ) - original_lookup = ProactiveDomainEffects._lookup_committed - lookup_calls = 0 - - async def cancel_after_commit(self): - nonlocal lookup_calls - lookup_calls += 1 - record = await original_lookup(self) - if lookup_calls == 2: - raise asyncio.CancelledError - return record - - monkeypatch.setattr( - ProactiveDomainEffects, - "_lookup_committed", - cancel_after_commit, + ) + _ = drift.store.transition( + cast(str, selected["selection_token"]), "ready_for_delivery" + ) + + await runtime.tick_once() + proposals = cast( + tuple[dict[str, Any], ...], + drift.store.snapshot(NOW)["proposals"], + ) + second = next(item for item in proposals if item["ref"]["revision"] == "attempt-2") + second_turn = {"session_id": "wake:default", "turn_id": "wake-2"} + selected = drift.store.select(second["ref"], second_turn, NOW) + arguments = { + "proposal_id": "emotion-feedback:1-1", + "revision": "attempt-2", + "context": "用户愿意继续讨论明确引用的主题。", + "candidates": [ + { + "effect": "boost", + "confidence": "medium", + "topic": "明确引用的主题", + "action": "提高同一主题后续候选的优先级", + "evidence": [1], + } + ], + } + assert await runtime.commit_preference_context(object(), arguments) == { + "committed": True, + "duplicate": False, + } + assert await runtime.commit_preference_context(object(), arguments) == { + "committed": False, + "duplicate": True, + } + runtime.observe_turn( + TurnCommitted( + session_key="wake:default", + channel="wake", + chat_id="chat", + input_message="tick", + persisted_user_message="tick", + assistant_response="已提交", + tools_used=["emotion_commit_preference_context"], + turn_id="wake-2", + timestamp=NOW, ) - with pytest.raises(asyncio.CancelledError): - await _run_manager_tick(manager, frame) - assert lookup_calls == 2 - - monkeypatch.setattr( - ProactiveDomainEffects, - "_lookup_committed", - original_lookup, + ) + runtime.observe_turn( + TurnCommitted( + session_key="wake:default", + channel="wake", + chat_id="chat", + input_message="tick", + persisted_user_message="tick", + assistant_response="已提交", + tools_used=["emotion_commit_preference_context"], + turn_id="wake-2", + timestamp=NOW, ) - resumed = await _run_manager_tick(manager, frame) - assert resumed.slots["proactive:prompt:system_bottom:emotion"] - - db = module.open_db(tmp_path / "workspace" / "emotion" / "emotion.db") - try: - assert db.execute("SELECT COUNT(*) FROM emotion_effects").fetchone()[0] == 1 - assert ( - db.execute("SELECT COUNT(*) FROM emotion_domain_effects").fetchone()[0] - == 1 - ) - finally: - db.close() - finally: - await manager.terminate_all() - + ) + _ = drift.store.transition( + cast(str, selected["selection_token"]), "ready_for_delivery" + ) -def test_manager_proactive_receipt_survives_core_process_crash_and_reentry( + conn = sqlite3.connect(emotion_root / "emotion.db") + runs = conn.execute( + "SELECT revision, status, result_json FROM emotion_drift_runs ORDER BY attempt" + ).fetchall() + assert [(row[0], row[1]) for row in runs] == [ + ("attempt-1", "completed_without_commit"), + ("attempt-2", "completed"), + ] + assert runs[0][2] is None and runs[1][2] is not None + assert conn.execute("SELECT count(*) FROM emotion_events").fetchone()[0] == 1 + assert conn.execute("SELECT count(*) FROM emotion_feedback_samples").fetchone()[0] == 1 + assert conn.execute("SELECT count(*) FROM emotion_effects").fetchone()[0] == 0 + assert conn.execute("SELECT count(*) FROM emotion_domain_effects").fetchone()[0] == 0 + conn.close() + + +class ManualHandle: + def __init__(self, timer_id: str, deadline: datetime) -> None: + self.id = timer_id + self.deadline = deadline + self._future: asyncio.Future[TimerReceipt] = asyncio.get_running_loop().create_future() + + async def result(self) -> TimerReceipt: + return await asyncio.shield(self._future) + + async def cancel(self) -> TimerReceipt: + if not self._future.done(): + self._future.set_result(self._receipt(TimerStatus.CANCELLED)) + return await asyncio.shield(self._future) + + async def cleanup(self) -> None: + _ = await self.cancel() + + def fire(self) -> None: + self._future.set_result(self._receipt(TimerStatus.FIRED)) + + def _receipt(self, status: TimerStatus) -> TimerReceipt: + return TimerReceipt(self.id, self.deadline, self.deadline, status) + + +class ManualTimer: + def __init__(self) -> None: + self.handles: list[ManualHandle] = [] + + def schedule(self, deadline: datetime) -> ManualHandle: + handle = ManualHandle(f"timer-{len(self.handles) + 1}", deadline) + self.handles.append(handle) + return handle + + @property + def active(self) -> int: + return sum(not handle._future.done() for handle in self.handles) + + +class RecordingContext: + def __init__(self) -> None: + self.incidents: list[tuple[str, str]] = [] + self.tasks: list[asyncio.Task[None]] = [] + + async def spawn(self, coroutine: Any, *, name: str) -> asyncio.Task[None]: + task = asyncio.create_task(coroutine, name=name) + self.tasks.append(task) + return task + + def report_incident(self, code: str, detail: str) -> None: + self.incidents.append((code, detail)) + + +class FailOnceDrift(EmptyDrift): + def __init__(self) -> None: + self.calls = 0 + + def propose(self, *args: object, **kwargs: object) -> dict[str, object]: + self.calls += 1 + if self.calls == 1: + raise OSError("temporary drift storage unavailable") + return {"inserted": True} + + +class ContractBrokenDrift(EmptyDrift): + def propose(self, *args: object, **kwargs: object) -> dict[str, object]: + raise RuntimeError("drift contract mismatch") + + +async def _eventually(predicate: Any) -> None: + for _ in range(100): + if predicate(): + return + await asyncio.sleep(0) + raise AssertionError("condition did not settle") + + +def _count_rows(path: Path, table: str) -> int: + with closing(sqlite3.connect(path)) as connection: + return int(connection.execute(f"SELECT count(*) FROM {table}").fetchone()[0]) + + +def _history_record(cursor: int, **changes: object) -> SimpleNamespace: + supplied_hash = changes.pop("payload_hash", None) + values: dict[str, object] = { + "cursor": cursor, + "event_id": f"proactive_feedback:{cursor}", + "session_key": "mobile:test", + "user_message_id": f"u{cursor}", + "assistant_message_id": f"a{cursor}", + "proactive_message_id": f"p{cursor}", + "feedback_type": "topic_follow", + "confidence": "high", + "pa_score": 0.8, + "pua_score": 0.7, + "lag_seconds": cursor, + "candidate_count": 1, + "matched_by": "pua", + "reason": "fixture", + "user_content_preview": "继续", + "assistant_content_preview": "回答", + "proactive_content_preview": "提醒", + } + values.update(changes) + canonical = { + field: values[field] + for field in ( + "session_key", + "user_message_id", + "assistant_message_id", + "proactive_message_id", + "feedback_type", + "confidence", + "pa_score", + "pua_score", + "lag_seconds", + "candidate_count", + "matched_by", + "reason", + "user_content_preview", + "assistant_content_preview", + "proactive_content_preview", + ) + } + values["payload_hash"] = supplied_hash or hashlib.sha256( + json.dumps( + canonical, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + ).hexdigest() + return SimpleNamespace(**values) + + +class RecordingHistory: + def __init__(self, records: list[SimpleNamespace]) -> None: + self.records = records + self.requests: list[tuple[int, int]] = [] + + def page(self, *, after_cursor: int, max_items: int) -> SimpleNamespace: + self.requests.append((after_cursor, max_items)) + records = tuple( + record for record in self.records if record.cursor > after_cursor + )[:max_items] + return SimpleNamespace(after_cursor=after_cursor, records=records) + + +class FailOnceHistory(RecordingHistory): + def __init__(self, records: list[SimpleNamespace]) -> None: + super().__init__(records) + self.calls = 0 + + def page(self, *, after_cursor: int, max_items: int) -> SimpleNamespace: + self.calls += 1 + if self.calls == 1: + raise OSError("temporary PF history I/O") + return super().page(after_cursor=after_cursor, max_items=max_items) + + +async def _mount_formal_with_history( tmp_path: Path, -) -> None: - plugin_dir = _copy_emotion_plugin(tmp_path) - workspace = tmp_path / "workspace" - script = """ -import asyncio -import os -import sys -from datetime import datetime, timezone -from pathlib import Path + *, + order: tuple[str, str], +) -> tuple[CompositionRoot, ManualTimer, Path]: + root = CompositionRoot("emotion-pf-" + "-".join(order)) + timer = ManualTimer() + tools = PluginTools(root.instance_token) + ui = PluginUiSlots() + drift = EmptyDrift() + history = RecordingHistory([_history_record(1)]) + _ = await root.context.provide(TIMERS, PluginTimers(timer)) + _ = await root.context.provide(TOOL_CATALOG, tools) + _ = await root.context.provide(UI_SLOTS, ui) + _ = await root.context.provide(module.DRIFT_PROPOSALS, drift) + _ = await root.context.provide(module.DRIFT_WAKE, drift) + emotion_root = tmp_path / "workspace" / "emotion" -from agent.plugins.generation_activity_host import ActivityHost -from agent.plugins.generation_proactive_bridge import CommittedProactiveBridge -from agent.plugins.generation_proactive_host import ProactiveActivityAdapter -from agent.plugins.generation_job_host import ProactiveDomainEffects -from agent.plugins.manager import PluginManager -from bus.event_bus import EventBus -from proactive_v2.frame import ProactiveFrame, ProactiveTickInput - - -async def run_tick(manager, frame): - snapshot = manager.current_snapshot - activity = manager.activity_host - assert snapshot is not None and activity is not None - lease = await manager.snapshot_store.acquire(snapshot.snapshot_id) - admission = activity.acquire(lease) - bridge = CommittedProactiveBridge(activity) - token = bridge.bind_execution(lease) - try: - runtime = bridge.runtime_for(snapshot) - modules = bridge.lifecycle_modules( - runtime, - lifecycle_id="default.proactive.frame.v1", - ) - assert len(modules) == 1 - await modules[0].run(frame) - finally: - bridge.reset_execution(token) - await admission.release() - await lease.release() - - -async def main(): - plugin_parent = Path(sys.argv[1]) - workspace = Path(sys.argv[2]) - manager = PluginManager( - plugin_dirs=[plugin_parent], - event_bus=EventBus(), - tool_registry=None, - workspace=workspace, - installed_cache_root=workspace.parent / "cache", - ) - manager.bind_activity_host(ActivityHost((ProactiveActivityAdapter(),))) - original_lookup = ProactiveDomainEffects._lookup_committed - lookup_calls = 0 - - async def crash_after_commit(self): - nonlocal lookup_calls - lookup_calls += 1 - if lookup_calls == 2: - os._exit(137) - return await original_lookup(self) - - ProactiveDomainEffects._lookup_committed = crash_after_commit - await manager.load_all() - frame = ProactiveFrame( - input=ProactiveTickInput( - session_key="proactive:emotion-crash", - started_at=datetime(2026, 8, 17, 0, 2, tzinfo=timezone.utc), + async def mount_emotion() -> None: + _ = await root.mount( + lambda ctx: module.apply(ctx, object()), + name="emotion", + inject=module.inject, + runtime=PluginRuntime( + plugin_id="emotion", + plugin_dir=Path(__file__).parents[1], + data_dir=tmp_path / "plugin-data" / "emotion", + workspace=emotion_root.parent, + config=None, + workspace_roots=("emotion",), + data_access="read_write", + ), ) + + async def mount_feedback() -> None: + async def provider(ctx: Any) -> None: + _ = await ctx.provide(module.PROACTIVE_FEEDBACK_HISTORY, history) + + _ = await root.mount(provider, name="proactive_feedback") + + for item in order: + await (mount_emotion() if item == "emotion" else mount_feedback()) + _ = _freeze_plugin_tools(tools, root.instance_token, {"emotion": root.generation_id}) + return root, timer, emotion_root + + +def test_feedback_history_empty_page_creates_no_emotion_state(tmp_path: Path) -> None: + history = RecordingHistory([]) + consumer = module.FeedbackHistoryConsumer( + cast(Any, object()), + tmp_path, + PluginTimers.candidate_validation(), + cast(Any, history), + now=lambda: NOW, ) - await run_tick(manager, frame) + assert consumer.tick_once() is False + assert history.requests == [(0, 50)] + assert not (tmp_path / "emotion.db").exists() -asyncio.run(main()) -""" - env = dict(os.environ) - core_root = os.environ.get("AKASHIC_AGENT_ROOT") or str( - Path(__file__).parents[3] / "akasic-agent" + +def test_feedback_history_page_is_atomic_idempotent_and_detects_hash_drift( + tmp_path: Path, +) -> None: + history = RecordingHistory([_history_record(1), _history_record(2)]) + consumer = module.FeedbackHistoryConsumer( + cast(Any, object()), + tmp_path, + PluginTimers.candidate_validation(), + cast(Any, history), + now=lambda: NOW, ) - env["PYTHONPATH"] = core_root + os.pathsep + env.get("PYTHONPATH", "") - crashed = subprocess.run( - [ - sys.executable, - "-c", - script, - str(plugin_dir.parent), - str(workspace), - ], - env=env, - check=False, + assert consumer.tick_once() is False + assert consumer.tick_once() is False + connection = sqlite3.connect(tmp_path / "emotion.db") + assert connection.execute("SELECT count(*) FROM emotion_events").fetchone() == (2,) + assert connection.execute( + "SELECT count(*) FROM emotion_feedback_samples" + ).fetchone() == (2,) + assert connection.execute( + "SELECT row_id FROM pf_history_cursor WHERE source='proactive_feedback'" + ).fetchone() == (2,) + connection.close() + connection = sqlite3.connect(tmp_path / "emotion.db") + connection.execute( + "UPDATE pf_history_cursor SET row_id=0 WHERE source='proactive_feedback'" + ) + connection.commit() + connection.close() + history.records = [_history_record(1, payload_hash="f" * 64)] + with pytest.raises(RuntimeError, match="payload hash 漂移"): + consumer.tick_once() + connection = sqlite3.connect(tmp_path / "emotion.db") + assert connection.execute( + "SELECT row_id FROM pf_history_cursor WHERE source='proactive_feedback'" + ).fetchone() == (0,) + assert connection.execute("SELECT count(*) FROM emotion_events").fetchone() == (2,) + connection.close() + + +@pytest.mark.parametrize( + ("field", "mutated"), + ( + ("session_key", "mobile:other"), + ("user_message_id", "other-user"), + ("assistant_message_id", "other-assistant"), + ("proactive_message_id", None), + ("feedback_type", "no_topic_follow"), + ("confidence", "low"), + ("pa_score", 0.1), + ("pua_score", 0.2), + ("lag_seconds", 99), + ("candidate_count", 2), + ("matched_by", "other-rule"), + ("reason", "changed"), + ("user_content_preview", None), + ("assistant_content_preview", None), + ("proactive_content_preview", None), + ), +) +def test_feedback_history_recomputes_canonical_hash_before_apply( + tmp_path: Path, + field: str, + mutated: object, +) -> None: + original = _history_record(1) + record = _history_record( + 1, + **{field: mutated, "payload_hash": original.payload_hash}, + ) + consumer = module.FeedbackHistoryConsumer( + cast(Any, object()), + tmp_path, + PluginTimers.candidate_validation(), + cast(Any, RecordingHistory([record])), + now=lambda: NOW, ) - assert crashed.returncode == 137 - db = module.open_db(workspace / "emotion" / "emotion.db") - try: - assert db.execute("SELECT COUNT(*) FROM emotion_effects").fetchone()[0] == 1 - assert ( - db.execute("SELECT COUNT(*) FROM emotion_domain_effects").fetchone()[0] - == 1 - ) - finally: - db.close() - - async def reenter() -> None: - manager = await _start_emotion_manager(tmp_path, plugin_dir) - try: - frame = ProactiveFrame( - input=ProactiveTickInput( - session_key="proactive:emotion-crash", - started_at=datetime(2026, 8, 17, 0, 2, tzinfo=timezone.utc), - ) - ) - resumed = await _run_manager_tick(manager, frame) - assert resumed.slots["proactive:prompt:system_bottom:emotion"] - finally: - await manager.terminate_all() + with pytest.raises(RuntimeError, match="payload hash 漂移"): + consumer.tick_once() + connection = sqlite3.connect(tmp_path / "emotion.db") + assert connection.execute( + "SELECT row_id FROM pf_history_cursor WHERE source='proactive_feedback'" + ).fetchone() == (0,) + assert connection.execute("SELECT count(*) FROM emotion_events").fetchone() == (0,) + connection.close() - asyncio.run(reenter()) - db = module.open_db(workspace / "emotion" / "emotion.db") - try: - assert db.execute("SELECT COUNT(*) FROM emotion_effects").fetchone()[0] == 1 - assert ( - db.execute("SELECT COUNT(*) FROM emotion_domain_effects").fetchone()[0] - == 1 - ) - finally: - db.close() +@pytest.mark.parametrize("score", (float("nan"), float("inf"), float("-inf"))) +def test_feedback_history_rejects_nonfinite_scores( + tmp_path: Path, + score: float, +) -> None: + record = _history_record(1, pa_score=score, payload_hash="0" * 64) + consumer = module.FeedbackHistoryConsumer( + cast(Any, object()), + tmp_path, + PluginTimers.candidate_validation(), + cast(Any, RecordingHistory([record])), + now=lambda: NOW, + ) -def test_domain_effect_receipt_is_atomic_idempotent_and_durable(tmp_path: Path) -> None: - db_path = tmp_path / "emotion" / "emotion.db" - conn = module.open_db(db_path) - try: - digest = hashlib.sha256(b"merged-documents").hexdigest() - committed = module.commit_domain_effect( - conn, - semantic_job_id="emotion:merge_proactive_pending", - event_id="drift-1", - invocation_id="invocation-1", - effect_id="emotion.state", - idempotency_key="emotion:merge_proactive_pending:event:drift-1", - attempt=1, - result_digest=digest, - ) - repeated = module.commit_domain_effect( - conn, - semantic_job_id="emotion:merge_proactive_pending", - event_id="drift-1", - invocation_id="invocation-1", - effect_id="emotion.state", - idempotency_key="emotion:merge_proactive_pending:event:drift-1", - attempt=1, - result_digest=digest, - ) - finally: - conn.close() + with pytest.raises(ValueError, match="pa_score 必须在"): + consumer.tick_once() + connection = sqlite3.connect(tmp_path / "emotion.db") + assert connection.execute( + "SELECT row_id FROM pf_history_cursor WHERE source='proactive_feedback'" + ).fetchone() == (0,) + connection.close() - restarted = module.open_db(db_path) - try: - found = module.lookup_domain_effect( - restarted, - invocation_id="invocation-1", - effect_id="emotion.state", - idempotency_key="emotion:merge_proactive_pending:event:drift-1", - ) - rows = restarted.execute( - "SELECT COUNT(*) FROM emotion_domain_effects" - ).fetchone() - with pytest.raises(RuntimeError, match="identity 漂移"): - module.commit_domain_effect( - restarted, - semantic_job_id="emotion:merge_proactive_pending", - event_id="drift-1", - invocation_id="invocation-2", - effect_id="emotion.state", - idempotency_key="emotion:merge_proactive_pending:event:drift-1", - attempt=1, - result_digest=digest, - ) - finally: - restarted.close() - assert committed == repeated == found - assert rows is not None and int(rows[0]) == 1 +def test_legacy_pf_event_import_records_terminal_without_double_apply( + tmp_path: Path, +) -> None: + path = tmp_path / "emotion.db" + _create_formal_legacy_fixture(path) + _insert_legacy_pf_event(path) + upgraded = module.open_db(path) + before_state = tuple(upgraded.execute( + "SELECT valence, arousal, dominance FROM emotion_state WHERE id=1" + ).fetchone()) + before_samples = upgraded.execute( + "SELECT count(*) FROM emotion_feedback_samples" + ).fetchone()[0] + upgraded.close() + + history = RecordingHistory([_history_record(1)]) + consumer = module.FeedbackHistoryConsumer( + cast(Any, object()), + tmp_path, + PluginTimers.candidate_validation(), + cast(Any, history), + now=lambda: NOW, + ) + assert consumer.tick_once() is False + connection = sqlite3.connect(path) + assert tuple(connection.execute( + "SELECT valence, arousal, dominance FROM emotion_state WHERE id=1" + ).fetchone()) == before_state + assert connection.execute( + "SELECT count(*) FROM emotion_feedback_samples" + ).fetchone()[0] == before_samples + receipt = connection.execute( + "SELECT source_type, valence_delta, dominance_delta, payload_json " + "FROM emotion_events WHERE source_event_id='pf_history_import:1'" + ).fetchone() + assert receipt[:3] == ("pf_history_import_terminal", 0.0, 0.0) + receipt_payload = json.loads(receipt[3]) + assert receipt_payload["disposition"] == "legacy_event_already_applied" + assert receipt_payload["legacy_event_id"] == "proactive_feedback:1" + assert connection.execute( + "SELECT row_id FROM pf_history_cursor WHERE source='proactive_feedback'" + ).fetchone() == (1,) + first_counts = ( + connection.execute("SELECT count(*) FROM emotion_events").fetchone()[0], + connection.execute( + "SELECT count(*) FROM emotion_feedback_samples" + ).fetchone()[0], + ) + connection.execute( + "UPDATE pf_history_cursor SET row_id=0 WHERE source='proactive_feedback'" + ) + connection.commit() + connection.close() + + assert consumer.tick_once() is False + connection = sqlite3.connect(path) + assert ( + connection.execute("SELECT count(*) FROM emotion_events").fetchone()[0], + connection.execute( + "SELECT count(*) FROM emotion_feedback_samples" + ).fetchone()[0], + ) == first_counts + assert connection.execute( + "SELECT row_id FROM pf_history_cursor WHERE source='proactive_feedback'" + ).fetchone() == (1,) + connection.close() + + history.records.append(_history_record(2)) + assert consumer.tick_once() is False + connection = sqlite3.connect(path) + assert connection.execute( + "SELECT count(*) FROM emotion_feedback_samples" + ).fetchone()[0] == before_samples + 1 + assert connection.execute( + "SELECT row_id FROM pf_history_cursor WHERE source='proactive_feedback'" + ).fetchone() == (2,) + assert connection.execute( + "SELECT count(*) FROM emotion_events " + "WHERE source_event_id='proactive_feedback:2'" + ).fetchone() == (1,) + connection.close() + + +def test_legacy_pf_event_identity_collision_rolls_back(tmp_path: Path) -> None: + path = tmp_path / "emotion.db" + _create_formal_legacy_fixture(path) + _insert_legacy_pf_event(path, user_message_id="different-user") + upgraded = module.open_db(path) + before = ( + upgraded.execute("SELECT count(*) FROM emotion_events").fetchone()[0], + upgraded.execute( + "SELECT payload_json FROM emotion_events " + "WHERE source_event_id='proactive_feedback:1'" + ).fetchone()[0], + tuple(upgraded.execute( + "SELECT valence, arousal, dominance FROM emotion_state WHERE id=1" + ).fetchone()), + upgraded.execute( + "SELECT count(*) FROM emotion_feedback_samples" + ).fetchone()[0], + ) + upgraded.close() + consumer = module.FeedbackHistoryConsumer( + cast(Any, object()), + tmp_path, + PluginTimers.candidate_validation(), + cast(Any, RecordingHistory([_history_record(1)])), + now=lambda: NOW, + ) + + with pytest.raises(RuntimeError, match="legacy identity 冲突"): + consumer.tick_once() + connection = sqlite3.connect(path) + assert connection.execute( + "SELECT row_id FROM pf_history_cursor WHERE source='proactive_feedback'" + ).fetchone() == (0,) + assert connection.execute( + "SELECT count(*) FROM emotion_events " + "WHERE source_event_id='pf_history_import:1'" + ).fetchone() == (0,) + after = ( + connection.execute("SELECT count(*) FROM emotion_events").fetchone()[0], + connection.execute( + "SELECT payload_json FROM emotion_events " + "WHERE source_event_id='proactive_feedback:1'" + ).fetchone()[0], + tuple(connection.execute( + "SELECT valence, arousal, dominance FROM emotion_state WHERE id=1" + ).fetchone()), + connection.execute( + "SELECT count(*) FROM emotion_feedback_samples" + ).fetchone()[0], + ) + connection.close() + assert after == before -def test_domain_effect_receipt_survives_core_process_crash_and_restart( +def test_pf_explicit_quote_receipt_does_not_double_apply_direct_signal( tmp_path: Path, ) -> None: - db_path = tmp_path / "emotion" / "emotion.db" - plugin_path = Path(__file__).parents[1] / "plugin.py" - script = """ -import importlib.util -import os -import sys -import types -from pathlib import Path + module._on_turn_committed(_feedback_turn(), root=tmp_path) + connection = sqlite3.connect(tmp_path / "emotion.db") + before = connection.execute( + "SELECT valence, dominance FROM emotion_state WHERE id=1" + ).fetchone() + assert connection.execute( + "SELECT source_plugin FROM emotion_events " + "WHERE source_event_id='emotion_explicit_quote:turn-feedback-1'" + ).fetchone() == ("emotion",) + assert connection.execute( + "SELECT count(*) FROM emotion_feedback_samples" + ).fetchone() == (1,) + connection.close() + record = _history_record( + 1, + feedback_type="explicit_quote", + confidence="gold", + user_message_id="u1", + matched_by="explicit_quote", + reason="explicit_quote", + ) + consumer = module.FeedbackHistoryConsumer( + cast(Any, object()), tmp_path, PluginTimers.candidate_validation(), + cast(Any, RecordingHistory([record])), now=lambda: NOW, + ) -path = Path(sys.argv[1]) -package_name = "emotion_crash_test" -package = types.ModuleType(package_name) -package.__path__ = [str(path.parent)] -sys.modules[package_name] = package -spec = importlib.util.spec_from_file_location( - package_name + ".plugin", - path, - submodule_search_locations=[str(path.parent)], -) -assert spec is not None and spec.loader is not None -module = importlib.util.module_from_spec(spec) -sys.modules[spec.name] = module -spec.loader.exec_module(module) -conn = module.open_db(Path(sys.argv[2])) -module.commit_domain_effect( - conn, - semantic_job_id="emotion:merge_proactive_pending", - event_id="crash-event", - invocation_id="crash-invocation", - effect_id="emotion.state", - idempotency_key="emotion:merge_proactive_pending:event:crash-event", - attempt=1, - result_digest="crash-digest", + assert consumer.tick_once() is False + connection = sqlite3.connect(tmp_path / "emotion.db") + assert connection.execute("SELECT count(*) FROM emotion_events").fetchone() == (2,) + assert connection.execute( + "SELECT count(*) FROM emotion_feedback_samples" + ).fetchone() == (1,) + terminal = connection.execute( + "SELECT source_type, reason, valence_delta, dominance_delta " + "FROM emotion_events WHERE source_event_id='proactive_feedback:1'" + ).fetchone() + assert terminal == ( + "explicit_quote_already_applied", + "direct_quote_already_applied", + 0.0, + 0.0, + ) + assert connection.execute( + "SELECT valence, dominance FROM emotion_state WHERE id=1" + ).fetchone() == before + assert connection.execute( + "SELECT row_id FROM pf_history_cursor WHERE source='proactive_feedback'" + ).fetchone() == (1,) + connection.close() + +def test_feedback_history_page_failure_rolls_back_all_derived_facts(tmp_path: Path) -> None: + connection = module.open_db(tmp_path / "emotion.db") + connection.execute( + """ + CREATE TRIGGER reject_second_feedback + BEFORE INSERT ON emotion_events + WHEN NEW.source_event_id = 'proactive_feedback:2' + BEGIN SELECT RAISE(ABORT, 'fixture rejection'); END + """ + ) + connection.commit() + connection.close() + history = RecordingHistory([_history_record(1), _history_record(2)]) + consumer = module.FeedbackHistoryConsumer( + cast(Any, object()), tmp_path, PluginTimers.candidate_validation(), + cast(Any, history), now=lambda: NOW, + ) + with pytest.raises(sqlite3.IntegrityError, match="fixture rejection"): + consumer.tick_once() + connection = sqlite3.connect(tmp_path / "emotion.db") + assert connection.execute("SELECT count(*) FROM emotion_events").fetchone() == (0,) + assert connection.execute( + "SELECT count(*) FROM emotion_feedback_samples" + ).fetchone() == (0,) + assert connection.execute( + "SELECT row_id FROM pf_history_cursor WHERE source='proactive_feedback'" + ).fetchone() == (0,) + connection.execute("DROP TRIGGER reject_second_feedback") + connection.commit() + connection.close() + restarted = module.FeedbackHistoryConsumer( + cast(Any, object()), tmp_path, PluginTimers.candidate_validation(), + cast(Any, history), now=lambda: NOW, + ) + assert restarted.tick_once() is False + connection = sqlite3.connect(tmp_path / "emotion.db") + assert connection.execute("SELECT count(*) FROM emotion_events").fetchone() == (2,) + assert connection.execute( + "SELECT row_id FROM pf_history_cursor WHERE source='proactive_feedback'" + ).fetchone() == (2,) + connection.close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "order", + (("emotion", "feedback"), ("feedback", "emotion")), ) -conn.close() -os._exit(137) - """ - env = dict(os.environ) - core_root = os.environ.get("AKASHIC_AGENT_ROOT") or str( - Path(__file__).parents[3] / "akasic-agent" - ) - env["PYTHONPATH"] = core_root + os.pathsep + str(plugin_path.parent) - result = subprocess.run( - [sys.executable, "-c", script, str(plugin_path), str(db_path)], - env=env, - check=False, - ) - assert result.returncode == 137 - restarted = module.open_db(db_path) +async def test_feedback_history_composes_in_both_mount_orders_and_uses_timer( + tmp_path: Path, + order: tuple[str, str], +) -> None: + root, timer, emotion_root = await _mount_formal_with_history( + tmp_path / "-".join(order), order=order + ) try: - found = module.lookup_domain_effect( - restarted, - invocation_id="crash-invocation", - effect_id="emotion.state", - idempotency_key="emotion:merge_proactive_pending:event:crash-event", + await root.context.serial(RUNTIME_STARTED, RuntimeStarted()) + await _eventually(lambda: len(timer.handles) >= 1) + feedback_timer = min(timer.handles, key=lambda handle: handle.deadline) + assert not (emotion_root / "emotion.db").exists() or _count_rows( + emotion_root / "emotion.db", "emotion_feedback_samples" + ) == 0 + feedback_timer.fire() + await _eventually( + lambda: (emotion_root / "emotion.db").exists() + and _count_rows( + emotion_root / "emotion.db", "emotion_feedback_samples" + ) == 1 ) + assert sum(not handle._future.done() for handle in timer.handles) == 2 + await root.context.serial(RUNTIME_STOPPING, RuntimeStopping()) + assert timer.active == 0 finally: - restarted.close() - assert found is not None - assert found.result_digest == "crash-digest" + await root.dispose() @pytest.mark.asyncio -async def test_v3_apply_registers_job_without_opening_emotion_db(tmp_path: Path) -> None: - root, emotion_root = await _mount_runtime(tmp_path) +async def test_feedback_history_candidate_handshake_has_zero_timer_and_db( + tmp_path: Path, +) -> None: + root, emotion_root = await _mount_candidate(tmp_path) + history = RecordingHistory([_history_record(1)]) + try: + async def provider(ctx: Any) -> None: + _ = await ctx.provide(module.PROACTIVE_FEEDBACK_HISTORY, history) + + _ = await root.mount(provider, name="proactive_feedback") + assert any( + fiber.name == "proactive-feedback-history" and fiber.state.value == "active" + for fiber in root.root_fiber.children + for fiber in fiber.children + ) + assert history.requests == [] + assert not (emotion_root / "emotion.db").exists() + finally: + await root.dispose() - _ = await root.mount( - lambda ctx: module.apply(ctx, object()), - name="emotion", - inject=(BACKGROUND_JOBS, PROACTIVE_COMPONENTS, UI_SLOTS), - runtime=PluginRuntime( - plugin_id="emotion", - plugin_dir=Path(__file__).parents[1], - data_dir=tmp_path / "plugin-data", - workspace=emotion_root.parent, - config=None, - workspace_roots=("emotion",), - ), + +@pytest.mark.asyncio +async def test_feedback_history_transient_failure_rearms_and_recovers( + tmp_path: Path, +) -> None: + timer = ManualTimer() + context = RecordingContext() + history = FailOnceHistory([_history_record(1)]) + consumer = module.FeedbackHistoryConsumer( + cast(Any, context), tmp_path, PluginTimers(timer), cast(Any, history), + now=lambda: NOW, ) - jobs = root.context.get(BACKGROUND_JOBS) - assert jobs is not None - catalog = _freeze_plugin_background_jobs(jobs, root.instance_token) - binding = catalog.job("emotion:merge_proactive_pending") - assert binding is not None - assert binding.definition.documents_scope == ("emotion",) - assert binding.definition.domain_effect == "emotion.state" - assert not (emotion_root / "emotion.db").exists() - await root.dispose() + await consumer.start() + await _eventually(lambda: len(timer.handles) == 1) + timer.handles[0].fire() + await _eventually(lambda: len(timer.handles) == 2) + assert context.incidents[0][0] == "emotion_feedback_history_transient" + assert not (tmp_path / "emotion.db").exists() + timer.handles[1].fire() + await _eventually( + lambda: (tmp_path / "emotion.db").exists() + and _count_rows(tmp_path / "emotion.db", "emotion_events") == 1 + ) + assert len(timer.handles) == 3 + await consumer.close() + assert timer.active == 0 @pytest.mark.asyncio -async def test_v3_merge_uses_core_ports_and_durable_emotion_receipt( +async def test_transient_tick_is_observable_and_rearms_without_state_pollution( tmp_path: Path, ) -> None: - emotion_root = tmp_path / "workspace" / "emotion" - emotion_root.mkdir(parents=True) - setattr(module, "_v3_emotion_root", emotion_root) - calls: list[str] = [] - prepared_intent = object() - issued_receipt = object() - - class Documents: - def read_pair(self): - calls.append("read") - return ( - ProactiveDocumentDigests(context=None, pending=None), - ProactiveDocumentPair( - context=b"# Proactive Context\n", - pending=b"- [ ] prefer calm summaries\n", - ), - ) + module._on_turn_committed(_feedback_turn(), root=tmp_path) + timer = ManualTimer() + context = RecordingContext() + drift = FailOnceDrift() + runtime = module.EmotionRuntime( + cast(Any, context), + tmp_path, + PluginTimers(timer), + drift, + drift, + now=lambda: NOW, + ) + await runtime.start() + await _eventually(lambda: len(timer.handles) == 1) + assert context.incidents[0][0] == "emotion_tick_transient" + assert timer.active == 1 + timer.handles[0].fire() + await _eventually(lambda: len(timer.handles) == 2) + assert drift.calls == 2 + assert timer.active == 1 + await runtime.close() + assert timer.active == 0 + + replacement = module.EmotionRuntime( + cast(Any, context), + tmp_path, + PluginTimers(timer), + drift, + drift, + now=lambda: NOW, + ) + await replacement.start() + await _eventually(lambda: len(timer.handles) == 3) + assert timer.active == 1 + await replacement.close() + assert timer.active == 0 - async def prepare_pair(self, expected, pair): - calls.append("prepare") - assert expected.pending is None - assert pair["pending"] == b"" - return prepared_intent - - async def commit_after(self, intent, receipt): - calls.append("documents") - assert intent is prepared_intent - assert receipt is issued_receipt - return object() - - class Effects: - async def run(self, effect_id, transaction): - calls.append("effect") - effect_ctx = SimpleNamespace( - semantic_job_id="emotion:merge_proactive_pending", - event_id="drift-v3-1", - invocation_id="invocation-v3-1", - effect_id=effect_id, - idempotency_key="emotion:merge_proactive_pending:event:drift-v3-1", - attempt=1, - ) - await transaction(effect_ctx) - durable = module.lookup_emotion_domain_effect_v3(effect_ctx) - assert durable is not None - return issued_receipt - - class Llm: - async def generate_text(self, **kwargs): - calls.append("llm") - assert "prefer calm summaries" in kwargs["prompt"] - return "# Proactive Context\n\n- Prefer calm summaries." - - event = DriftFinished( - event_id="drift-v3-1", - session_key="session", - skill_name="feedback-preference-context", - status="completed", - briefing="briefing", - message_result="ok", - timestamp=datetime.now(timezone.utc), - ) - ctx = SimpleNamespace( - event=event, - documents=Documents(), - domain_effects=Effects(), - llm=Llm(), - ) - - await module.merge_proactive_pending_v3(ctx) - - assert calls == ["read", "llm", "prepare", "effect", "documents"] - db = module.open_db(emotion_root / "emotion.db") - try: - assert db.execute("SELECT COUNT(*) FROM emotion_domain_effects").fetchone()[0] == 1 - finally: - db.close() + +@pytest.mark.asyncio +async def test_contract_failure_stops_without_rearming_and_stays_observable( + tmp_path: Path, +) -> None: + module._on_turn_committed(_feedback_turn(), root=tmp_path) + timer = ManualTimer() + context = RecordingContext() + drift = ContractBrokenDrift() + runtime = module.EmotionRuntime( + cast(Any, context), + tmp_path, + PluginTimers(timer), + drift, + drift, + now=lambda: NOW, + ) + await runtime.start() + await _eventually(lambda: bool(context.tasks) and context.tasks[0].done()) + with pytest.raises(RuntimeError, match="drift contract mismatch"): + _ = context.tasks[0].result() + assert timer.handles == [] + assert context.incidents == [] + await runtime.close() diff --git a/tests/test_proactive_feedback_interop.py b/tests/test_proactive_feedback_interop.py new file mode 100644 index 0000000..268fef9 --- /dev/null +++ b/tests/test_proactive_feedback_interop.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import importlib +import importlib.util +import os +import sqlite3 +import sys +from contextlib import closing +from pathlib import Path +from types import ModuleType +from typing import Any, cast + +from agent.plugin_composition import PluginTimers +from bus.events_lifecycle import TurnCommitted + + + +def _load_emotion_module() -> ModuleType: + path = Path(__file__).parents[1] / "plugin.py" + spec = importlib.util.spec_from_file_location( + "emotion_pf_interop_plugin", + path, + submodule_search_locations=[str(path.parent)], + ) + if spec is None or spec.loader is None: + raise ImportError(str(path)) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +EMOTION = _load_emotion_module() + + +def _load_feedback_modules() -> tuple[ModuleType, ModuleType]: + root_value = os.environ.get("AKASHIC_PROACTIVE_FEEDBACK_ROOT", "").strip() + if not root_value: + raise RuntimeError( + "AKASHIC_PROACTIVE_FEEDBACK_ROOT 必须指向 exact Feedback checkout" + ) + root = Path(root_value) + package = ModuleType("emotion_pf_interop") + package.__path__ = [str(root)] # type: ignore[attr-defined] + sys.modules[package.__name__] = package + return ( + importlib.import_module("emotion_pf_interop.db"), + importlib.import_module("emotion_pf_interop.history"), + ) + + +def test_pf_accepted_fact_reaches_emotion_only_after_history_pull( + tmp_path: Path, +) -> None: + pf_db, pf_history = _load_feedback_modules() + assert ( + pf_history.PROACTIVE_FEEDBACK_HISTORY + == EMOTION.PROACTIVE_FEEDBACK_HISTORY + ) + feedback_path = tmp_path / "feedback" / "proactive_feedback.db" + connection = pf_db.open_db(feedback_path) + try: + row_id = pf_db.insert_feedback( + connection, + pf_db.FeedbackEvent( + session_key="mobile:interop", + user_message_id="u1", + assistant_message_id="a1", + proactive_message_id="p1", + feedback_type="topic_follow", + confidence="high", + pa_score=0.9, + pua_score=0.8, + lag_seconds=12, + candidate_count=1, + matched_by="pua", + reason="fixture", + user_content_preview="好呀,我晚上去", + assistant_content_preview="好,晚上更凉快", + proactive_content_preview="记得今天散步", + ), + ) + finally: + connection.close() + assert row_id == 1 + + emotion_root = tmp_path / "emotion" + EMOTION._on_turn_committed( + TurnCommitted( + session_key="mobile:interop", + channel="test", + chat_id="chat", + input_message="好呀,我晚上去", + persisted_user_message="好呀,我晚上去", + assistant_response="好,晚上更凉快", + tools_used=[], + turn_id="turn-1", + persisted_user_message_id="u1", + assistant_message_id="a1", + extra={"proactive_feedback": {"feedback_type": "topic_follow"}}, + ), + root=emotion_root, + ) + assert not (emotion_root / "emotion.db").exists() + + consumer = EMOTION.FeedbackHistoryConsumer( + cast(Any, object()), + emotion_root, + PluginTimers.candidate_validation(), + pf_history.SqliteFeedbackHistory(feedback_path), + ) + assert consumer.tick_once() is False + assert consumer.tick_once() is False + with closing(sqlite3.connect(emotion_root / "emotion.db")) as emotion: + assert emotion.execute( + "SELECT count(*) FROM emotion_feedback_samples" + ).fetchone() == (1,) + assert emotion.execute( + "SELECT count(*) FROM emotion_events" + ).fetchone() == (1,) + assert emotion.execute( + "SELECT row_id FROM pf_history_cursor " + "WHERE source='proactive_feedback'" + ).fetchone() == (1,)