From cf11b04bff89dd91782061a5908db36fd9b1b70e Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Mon, 17 Aug 2026 18:02:47 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat(plugin):=20=E5=AE=8C=E6=88=90=20Proact?= =?UTF-8?q?ive=20Feedback=20v3=20=E8=BF=81=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/plugin-api-v3.yml | 70 ++++ README.md | 29 ++ akashic.plugin.toml | 13 + dashboard.py | 113 +++--- db.py | 30 +- events.py | 5 - plugin.py | 394 ++++++++++----------- scorer.py | 126 +++++++ scripts/migrate_feedback_previews.py | 130 +++++++ scripts/migrate_v2_data.py | 209 ++++++++++++ tests/test_backfill_paths.py | 67 ++++ tests/test_migrate_v2_data.py | 98 ++++++ tests/test_plugin.py | 492 +++++++++++++++++++-------- 13 files changed, 1370 insertions(+), 406 deletions(-) create mode 100644 .github/workflows/plugin-api-v3.yml create mode 100644 akashic.plugin.toml delete mode 100644 events.py create mode 100644 scripts/migrate_feedback_previews.py create mode 100644 scripts/migrate_v2_data.py create mode 100644 tests/test_migrate_v2_data.py diff --git a/.github/workflows/plugin-api-v3.yml b/.github/workflows/plugin-api-v3.yml new file mode 100644 index 0000000..47b5cfe --- /dev/null +++ b/.github/workflows/plugin-api-v3.yml @@ -0,0 +1,70 @@ +name: plugin-api-v3 + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +jobs: + contract: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + with: + repository: akashic-plugins/plugin-contracts + ref: 4dd69dd621e029e51e99aa428443fa3a4ec1f6cf + path: .plugin-contracts + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Check Plugin API v3 + env: + PYTHONPATH: .plugin-contracts + run: python -m akashic_plugin_contracts check plugin.py + + plugin-tests: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + with: + repository: kachofugetsu09/akashic-agent + ref: 5624a059348406c1f97993612adfec886b158158 + path: .akashic-core + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + cache: pip + cache-dependency-path: | + .akashic-core/requirements.txt + .akashic-core/requirements-dev.txt + - name: Install exact Core runtime + run: python -m pip install -r .akashic-core/requirements.txt -r .akashic-core/requirements-dev.txt + - name: Run focused plugin tests + env: + AKASHIC_AGENT_ROOT: .akashic-core + PYTHONPATH: .akashic-core + run: python -m pytest -q tests + - uses: actions/setup-node@v4 + with: + node-version: "22" + - name: Verify mobile panel + run: node --test tests/test_mobile_panel.mjs + - name: Check v3 source types + env: + AKASHIC_AGENT_ROOT: .akashic-core + PYTHONPATH: .akashic-core + run: >- + pyright --level error plugin.py dashboard.py db.py scorer.py + scripts/migrate_v2_data.py scripts/migrate_feedback_previews.py tests + - name: Compile Python sources + run: python -m compileall -q plugin.py dashboard.py db.py scorer.py tests scripts + - name: Check diff formatting + run: git diff --check diff --git a/README.md b/README.md index 162f818..6f860a4 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,35 @@ Akashic proactive feedback plugin. +## v3 接入 + +插件入口是 module-level `api_version = 3` 与 `apply(ctx, config)`: + +- 通过 Core `AFTER_TURN_COMMITTED` 串行接入点观察已提交 Turn; +- 通过 `SESSION_READ` 读取脱离持久化 owner 的 Session 快照; +- 反馈数据库由 Core 分配的 `ctx.data_root` 独占,Dashboard 与 Mobile 只读同一投影; +- `apply` 不读取或写入正式 `sessions.db`,候选期不会访问正式 Session。 + +插件加载不会自动移动旧数据库。首次从 v2 切换时,先停用旧 runtime,再显式执行 +SQLite 一致性迁移;旧源始终保留: + +```bash +python scripts/migrate_v2_data.py \ + --workspace \ + --marketplace github +``` + +迁移完成后,可用下面的独立命令补齐历史事件的文本预览;它只更新插件自己的可选投影列,不删除消息: + +```bash +python scripts/migrate_feedback_previews.py \ + --sessions-db /sessions.db \ + --feedback-db /plugin-data//proactive_feedback.db +``` + +插件不再声明 v2 `Plugin` class、EventBus listener、`ProactiveFeedbackRecorded` 或 tool +ABI;v3 运行路径只观察 Core 的 committed Turn 事件。 + ## 移动端看板 插件通过 Akashic 的通用移动 UI 生命周期注册“主动反馈”入口,不要求 Agent 核心识别 diff --git a/akashic.plugin.toml b/akashic.plugin.toml new file mode 100644 index 0000000..0508bc1 --- /dev/null +++ b/akashic.plugin.toml @@ -0,0 +1,13 @@ +schema_version = 1 +name = "proactive_feedback" +version = "3.0.0" +api_version = 3 +entrypoint = "plugin.py" + +[validation] +exclude_data_paths = [ + "proactive_feedback.db", + "proactive_feedback.db-wal", + "proactive_feedback.db-shm", + ".proactive-feedback-v2-migration.json", +] diff --git a/dashboard.py b/dashboard.py index f26e02e..8e71c17 100644 --- a/dashboard.py +++ b/dashboard.py @@ -3,16 +3,26 @@ from contextlib import contextmanager import sqlite3 import threading +from collections.abc import Iterator from pathlib import Path -from typing import Any, Iterator +from typing import Any +from agent.plugin_composition import DashboardContext from fastapi import FastAPI +_PREVIEW_COLUMNS = ( + "user_content_preview", + "assistant_content_preview", + "proactive_content_preview", +) + + class ProactiveFeedbackDashboardReader: - def __init__(self, workspace: Path) -> None: - self.db_path = workspace / "proactive_feedback" / "proactive_feedback.db" - self.sessions_db_path = workspace / "sessions.db" + """Read the plugin-owned feedback projection without opening Core databases.""" + + def __init__(self, data_root: Path) -> None: + self.db_path = data_root / "proactive_feedback.db" self._lock = threading.RLock() def get_overview(self) -> dict[str, Any]: @@ -103,7 +113,8 @@ def list_events( lag_seconds, candidate_count, matched_by, - reason + reason, + {_preview_select(db)} FROM proactive_feedback_events {where} ORDER BY created_at DESC, id DESC @@ -111,8 +122,7 @@ def list_events( """, (*params, safe_size, offset), ).fetchall() - previews = self._load_previews(rows) - return [_event_row(row, previews, preview_limit=360) for row in rows], total + return [_event_row(row, preview_limit=360) for row in rows], total def get_event(self, event_id: int) -> dict[str, Any] | None: if not self.db_path.exists(): @@ -127,42 +137,13 @@ def get_event(self, event_id: int) -> dict[str, Any] | None: """, (event_id,), ).fetchone() - if row is None: - return None - previews = self._load_previews([row]) - return _event_row(row, previews, preview_limit=2400) - - def _load_previews(self, rows: list[sqlite3.Row]) -> dict[str, str]: - ids: list[str] = [] - for row in rows: - ids.extend( - str(value) - for value in ( - row["user_message_id"], - row["assistant_message_id"], - row["proactive_message_id"], - ) - if value - ) - if not ids or not self.sessions_db_path.exists(): - return {} - unique_ids = list(dict.fromkeys(ids)) - placeholders = ",".join("?" for _ in unique_ids) - with _connect(self.sessions_db_path) as db: - msg_rows = db.execute( - f""" - SELECT id, content - FROM messages - WHERE id IN ({placeholders}) - """, - unique_ids, - ).fetchall() - return {str(row["id"]): str(row["content"] or "") for row in msg_rows} - - -def register(app: FastAPI, plugin_dir: Path, workspace: Path) -> None: - _ = plugin_dir - reader = ProactiveFeedbackDashboardReader(workspace) + return None if row is None else _event_row(row, preview_limit=2400) + + +def register(app: FastAPI, context: DashboardContext) -> None: + """Register dashboard routes against the exact generation data root.""" + + reader = ProactiveFeedbackDashboardReader(context.data_root) @app.get("/api/dashboard/proactive-feedback/overview") def get_proactive_feedback_overview() -> dict[str, Any]: @@ -205,6 +186,17 @@ def _empty_overview() -> dict[str, Any]: } +def _preview_select(db: sqlite3.Connection) -> str: + columns = { + str(row[1]) + for row in db.execute("PRAGMA table_info(proactive_feedback_events)") + } + return ", ".join( + column if column in columns else f"NULL AS {column}" + for column in _PREVIEW_COLUMNS + ) + + def _group_rows(db: sqlite3.Connection, column: str) -> list[dict[str, Any]]: rows = db.execute( f""" @@ -226,23 +218,15 @@ def _scalar_int( return int(row[0] or 0) if row is not None else 0 -def _event_row( - row: sqlite3.Row, - previews: dict[str, str], - *, - preview_limit: int = 120, -) -> dict[str, Any]: - user_id = str(row["user_message_id"]) - assistant_id = str(row["assistant_message_id"]) - proactive_id = str(row["proactive_message_id"] or "") - user_text = previews.get(user_id) +def _event_row(row: sqlite3.Row, *, preview_limit: int = 120) -> dict[str, Any]: + user_text = _row_text(row, "user_content_preview") return { "id": int(row["id"]), "created_at": row["created_at"], "session_key": row["session_key"], - "user_message_id": user_id, - "assistant_message_id": assistant_id, - "proactive_message_id": proactive_id, + "user_message_id": str(row["user_message_id"]), + "assistant_message_id": str(row["assistant_message_id"]), + "proactive_message_id": str(row["proactive_message_id"] or ""), "feedback_type": row["feedback_type"], "confidence": row["confidence"], "pa_score": row["pa_score"], @@ -254,11 +238,24 @@ def _event_row( "user_preview": _preview(user_text, preview_limit), "user_reply_preview": _preview(_current_reply(user_text), preview_limit), "quoted_preview": _preview(_quoted_reply(user_text), preview_limit), - "assistant_preview": _preview(previews.get(assistant_id), preview_limit), - "proactive_preview": _preview(previews.get(proactive_id), preview_limit), + "assistant_preview": _preview( + _row_text(row, "assistant_content_preview"), + preview_limit, + ), + "proactive_preview": _preview( + _row_text(row, "proactive_content_preview"), + preview_limit, + ), } +def _row_text(row: sqlite3.Row, name: str) -> str | None: + if name not in row.keys(): + return None + value = row[name] + return None if value is None else str(value) + + def _preview(value: str | None, limit: int) -> str: text = str(value or "").replace("\n", " ").strip() if len(text) <= limit: diff --git a/db.py b/db.py index 9677c73..ff15a8d 100644 --- a/db.py +++ b/db.py @@ -19,6 +19,9 @@ class FeedbackEvent: candidate_count: int matched_by: str reason: str + user_content_preview: str | None = None + assistant_content_preview: str | None = None + proactive_content_preview: str | None = None def open_db(path: Path) -> sqlite3.Connection: @@ -44,6 +47,9 @@ def open_db(path: Path) -> sqlite3.Connection: candidate_count INTEGER NOT NULL, matched_by TEXT NOT NULL, reason TEXT NOT NULL, + user_content_preview TEXT, + assistant_content_preview TEXT, + proactive_content_preview TEXT, UNIQUE(user_message_id, proactive_message_id) ); @@ -58,6 +64,9 @@ def open_db(path: Path) -> sqlite3.Connection: WHERE proactive_message_id IS NOT NULL; """ ) + _ensure_column(conn, "user_content_preview") + _ensure_column(conn, "assistant_content_preview") + _ensure_column(conn, "proactive_content_preview") conn.commit() return conn @@ -94,9 +103,12 @@ def insert_feedback(conn: sqlite3.Connection, event: FeedbackEvent) -> int | Non lag_seconds, candidate_count, matched_by, - reason + reason, + user_content_preview, + assistant_content_preview, + proactive_content_preview ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( event.session_key, @@ -111,6 +123,9 @@ def insert_feedback(conn: sqlite3.Connection, event: FeedbackEvent) -> int | Non event.candidate_count, event.matched_by, event.reason, + event.user_content_preview, + event.assistant_content_preview, + event.proactive_content_preview, ), ) conn.commit() @@ -118,3 +133,14 @@ def insert_feedback(conn: sqlite3.Connection, event: FeedbackEvent) -> int | Non if row_id is None: raise RuntimeError("feedback insert failed") return int(row_id) + + +def _ensure_column(conn: sqlite3.Connection, name: str) -> None: + columns = { + str(row[1]) + for row in conn.execute("PRAGMA table_info(proactive_feedback_events)") + } + if name not in columns: + _ = conn.execute( + f"ALTER TABLE proactive_feedback_events ADD COLUMN {name} TEXT" + ) diff --git a/events.py b/events.py deleted file mode 100644 index 47c410d..0000000 --- a/events.py +++ /dev/null @@ -1,5 +0,0 @@ -from __future__ import annotations - -from bus.events_proactive import ProactiveFeedbackRecorded - -__all__ = ["ProactiveFeedbackRecorded"] diff --git a/plugin.py b/plugin.py index 7aa6e7e..153f7c7 100644 --- a/plugin.py +++ b/plugin.py @@ -2,76 +2,123 @@ import asyncio import logging -import sqlite3 -from contextlib import suppress from pathlib import Path -from typing import Any -from agent.config_models import Config -from agent.plugins import MobileUiContribution, MobileUiNavigation, Plugin, tool -from agent.plugins.mobile_ui import MobileUiRpcInvalidRequest -from bus.events_proactive import ProactiveFeedbackRecorded +from agent.config_models import Config as CoreConfig +from agent.plugin_composition import ( + Context, + MobileUiDefinition, + MobileUiNavigation, + MobileUiRpcInvalidRequest, + SESSION_READ, + SessionReadService, + UI_SLOTS, +) +from agent.turn_events.after_turn import AFTER_TURN_COMMITTED from bus.events_lifecycle import TurnCommitted from memory2.embedder import Embedder -from .db import FeedbackEvent, insert_feedback, open_db from .dashboard import ProactiveFeedbackDashboardReader +from .db import FeedbackEvent, insert_feedback, open_db from .scorer import ( - latest_turn_messages, + latest_turn_messages_from_rows, + message_rows_from_snapshot, + MessageRow, parse_quote_parts, - proactive_since_previous_user, - recent_proactive_messages, + proactive_since_previous_user_from_rows, + recent_proactive_messages_from_rows, score_followup, ) logger = logging.getLogger("plugin.proactive_feedback") _QUEUE_MAX = 100 +_FEEDBACK_DB_NAME = "proactive_feedback.db" +_PREVIEW_MAX_CHARS = 2400 + +api_version = 3 +name = "proactive_feedback" +version = "3.0.0" +desc = "记录主动消息被继续的反馈,并提供桌面与移动只读投影。" +author = "Akashic" +inject = (SESSION_READ, UI_SLOTS) +skill_roots: tuple[str, ...] = () +drift_skill_roots: tuple[str, ...] = () +workspace_roots: tuple[str, ...] = () +dashboard_module = "dashboard.py" + + +async def apply(ctx: Context, config: object) -> None: + """Register the committed-turn observer, worker, and exact mobile projection.""" + + # 1. Resolve only Core-owned services and generation paths. + _ = config + session_read = ctx.require(SESSION_READ) + ui_slots = ctx.require(UI_SLOTS) + db_path = ctx.data_root / _FEEDBACK_DB_NAME + runtime = ProactiveFeedbackRuntime( + session_read=session_read, + workspace=ctx.runtime.workspace, + db_path=db_path, + ) - -class ProactiveFeedbackPlugin(Plugin): - api_version = 2 - @classmethod - def dashboard_module(cls) -> str | None: - return "dashboard.py" - - @classmethod - def mobile_ui(cls) -> MobileUiContribution: - return MobileUiContribution( + # 2. Bind every executable contribution to this Fiber's lifecycle. + await ctx.on(AFTER_TURN_COMMITTED, runtime.enqueue) + await ui_slots.register_mobile( + ctx, + MobileUiDefinition( module="mobile_panel.js", stylesheet="mobile_panel.css", navigation=MobileUiNavigation( label="主动反馈", description="主动消息是否被继续,以及对应的回应链路", ), - ) + ), + query=runtime.mobile_ui_query, + ) + await ctx.spawn(runtime.run_worker(), name="proactive_feedback_worker") - name = "proactive_feedback" - version = "1.1.0" - def activate(self) -> None: - workspace = self.context.workspace - if workspace is None: - logger.warning("proactive_feedback 插件缺少 workspace,跳过加载") - return +class ProactiveFeedbackRuntime: + """Own one generation's feedback queue and plugin-owned SQLite projection.""" + + def __init__( + self, + *, + session_read: SessionReadService, + workspace: Path, + db_path: Path, + ) -> None: + self._session_read = session_read self._workspace = workspace - self._sessions_db = workspace / "sessions.db" - self._db_path = workspace / "proactive_feedback" / "proactive_feedback.db" + self._db_path = db_path self._queue: asyncio.Queue[TurnCommitted] = asyncio.Queue(maxsize=_QUEUE_MAX) self._embedder: Embedder | None = None - self._worker_task = self.context.create_task( - self._run_worker(), - name="proactive_feedback_worker", - ) - self.context.event_bus.on(TurnCommitted, self._on_turn_committed) - async def terminate(self) -> None: - task = getattr(self, "_worker_task", None) - if task is None: + def enqueue(self, event: TurnCommitted) -> None: + """Queue one committed turn without blocking the Core lifecycle seam.""" + + if event.persisted_user_message is None: return - _ = task.cancel() - with suppress(asyncio.CancelledError): - await task + try: + self._queue.put_nowait(event) + except asyncio.QueueFull: + logger.warning( + "proactive_feedback queue full, drop session=%s", + event.session_key, + ) + + async def run_worker(self) -> None: + """Process queued committed turns until the owning Fiber is disposed.""" + + while True: + event = await self._queue.get() + try: + await self._process(event) + except Exception: + logger.exception("proactive_feedback process failed") + finally: + self._queue.task_done() def mobile_ui_query( self, @@ -81,24 +128,21 @@ def mobile_ui_query( session_id: str | None, turn_id: str | None, ) -> dict[str, object]: - """返回主动反馈的移动端任务投影。""" + """Return the read-only mobile projection for this exact generation.""" - # 1. 在插件 RPC 边界校验查询方法与分页参数 + # 1. Validate the bounded RPC input before touching plugin data. _ = session_id, turn_id if method not in {"feedback.overview", "feedback.events"}: raise MobileUiRpcInvalidRequest( f"未知 proactive_feedback 移动方法: {method}" ) - workspace = self.context.workspace - if workspace is None: - raise RuntimeError("proactive_feedback 移动看板缺少 workspace") - reader = ProactiveFeedbackDashboardReader(workspace) + reader = ProactiveFeedbackDashboardReader(self._db_path.parent) if method == "feedback.overview": if payload: raise MobileUiRpcInvalidRequest("feedback.overview 不接受参数") return reader.get_overview() - # 2. 调度器线程复用桌面端已经验证的反馈关联数据 + # 2. Reuse the dashboard reader for stable pagination and filters. if set(payload) - {"page", "page_size", "feedback_type"}: raise MobileUiRpcInvalidRequest("feedback.events 参数无效") page = _mobile_page_value(payload, "page", default=1, maximum=10_000) @@ -116,67 +160,51 @@ def mobile_ui_query( "page_size": page_size, } - def _on_turn_committed(self, event: TurnCommitted) -> None: - if event.persisted_user_message is None: - return - queue = getattr(self, "_queue", None) - if queue is None: - return - try: - queue.put_nowait(event) - except asyncio.QueueFull: - logger.warning("proactive_feedback queue full, drop session=%s", event.session_key) - - async def _run_worker(self) -> None: - while True: - event = await self._queue.get() - try: - await self._process(event) - except Exception: - logger.exception("proactive_feedback process failed") - finally: - self._queue.task_done() - async def _process(self, event: TurnCommitted) -> None: + """Score one committed turn using a detached Session snapshot.""" + + # 1. Resolve the committed message identity through Core's read service. user_text = event.persisted_user_message if not user_text or not event.assistant_response: return - if not self._sessions_db.exists(): + snapshot = self._session_read.read(event.session_key) + if snapshot is None: return - - source = sqlite3.connect(self._sessions_db) - source.row_factory = sqlite3.Row - try: - turn = latest_turn_messages( - source, - session_key=event.session_key, - user_content=user_text, - assistant_content=event.assistant_response, + rows = message_rows_from_snapshot(snapshot.messages) + turn = latest_turn_messages_from_rows( + rows, + user_message_id=event.persisted_user_message_id, + assistant_message_id=event.assistant_message_id, + user_content=user_text, + assistant_content=event.assistant_response, + ) + if turn is None: + logger.warning( + "proactive_feedback committed message missing session=%s", + event.session_key, + ) + return + user, assistant = turn + + # 2. Preserve the v2 candidate window and quote matching semantics. + quote = parse_quote_parts(user.content) + allow_pua = not bool(quote.quoted_text) + if quote.quoted_text: + candidates = recent_proactive_messages_from_rows( + rows, + before_seq=user.seq, + limit=64, + ) + else: + candidates = proactive_since_previous_user_from_rows( + rows, + before_seq=user.seq, + limit=8, ) - if turn is None: - return - user, assistant = turn - quote = parse_quote_parts(user.content) - allow_pua = not bool(quote.quoted_text) - if quote.quoted_text: - candidates = recent_proactive_messages( - source, - session_key=event.session_key, - before_seq=user.seq, - limit=64, - ) - else: - candidates = proactive_since_previous_user( - source, - session_key=event.session_key, - before_seq=user.seq, - limit=8, - ) - finally: - source.close() if not candidates: return + # 3. Persist one deduplicated projection, including bounded display text. try: scored = await score_followup( embed_batch=self._get_embedder().embed_batch if allow_pua else _no_embed, @@ -187,100 +215,91 @@ async def _process(self, event: TurnCommitted) -> None: ) except Exception: logger.exception("proactive_feedback scoring failed") - scored = None - if candidates: - sink = open_db(self._db_path) - try: - feedback = FeedbackEvent( - session_key=event.session_key, - user_message_id=user.id, - assistant_message_id=assistant.id, - proactive_message_id=candidates[0].id, - feedback_type="unscored", - confidence="low", - pa_score=None, - pua_score=None, - lag_seconds=None, - candidate_count=len(candidates), - matched_by="recent_pua", - reason="scoring_failed", - ) - event_id = insert_feedback(sink, feedback) - finally: - sink.close() - if event_id is not None: - await self.context.event_bus.fanout(_recorded_event(event_id, feedback)) + await self._persist_feedback( + event=event, + user=user, + assistant=assistant, + proactive=candidates[0], + feedback_type="unscored", + confidence="low", + pa_score=None, + pua_score=None, + lag_seconds=None, + candidate_count=len(candidates), + matched_by="recent_pua", + reason="scoring_failed", + ) + return if scored is None: return + await self._persist_feedback( + event=event, + user=user, + assistant=assistant, + proactive=scored.proactive, + feedback_type=scored.feedback_type, + confidence=scored.confidence, + pa_score=scored.pa_score, + pua_score=scored.pua_score, + lag_seconds=scored.lag_seconds, + candidate_count=scored.candidate_count, + matched_by=scored.matched_by, + reason=scored.reason, + ) + async def _persist_feedback( + self, + *, + event: TurnCommitted, + user: MessageRow, + assistant: MessageRow, + proactive: MessageRow, + 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, + ) -> None: sink = open_db(self._db_path) try: - feedback = FeedbackEvent( - session_key=event.session_key, - user_message_id=user.id, - assistant_message_id=assistant.id, - proactive_message_id=scored.proactive.id, - feedback_type=scored.feedback_type, - confidence=scored.confidence, - pa_score=scored.pa_score, - pua_score=scored.pua_score, - lag_seconds=scored.lag_seconds, - candidate_count=scored.candidate_count, - matched_by=scored.matched_by, - reason=scored.reason, + _ = insert_feedback( + sink, + FeedbackEvent( + session_key=event.session_key, + user_message_id=user.id, + assistant_message_id=assistant.id, + proactive_message_id=proactive.id, + feedback_type=feedback_type, + confidence=confidence, + pa_score=pa_score, + pua_score=pua_score, + lag_seconds=lag_seconds, + candidate_count=candidate_count, + matched_by=matched_by, + reason=reason, + user_content_preview=_bounded_preview(user.content), + assistant_content_preview=_bounded_preview(assistant.content), + proactive_content_preview=_bounded_preview(proactive.content), + ), ) - event_id = insert_feedback(sink, feedback) finally: sink.close() - if event_id is not None: - await self.context.event_bus.fanout(_recorded_event(event_id, feedback)) def _get_embedder(self) -> Embedder: if self._embedder is None: self._embedder = _build_embedder(self._workspace) return self._embedder - @tool( - "get_proactive_feedback_summary", - risk="read-only", - search_hint="查询 proactive 主动推送反馈统计摘要", - ) - async def get_summary(self, event: Any) -> dict[str, Any]: - """查询 proactive 主动推送反馈统计摘要。""" - _ = event - db_path = getattr(self, "_db_path", None) - if db_path is None or not Path(db_path).exists(): - return {"total": 0, "by_type": [], "by_confidence": []} - conn = open_db(Path(db_path)) - try: - total = conn.execute("SELECT count(*) FROM proactive_feedback_events").fetchone()[0] - by_type = _rows( - conn.execute( - """ - SELECT feedback_type, count(*) AS count - FROM proactive_feedback_events - GROUP BY feedback_type - ORDER BY count DESC - """ - ).fetchall() - ) - by_confidence = _rows( - conn.execute( - """ - SELECT confidence, count(*) AS count - FROM proactive_feedback_events - GROUP BY confidence - ORDER BY count DESC - """ - ).fetchall() - ) - finally: - conn.close() - return {"total": total, "by_type": by_type, "by_confidence": by_confidence} + +def _bounded_preview(value: str, limit: int = _PREVIEW_MAX_CHARS) -> str: + return value[:limit] def _build_embedder(workspace: Path) -> Embedder: - embedding = Config.load(workspace=workspace).memory.embedding + embedding = CoreConfig.load(workspace=workspace).memory.embedding return Embedder( base_url=embedding.base_url, api_key=embedding.api_key, @@ -289,30 +308,11 @@ def _build_embedder(workspace: Path) -> Embedder: ) -def _recorded_event(event_id: int, feedback: FeedbackEvent) -> ProactiveFeedbackRecorded: - return ProactiveFeedbackRecorded( - event_id=event_id, - session_key=feedback.session_key, - user_message_id=feedback.user_message_id, - assistant_message_id=feedback.assistant_message_id, - proactive_message_id=feedback.proactive_message_id or "", - feedback_type=feedback.feedback_type, - confidence=feedback.confidence, - pua_score=feedback.pua_score, - lag_seconds=feedback.lag_seconds, - matched_by=feedback.matched_by, - ) - - async def _no_embed(texts: list[str]) -> list[list[float]]: _ = texts raise RuntimeError("quoted feedback must not call embedding") -def _rows(rows: list[sqlite3.Row]) -> list[dict[str, Any]]: - return [dict(row) for row in rows] - - def _mobile_page_value( payload: dict[str, object], name: str, diff --git a/scorer.py b/scorer.py index 2b7dba0..dad5368 100644 --- a/scorer.py +++ b/scorer.py @@ -6,6 +6,7 @@ import sqlite3 from dataclasses import dataclass from datetime import datetime +from collections.abc import Mapping, Sequence from typing import Protocol @@ -42,6 +43,24 @@ class EmbedBatch(Protocol): async def __call__(self, texts: list[str]) -> list[list[float]]: ... +def message_rows_from_snapshot( + messages: Sequence[Mapping[str, object]], +) -> list[MessageRow]: + """将 Core 脱离持久化 owner 的消息快照转换成评分行。""" + + return [ + MessageRow( + id=str(message["id"]), + seq=_required_int(message["seq"], field="seq"), + role=str(message["role"]), + content=str(message.get("content") or ""), + extra=_optional_string(message.get("extra")), + ts=str(message.get("ts") or ""), + ) + for message in messages + ] + + def clean_text(text: str, max_chars: int = 1200) -> str: return re.sub(r"\s+", " ", text).strip()[:max_chars] @@ -129,6 +148,33 @@ def latest_turn_messages( return _row(user), _row(assistant) +def latest_turn_messages_from_rows( + rows: Sequence[MessageRow], + *, + user_message_id: str | None, + assistant_message_id: str | None, + user_content: str, + assistant_content: str, +) -> tuple[MessageRow, MessageRow] | None: + """按 TurnCommitted 身份从脱离快照解析本次 user/assistant。""" + + user = _latest_snapshot_row( + rows, + role="user", + message_id=user_message_id, + content=user_content, + ) + assistant = _latest_snapshot_row( + rows, + role="assistant", + message_id=assistant_message_id, + content=assistant_content, + ) + if user is None or assistant is None: + return None + return user, assistant + + def iter_user_assistant_turns( conn: sqlite3.Connection, ) -> list[tuple[str, MessageRow, MessageRow]]: @@ -212,6 +258,28 @@ def recent_proactive_messages( return proactive[:limit] +def recent_proactive_messages_from_rows( + rows: Sequence[MessageRow], + *, + before_seq: int, + limit: int, +) -> list[MessageRow]: + """从脱离快照取出当前 user 之前最近的主动 assistant。""" + + recent = sorted( + ( + row + for row in rows + if row.role == "assistant" + and row.seq < before_seq + and row.content + ), + key=lambda row: row.seq, + reverse=True, + )[: limit * 4] + return [row for row in recent if is_proactive(row.extra)][:limit] + + def proactive_since_previous_user( conn: sqlite3.Connection, *, @@ -251,6 +319,33 @@ def proactive_since_previous_user( return proactive[:limit] +def proactive_since_previous_user_from_rows( + rows: Sequence[MessageRow], + *, + before_seq: int, + limit: int | None = None, +) -> list[MessageRow]: + """从脱离快照取出上一个 user 之后的主动 assistant。""" + + previous = [ + row for row in rows if row.role == "user" and row.seq < before_seq + ] + after_seq = max((row.seq for row in previous), default=-1) + candidates = sorted( + ( + row + for row in rows + if row.role == "assistant" + and after_seq < row.seq < before_seq + and row.content + and is_proactive(row.extra) + ), + key=lambda row: row.seq, + reverse=True, + ) + return candidates if limit is None else candidates[:limit] + + async def score_followup( *, embed_batch: EmbedBatch, @@ -329,3 +424,34 @@ def _row(row: sqlite3.Row) -> MessageRow: extra=row["extra"], ts=str(row["ts"]), ) + + +def _latest_snapshot_row( + rows: Sequence[MessageRow], + *, + role: str, + message_id: str | None, + content: str, +) -> MessageRow | None: + candidates = [row for row in rows if row.role == role] + if message_id is not None: + candidates = [row for row in candidates if row.id == message_id] + else: + candidates = [row for row in candidates if row.content == content] + if message_id is not None and candidates and content: + candidates = [row for row in candidates if row.content == content] + return max(candidates, key=lambda row: row.seq, default=None) + + +def _optional_string(value: object) -> str | None: + if value is None: + return None + if not isinstance(value, str): + raise TypeError("消息 extra 必须是字符串或 None") + return value + + +def _required_int(value: object, *, field: str) -> int: + if isinstance(value, bool) or not isinstance(value, (int, str)): + raise TypeError(f"消息 {field} 必须是整数") + return int(value) diff --git a/scripts/migrate_feedback_previews.py b/scripts/migrate_feedback_previews.py new file mode 100644 index 0000000..88d5ef4 --- /dev/null +++ b/scripts/migrate_feedback_previews.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import argparse +import sqlite3 +from dataclasses import dataclass +from pathlib import Path + + +_PREVIEW_MAX_CHARS = 2400 +_PREVIEW_COLUMNS = ( + "user_content_preview", + "assistant_content_preview", + "proactive_content_preview", +) + + +@dataclass(frozen=True) +class MigrationStats: + scanned: int + updated: int + + +def migrate_feedback_previews( + *, + sessions_db: Path, + feedback_db: Path, +) -> MigrationStats: + """Fill plugin-owned display projections from immutable Session messages.""" + + if not sessions_db.exists(): + raise FileNotFoundError(sessions_db) + if not feedback_db.exists(): + raise FileNotFoundError(feedback_db) + if sessions_db.resolve() == feedback_db.resolve(): + raise ValueError("sessions.db 与 feedback.db 必须是两个不同文件") + source = sqlite3.connect(sessions_db) + source.row_factory = sqlite3.Row + sink = sqlite3.connect(feedback_db) + sink.row_factory = sqlite3.Row + try: + # 1. Add only nullable projection columns; existing identities remain intact. + _ensure_preview_columns(sink) + rows = sink.execute( + """ + SELECT id, user_message_id, assistant_message_id, proactive_message_id + FROM proactive_feedback_events + ORDER BY id ASC + """ + ).fetchall() + updated = 0 + + # 2. Resolve all message text in one bounded read batch per event row. + for row in rows: + ids = tuple( + str(value) + for value in ( + row["user_message_id"], + row["assistant_message_id"], + row["proactive_message_id"], + ) + if value + ) + if not ids: + continue + placeholders = ",".join("?" for _ in ids) + messages = source.execute( + f"SELECT id, content FROM messages WHERE id IN ({placeholders})", + ids, + ).fetchall() + content = { + str(message["id"]): _bounded_preview(message["content"]) + for message in messages + } + if not content: + continue + cursor = sink.execute( + """ + UPDATE proactive_feedback_events + SET user_content_preview = COALESCE(user_content_preview, ?), + assistant_content_preview = COALESCE(assistant_content_preview, ?), + proactive_content_preview = COALESCE(proactive_content_preview, ?) + WHERE id = ? + """, + ( + content.get(str(row["user_message_id"])), + content.get(str(row["assistant_message_id"])), + content.get(str(row["proactive_message_id"])), + row["id"], + ), + ) + updated += cursor.rowcount + sink.commit() + return MigrationStats(scanned=len(rows), updated=updated) + finally: + sink.close() + source.close() + + +def _ensure_preview_columns(conn: sqlite3.Connection) -> None: + columns = { + str(row[1]) + for row in conn.execute("PRAGMA table_info(proactive_feedback_events)") + } + for name in _PREVIEW_COLUMNS: + if name not in columns: + _ = conn.execute( + f"ALTER TABLE proactive_feedback_events ADD COLUMN {name} TEXT" + ) + + +def _bounded_preview(value: object) -> str: + return str(value or "")[:_PREVIEW_MAX_CHARS] + + +def main() -> None: + parser = argparse.ArgumentParser( + description="从 sessions.db 补齐 proactive feedback Dashboard 文本投影" + ) + _ = parser.add_argument("--sessions-db", type=Path, required=True) + _ = parser.add_argument("--feedback-db", type=Path, required=True) + args = parser.parse_args() + stats = migrate_feedback_previews( + sessions_db=args.sessions_db, + feedback_db=args.feedback_db, + ) + print(f"scanned={stats.scanned} updated={stats.updated}") + + +if __name__ == "__main__": + main() diff --git a/scripts/migrate_v2_data.py b/scripts/migrate_v2_data.py new file mode 100644 index 0000000..ade2637 --- /dev/null +++ b/scripts/migrate_v2_data.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +"""把 Proactive Feedback v2 SQLite 非破坏迁移到 v3 plugin-data。""" + +from __future__ import annotations + +import argparse +from contextlib import closing +import hashlib +import json +import os +from pathlib import Path +import shutil +import sqlite3 +import uuid + +from agent.plugins.manifest import ( + ensure_workspace_plugin_data_dir, + validate_workspace_plugin_data_path, +) +from bootstrap.workspace_lock import WorkspaceInstanceLock + + +_DATABASE = "proactive_feedback.db" +_RECEIPT = ".proactive-feedback-v2-migration.json" + + +def _digest(path: Path) -> str: + value = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + value.update(chunk) + return value.hexdigest() + + +def _integrity(path: Path) -> None: + uri = f"{path.resolve().as_uri()}?mode=ro" + with closing(sqlite3.connect(uri, uri=True)) as database: + result = database.execute("PRAGMA integrity_check").fetchone() + if result != ("ok",): + raise sqlite3.DatabaseError(f"Proactive Feedback SQLite 损坏: {path}") + + +def _backup(source: Path, destination: Path) -> None: + """Create and verify one transactionally consistent SQLite copy.""" + + _integrity(source) + source_uri = f"{source.resolve().as_uri()}?mode=ro" + with closing(sqlite3.connect(source_uri, uri=True)) as source_db: + with closing(sqlite3.connect(destination)) as destination_db: + source_db.backup(destination_db, pages=256, sleep=0.1) + destination_db.commit() + _integrity(destination) + + +def _remove_crash_staging(workspace: Path) -> None: + parent = workspace / "plugin-data" + if parent.is_symlink(): + raise ValueError(f"plugin-data 根不得是符号链接: {parent}") + if not parent.is_dir(): + return + for path in parent.glob(".proactive-feedback-v2-migrate-*"): + if path.is_symlink() or not path.is_dir(): + raise ValueError(f"Proactive Feedback staging 无效: {path}") + shutil.rmtree(path) + + +def _verify_receipt(target: Path, receipt: dict[str, object]) -> dict[str, object]: + """Verify the durable receipt and its exact published database.""" + + database = receipt.get("database") + if ( + receipt.get("schema_version") != 1 + or receipt.get("source") != "proactive_feedback/proactive_feedback.db" + or receipt.get("target") != f"plugin-data/{target.name}/{_DATABASE}" + or receipt.get("source_retained") is not True + or not isinstance(database, dict) + ): + raise ValueError("Proactive Feedback migration receipt 身份无效") + expected = database.get("sha256") + size = database.get("size") + path = target / _DATABASE + if ( + database.get("name") != _DATABASE + or not isinstance(expected, str) + or len(expected) != 64 + or not isinstance(size, int) + or isinstance(size, bool) + or path.is_symlink() + or not path.is_file() + or path.stat().st_size != size + or _digest(path) != expected + ): + raise ValueError(f"Proactive Feedback migration 目标漂移: {path}") + _integrity(path) + return receipt + + +def _read_receipt(path: Path) -> dict[str, object] | None: + if not path.exists() and not path.is_symlink(): + return None + if path.is_symlink() or not path.is_file(): + raise ValueError(f"Proactive Feedback migration receipt 无效: {path}") + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise ValueError(f"Proactive Feedback migration receipt 无效: {path}") + return value + + +def _migrate_locked(workspace: Path, marketplace: str) -> dict[str, object]: + """Stage, publish, and verify one idempotent v2 database migration.""" + + # 1. Validate all durable paths before opening SQLite. + if not marketplace or not marketplace.replace("-", "").replace("_", "").isalnum(): + raise ValueError(f"Proactive Feedback marketplace 无效: {marketplace}") + source = workspace / "proactive_feedback" / _DATABASE + if source.is_symlink() or not source.is_file() or not source.is_relative_to(workspace): + raise FileNotFoundError(f"Proactive Feedback v2 数据库不存在或不安全: {source}") + target = workspace / "plugin-data" / f"proactive_feedback-{marketplace}" + validate_workspace_plugin_data_path(target, workspace) + _remove_crash_staging(workspace) + existing = _read_receipt(target / _RECEIPT) + if existing is not None: + return _verify_receipt(target, existing) + + # 2. Freeze a consistent source snapshot outside the published target. + parent = workspace / "plugin-data" + parent.mkdir(parents=True, exist_ok=True) + staging = parent / f".proactive-feedback-v2-migrate-{uuid.uuid4().hex}" + staging.mkdir() + staged_database = staging / _DATABASE + target_created = not target.exists() + try: + _backup(source, staged_database) + digest = _digest(staged_database) + size = staged_database.stat().st_size + ensure_workspace_plugin_data_dir(target, workspace) + destination = target / _DATABASE + published = False + if destination.exists() or destination.is_symlink(): + if ( + destination.is_symlink() + or not destination.is_file() + or destination.stat().st_size != size + or _digest(destination) != digest + ): + raise FileExistsError( + f"Proactive Feedback v3 目标已存在且内容不同: {destination}" + ) + _integrity(destination) + else: + os.replace(staged_database, destination) + published = True + + # 3. Publish the receipt last; in-process failure rolls back this run. + receipt: dict[str, object] = { + "schema_version": 1, + "source": "proactive_feedback/proactive_feedback.db", + "target": f"plugin-data/{target.name}/{_DATABASE}", + "source_retained": True, + "database": {"name": _DATABASE, "sha256": digest, "size": size}, + } + staged_receipt = staging / _RECEIPT + staged_receipt.write_text( + json.dumps(receipt, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + try: + os.replace(staged_receipt, target / _RECEIPT) + except BaseException: + if published: + destination.unlink(missing_ok=True) + raise + return _verify_receipt(target, receipt) + except BaseException: + if target_created and target.is_dir() and not any(target.iterdir()): + target.rmdir() + raise + finally: + shutil.rmtree(staging, ignore_errors=True) + + +def migrate_v2_data(workspace: Path, marketplace: str) -> dict[str, object]: + """Hold the workspace owner lock while migrating the v2 database.""" + + resolved = workspace.expanduser().resolve() + lock = WorkspaceInstanceLock(resolved) + lock.acquire() + try: + return _migrate_locked(resolved, marketplace) + finally: + lock.release() + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--workspace", type=Path, required=True) + parser.add_argument("--marketplace", default="github") + args = parser.parse_args() + print( + json.dumps( + migrate_v2_data(args.workspace, args.marketplace), + ensure_ascii=False, + indent=2, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/tests/test_backfill_paths.py b/tests/test_backfill_paths.py index 809905b..0860764 100644 --- a/tests/test_backfill_paths.py +++ b/tests/test_backfill_paths.py @@ -1,6 +1,7 @@ from __future__ import annotations import importlib.util +import sqlite3 import sys from pathlib import Path @@ -18,7 +19,22 @@ def _load_backfill_module(): return module +def _load_preview_migration_module(): + path = Path(__file__).parents[1] / "scripts" / "migrate_feedback_previews.py" + spec = importlib.util.spec_from_file_location( + "test_feedback_preview_migration_module", + path, + ) + 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 + + module = _load_backfill_module() +preview_module = _load_preview_migration_module() def test_explicit_workspace_has_priority(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: @@ -40,3 +56,54 @@ def test_missing_workspace_fails_loudly(monkeypatch: pytest.MonkeyPatch) -> None def test_blank_explicit_workspace_fails_loudly() -> None: with pytest.raises(RuntimeError, match="不能为空"): module._resolve_workspace(Path(" ")) + + +def test_preview_migration_keeps_feedback_identity_and_fills_text(tmp_path: Path) -> None: + sessions = tmp_path / "sessions.db" + source = sqlite3.connect(sessions) + try: + _ = source.execute( + "CREATE TABLE messages (id TEXT PRIMARY KEY, content TEXT)" + ) + _ = source.executemany( + "INSERT INTO messages (id, content) VALUES (?, ?)", + [("u1", "user text"), ("a1", "assistant text"), ("p1", "proactive text")], + ) + source.commit() + finally: + source.close() + + feedback = tmp_path / "feedback.db" + sink = sqlite3.connect(feedback) + try: + _ = sink.execute( + """ + CREATE TABLE proactive_feedback_events ( + id INTEGER PRIMARY KEY, + user_message_id TEXT NOT NULL, + assistant_message_id TEXT NOT NULL, + proactive_message_id TEXT + ) + """ + ) + _ = sink.execute( + "INSERT INTO proactive_feedback_events VALUES (7, 'u1', 'a1', 'p1')" + ) + sink.commit() + finally: + sink.close() + + stats = preview_module.migrate_feedback_previews( + sessions_db=sessions, + feedback_db=feedback, + ) + assert stats == preview_module.MigrationStats(scanned=1, updated=1) + check = sqlite3.connect(feedback) + try: + row = check.execute( + "SELECT id, user_content_preview, assistant_content_preview, proactive_content_preview " + "FROM proactive_feedback_events" + ).fetchone() + finally: + check.close() + assert row == (7, "user text", "assistant text", "proactive text") diff --git a/tests/test_migrate_v2_data.py b/tests/test_migrate_v2_data.py new file mode 100644 index 0000000..b283270 --- /dev/null +++ b/tests/test_migrate_v2_data.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import hashlib +import os +from pathlib import Path +import sqlite3 +import subprocess +import sys + +import pytest + +from scripts import migrate_v2_data as migration + + +def _source(workspace: Path) -> Path: + path = workspace / "proactive_feedback" / "proactive_feedback.db" + path.parent.mkdir(parents=True) + with sqlite3.connect(path) as database: + database.execute("CREATE TABLE evidence (id INTEGER PRIMARY KEY, value TEXT)") + database.execute("INSERT INTO evidence(value) VALUES ('retained')") + database.commit() + return path + + +def _digest(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def test_in_process_failure_rolls_back_new_target_and_retains_source( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + workspace = tmp_path / "workspace" + source = _source(workspace) + source_digest = _digest(source) + original_replace = migration.os.replace + calls = 0 + + def fail_receipt(source_path: Path, target_path: Path) -> None: + nonlocal calls + calls += 1 + if calls == 2: + raise OSError("injected receipt failure") + original_replace(source_path, target_path) + + monkeypatch.setattr(migration.os, "replace", fail_receipt) + with pytest.raises(OSError, match="injected receipt failure"): + _ = migration.migrate_v2_data(workspace, "github") + + assert source.is_file() and _digest(source) == source_digest + assert not (workspace / "plugin-data" / "proactive_feedback-github").exists() + + +def test_core_process_crash_resumes_partial_publication(tmp_path: Path) -> None: + workspace = tmp_path / "workspace" + source = _source(workspace) + source_digest = _digest(source) + repo = Path(__file__).resolve().parents[1] + core = Path(os.environ["AKASHIC_AGENT_ROOT"]) + code = """ +import os +from pathlib import Path +from scripts import migrate_v2_data as migration + +real_replace = migration.os.replace +calls = 0 +def crash_receipt(source, target): + global calls + calls += 1 + if calls == 2: + os._exit(137) + real_replace(source, target) +migration.os.replace = crash_receipt +migration.migrate_v2_data(Path(os.environ['FEEDBACK_TEST_WORKSPACE']), 'github') +""" + environment = { + **os.environ, + "AKASHIC_AGENT_ROOT": str(core), + "FEEDBACK_TEST_WORKSPACE": str(workspace), + "PYTHONPATH": os.pathsep.join((str(repo), str(core))), + } + crashed = subprocess.run( + [sys.executable, "-c", code], + cwd=repo, + env=environment, + check=False, + ) + assert crashed.returncode == 137 + + target = workspace / "plugin-data" / "proactive_feedback-github" + assert (target / "proactive_feedback.db").is_file() + assert not (target / ".proactive-feedback-v2-migration.json").exists() + receipt = migration.migrate_v2_data(workspace, "github") + + assert receipt["source_retained"] is True + assert source.is_file() and _digest(source) == source_digest + assert (target / ".proactive-feedback-v2-migration.json").is_file() + assert not list((workspace / "plugin-data").glob(".proactive-feedback-v2-migrate-*")) diff --git a/tests/test_plugin.py b/tests/test_plugin.py index fc2e5f6..541c0a4 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -1,220 +1,424 @@ from __future__ import annotations +import asyncio +import hashlib import importlib.util +import inspect +import shutil +import sqlite3 import sys from pathlib import Path +from types import SimpleNamespace +from typing import Any, cast import pytest -from agent.plugins.context import PluginContext, PluginKVStore -from agent.plugins.scope import PluginScope, ScopedEventBus +from agent.plugin_composition import SessionReadService, SessionReadSnapshot +from agent.plugins.composable import ComposablePlugin +from agent.plugins.dashboard_host import DashboardBinding, PluginDashboardHost +from agent.plugins.manager import PluginManager +from agent.plugins.mobile_ui import PluginMobileUiProvider +from agent.plugins.manifest import write_plugin_manifest +from bus.events_lifecycle import TurnCommitted from bus.event_bus import EventBus def _load_plugin_module(): path = Path(__file__).parents[1] / "plugin.py" spec = importlib.util.spec_from_file_location( - "test_proactive_feedback_plugin", + "proactive_feedback_v3_test.plugin", path, submodule_search_locations=[str(path.parent)], ) if spec is None or spec.loader is None: raise ImportError(str(path)) + package = type(sys)("proactive_feedback_v3_test") + package.__path__ = [str(path.parent)] + sys.modules[package.__name__] = package module = importlib.util.module_from_spec(spec) sys.modules[spec.name] = module spec.loader.exec_module(module) return module -def _plugin_context(tmp_path: Path) -> PluginContext: - scope = PluginScope("proactive_feedback") - return PluginContext( - event_bus=ScopedEventBus(EventBus(), scope), - tool_registry=None, - plugin_id="proactive_feedback", - plugin_dir=tmp_path, - data_dir=tmp_path, - kv_store=PluginKVStore(tmp_path / ".kv.json"), - workspace=tmp_path, - scope=scope, +module = _load_plugin_module() +FeedbackEvent = module.FeedbackEvent + + +def _event(*, quoted: bool = True) -> TurnCommitted: + user = ( + "被回复消息:主动提醒某个很长很长的主题\n\n【你当前新消息】我继续这个主题" + if quoted + else "我继续这个主题" + ) + return TurnCommitted( + session_key="mobile:test", + channel="test", + chat_id="chat", + input_message=user, + persisted_user_message=user, + assistant_response="我接着回答这个主题", + tools_used=[], + persisted_user_message_id="u1", + assistant_message_id="a1", ) -module = _load_plugin_module() -ProactiveFeedbackPlugin = module.ProactiveFeedbackPlugin -FeedbackEvent = module.FeedbackEvent +def _snapshot(*, quoted: bool = True) -> SessionReadSnapshot: + user_content = ( + "被回复消息:主动提醒某个很长很长的主题\n\n【你当前新消息】我继续这个主题" + if quoted + else "我继续这个主题" + ) + return SessionReadSnapshot( + session_key="mobile:test", + messages=( + { + "id": "p1", + "seq": 1, + "role": "assistant", + "content": "主动提醒某个很长很长的主题", + "extra": '{"proactive": true}', + "ts": "2026-08-17T00:00:00+00:00", + }, + { + "id": "u1", + "seq": 2, + "role": "user", + "content": user_content, + "extra": None, + "ts": "2026-08-17T00:00:10+00:00", + }, + { + "id": "a1", + "seq": 3, + "role": "assistant", + "content": "我接着回答这个主题", + "extra": None, + "ts": "2026-08-17T00:00:11+00:00", + }, + ), + compaction_generation=0, + consolidated_through_seq=None, + ) + + +def test_module_exports_pure_v3_contract() -> None: + assert module.api_version == 3 + assert module.name == "proactive_feedback" + assert inspect.signature(module.apply).parameters.keys() == {"ctx", "config"} + assert ComposablePlugin.from_module(module).dashboard_module == "dashboard.py" + + +def test_v2_runtime_symbols_are_not_used_by_module() -> None: + module_file = module.__file__ + assert module_file is not None + source = Path(module_file).read_text(encoding="utf-8") + assert "class ProactiveFeedbackPlugin" not in source + assert "from agent.plugins import" not in source + assert "event_bus" not in source + assert "sessions.db" not in source @pytest.mark.asyncio -async def test_proactive_feedback_summary_empty(tmp_path: Path) -> None: - plugin = ProactiveFeedbackPlugin() - scope = PluginScope("proactive_feedback") - plugin.context = PluginContext( - event_bus=ScopedEventBus(EventBus(), scope), - tool_registry=None, - plugin_id="proactive_feedback", - plugin_dir=tmp_path, - data_dir=tmp_path, - kv_store=PluginKVStore(tmp_path / ".kv.json"), +async def test_committed_turn_writes_plugin_owned_projection(tmp_path: Path) -> None: + runtime = module.ProactiveFeedbackRuntime( + session_read=SessionReadService( + lambda _key: (cast(Any, _session_state()), None) + ), workspace=tmp_path, - scope=scope, + db_path=tmp_path / "data" / "proactive_feedback.db", ) - plugin.activate() + await runtime._process(_event()) + + conn = sqlite3.connect(runtime._db_path) + conn.row_factory = sqlite3.Row try: - summary = await plugin.get_summary(None) + row = conn.execute( + "SELECT * FROM proactive_feedback_events" + ).fetchone() finally: - await plugin.terminate() - assert await scope.aclose() == [] - assert summary["total"] == 0 + conn.close() + assert row is not None + assert row["feedback_type"] == "explicit_quote" + assert row["user_content_preview"].startswith("被回复消息") + assert row["assistant_content_preview"] == "我接着回答这个主题" + assert row["proactive_content_preview"] == "主动提醒某个很长很长的主题" -def test_recorded_event_matches_runtime_shape() -> None: - feedback = FeedbackEvent( - session_key="telegram:1", - 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=2, - matched_by="recent_pua", - reason="matched", +@pytest.mark.asyncio +async def test_candidate_session_read_fails_before_any_write(tmp_path: Path) -> None: + db_path = tmp_path / "data" / "proactive_feedback.db" + runtime = module.ProactiveFeedbackRuntime( + session_read=SessionReadService.candidate_validation(), + workspace=tmp_path, + db_path=db_path, ) + with pytest.raises(RuntimeError, match="禁止读取正式 Session"): + await runtime._process(_event()) + assert not db_path.exists() - event = module._recorded_event(7, feedback) - assert event.event_id == 7 - assert event.session_key == "telegram:1" - assert event.user_message_id == "u1" - assert event.assistant_message_id == "a1" - assert event.proactive_message_id == "p1" - assert event.feedback_type == "topic_follow" - assert event.confidence == "high" - assert event.pua_score == 0.8 - assert event.lag_seconds == 12 - assert event.matched_by == "recent_pua" +@pytest.mark.asyncio +async def test_nonquoted_turn_keeps_pua_scoring_path( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + runtime = module.ProactiveFeedbackRuntime( + session_read=SessionReadService( + lambda _key: (cast(Any, _session_state(quoted=False)), None) + ), + workspace=tmp_path, + db_path=tmp_path / "data" / "proactive_feedback.db", + ) + class EmbedderStub: + async def embed_batch(self, texts: list[str]) -> list[list[float]]: + assert len(texts) == 3 + return [[1.0, 0.0], [1.0, 0.0], [1.0, 0.0]] -def test_get_embedder_uses_workspace(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - seen: list[Path] = [] + monkeypatch.setattr(runtime, "_get_embedder", lambda: EmbedderStub()) + await runtime._process(_event(quoted=False)) + conn = sqlite3.connect(runtime._db_path) + try: + row = conn.execute( + "SELECT feedback_type, matched_by FROM proactive_feedback_events" + ).fetchone() + finally: + conn.close() + assert row == ("topic_follow", "recent_pua") - def fake_build_embedder(root: Path) -> object: - seen.append(root) - return object() - plugin = ProactiveFeedbackPlugin() - plugin.context = _plugin_context(tmp_path) - plugin._workspace = tmp_path - plugin._embedder = None - monkeypatch.setattr(module, "_build_embedder", fake_build_embedder) +@pytest.mark.asyncio +async def test_scoring_failure_records_unscored_feedback( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + runtime = module.ProactiveFeedbackRuntime( + session_read=SessionReadService( + lambda _key: (cast(Any, _session_state()), None) + ), + workspace=tmp_path, + db_path=tmp_path / "data" / "proactive_feedback.db", + ) - embedder = plugin._get_embedder() + async def fail_scoring(**_kwargs: object) -> None: + raise RuntimeError("embedding unavailable") - assert embedder is plugin._embedder - assert seen == [tmp_path] + monkeypatch.setattr(module, "score_followup", fail_scoring) + await runtime._process(_event()) + conn = sqlite3.connect(runtime._db_path) + try: + row = conn.execute( + "SELECT feedback_type, reason FROM proactive_feedback_events" + ).fetchone() + finally: + conn.close() + assert row == ("unscored", "scoring_failed") -def test_mobile_contribution_declares_dashboard() -> None: - contribution = ProactiveFeedbackPlugin.mobile_ui() +@pytest.mark.asyncio +async def test_in_process_cancellation_does_not_persist_partial_feedback( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + runtime = module.ProactiveFeedbackRuntime( + session_read=SessionReadService( + lambda _key: (cast(Any, _session_state()), None) + ), + workspace=tmp_path, + db_path=tmp_path / "data" / "proactive_feedback.db", + ) + started = asyncio.Event() + + async def blocked_scoring(**_kwargs: object) -> None: + started.set() + await asyncio.Future() - assert contribution.module == "mobile_panel.js" - assert contribution.stylesheet == "mobile_panel.css" - assert contribution.navigation is not None - assert contribution.navigation.label == "主动反馈" + monkeypatch.setattr(module, "score_followup", blocked_scoring) + task = asyncio.create_task(runtime._process(_event())) + await started.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert not runtime._db_path.exists() -def test_mobile_feedback_projection_reuses_dashboard_reader(tmp_path: Path) -> None: - plugin = ProactiveFeedbackPlugin() - plugin.context = _plugin_context(tmp_path) - sink = module.open_db(tmp_path / "proactive_feedback" / "proactive_feedback.db") +@pytest.mark.asyncio +async def test_worker_cancellation_has_no_live_task(tmp_path: Path) -> None: + runtime = module.ProactiveFeedbackRuntime( + session_read=SessionReadService.candidate_validation(), + workspace=tmp_path, + db_path=tmp_path / "data" / "proactive_feedback.db", + ) + task = asyncio.create_task(runtime.run_worker()) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert not runtime._db_path.exists() + + +def test_dashboard_reads_preview_projection_without_sessions_database(tmp_path: Path) -> None: + sink = module.open_db(tmp_path / "proactive_feedback.db") try: module.insert_feedback( sink, FeedbackEvent( session_key="mobile:test", - user_message_id="u-mobile", - assistant_message_id="a-mobile", - proactive_message_id="p-mobile", + user_message_id="u1", + assistant_message_id="a1", + proactive_message_id="p1", feedback_type="explicit_quote", confidence="gold", pa_score=1.0, - pua_score=None, + pua_score=1.0, lag_seconds=8, candidate_count=1, - matched_by="quote", + matched_by="explicit_quote", reason="explicit_quote", + user_content_preview="被回复消息:主题\n\n【你当前新消息】继续", + assistant_content_preview="回答", + proactive_content_preview="主题", ), ) finally: sink.close() - - overview = plugin.mobile_ui_query( - "feedback.overview", - {}, - session_id=None, - turn_id=None, + reader = module.ProactiveFeedbackDashboardReader(tmp_path) + items, total = reader.list_events() + assert total == 1 + assert items[0]["quoted_preview"] == "主题" + assert items[0]["user_reply_preview"] == "继续" + assert items[0]["assistant_preview"] == "回答" + + +def test_plugin_runtime_does_not_move_legacy_database(tmp_path: Path) -> None: + legacy = tmp_path / "workspace" / "proactive_feedback" / "proactive_feedback.db" + legacy.parent.mkdir(parents=True) + old = module.open_db(legacy) + old.close() + target_root = tmp_path / "plugin-data" + runtime = module.ProactiveFeedbackRuntime( + session_read=cast(Any, object()), + workspace=tmp_path / "workspace", + db_path=target_root / "proactive_feedback.db", ) - page = plugin.mobile_ui_query( - "feedback.events", - {"page": 1, "page_size": 30, "feedback_type": "explicit_quote"}, - session_id=None, - turn_id=None, - ) - - assert overview["total"] == 1 - assert overview["follow_rate"] == 1.0 - assert page["total"] == 1 - assert page["items"][0]["feedback_type"] == "explicit_quote" + assert runtime._db_path == target_root / "proactive_feedback.db" + assert legacy.exists() + assert not target_root.exists() -def test_mobile_feedback_projection_rejects_unknown_filter(tmp_path: Path) -> None: - plugin = ProactiveFeedbackPlugin() - plugin.context = _plugin_context(tmp_path) - with pytest.raises(ValueError, match="feedback_type 不受支持"): - plugin.mobile_ui_query( - "feedback.events", - {"feedback_type": "invented"}, - session_id=None, - turn_id=None, +@pytest.mark.asyncio +async def test_manager_stable_candidate_ui_dashboard_and_cleanup(tmp_path: Path) -> None: + plugin_dir = tmp_path / "plugins" / "proactive_feedback" + plugin_dir.mkdir(parents=True) + for filename in ( + "plugin.py", + "dashboard.py", + "db.py", + "scorer.py", + "mobile_panel.js", + "mobile_panel.css", + "akashic.plugin.toml", + ): + shutil.copy2(Path(__file__).parents[1] / filename, plugin_dir / filename) + write_plugin_manifest( + {"proactive_feedback": True}, + plugins_home=tmp_path / "home", + ) + manager = PluginManager( + plugin_dirs=[tmp_path / "plugins"], + event_bus=EventBus(), + tool_registry=None, + session_manager=_empty_session_manager(), + workspace=tmp_path / "workspace", + installed_cache_root=tmp_path / "home" / "cache", + ) + dashboard_host = PluginDashboardHost( + workspace=tmp_path / "workspace", + memory_admin=object(), + memory_store=object(), + core_routes=(), + ) + try: + await manager.load_all() + stable = manager.current_snapshot + assert stable is not None and stable.composition_root is not None + assert stable.composition_root.receipt().ready + assert stable.mobile_ui_registry is not None + dashboard_host.prepare_initial_snapshot(stable) + manager.bind_dashboard_preparer( + dashboard_host.prepare_snapshot, + validation_releaser=dashboard_host.release_validation, + ) + mobile_provider = PluginMobileUiProvider(manager) + assert mobile_provider.catalog()["items"] + formal_data = stable.composition_root.plugin_runtime( + "proactive_feedback" + ).data_dir + assert not (formal_data / "proactive_feedback.db").exists() + formal_database = formal_data / "proactive_feedback.db" + formal_database.parent.mkdir(parents=True, exist_ok=True) + formal_connection = module.open_db(formal_database) + formal_connection.close() + formal_digest = hashlib.sha256(formal_database.read_bytes()).hexdigest() + + plugin_source = plugin_dir / "plugin.py" + plugin_source.write_text( + plugin_source.read_text(encoding="utf-8").replace( + 'version = "3.0.0"', + 'version = "3.0.1"', + ), + encoding="utf-8", + ) + manifest = plugin_dir / "akashic.plugin.toml" + manifest.write_text( + manifest.read_text(encoding="utf-8").replace( + 'version = "3.0.0"', + 'version = "3.0.1"', + ), + encoding="utf-8", ) + candidate = await manager.prepare_candidate("proactive_feedback") + assert candidate is not None and candidate.runtime_snapshot is not None + assert manager.current_snapshot is stable + candidate_snapshot = candidate.runtime_snapshot + assert candidate_snapshot.mobile_ui_registry is not None + dashboard_host.prepare_snapshot(candidate_snapshot) + assert len(candidate_snapshot.dashboard_bindings) == 1 + binding = candidate_snapshot.dashboard_bindings[0] + assert isinstance(binding, DashboardBinding) + assert binding.validation is True + assert binding.runtime_data_root is not None + assert binding.runtime_data_root != formal_data.resolve() + assert not (binding.runtime_data_root / "proactive_feedback.db").exists() + assert hashlib.sha256(formal_database.read_bytes()).hexdigest() == formal_digest + await manager.discard_prepared("proactive_feedback") + assert manager.current_snapshot is stable + finally: + await manager.terminate_all() + receipt = stable.composition_root.receipt() + assert receipt.effects == () + assert cast(Any, stable.composition_root)._events.registrations() == () -@pytest.mark.parametrize( - ("payload", "message"), - [ - ({"page": True}, "page 必须"), - ({"page_size": 51}, "page_size 必须"), - ], -) -def test_mobile_feedback_projection_rejects_invalid_page( - tmp_path: Path, - payload: dict[str, object], - message: str, -) -> None: - plugin = ProactiveFeedbackPlugin() - plugin.context = _plugin_context(tmp_path) - - with pytest.raises(ValueError, match=message): - plugin.mobile_ui_query( - "feedback.events", - payload, - session_id=None, - turn_id=None, - ) +def _session_state(*, quoted: bool = True) -> object: + return SimpleNamespace( + messages=[dict(message) for message in _snapshot(quoted=quoted).messages], + last_consolidated=0, + ) -def test_mobile_feedback_projection_rejects_unknown_method(tmp_path: Path) -> None: - plugin = ProactiveFeedbackPlugin() - plugin.context = _plugin_context(tmp_path) +def _empty_session_manager() -> object: + class ControlStore: + def get_active_compaction(self, session_key: str) -> None: + _ = session_key + return None - with pytest.raises(ValueError, match="未知 proactive_feedback 移动方法"): - plugin.mobile_ui_query( - "feedback.delete", - {}, - session_id=None, - turn_id=None, - ) + class SessionManager: + control_store = ControlStore() + + def get_existing(self, session_key: str) -> None: + raise KeyError(session_key) + + return SessionManager() From 5e7d9517c5234730b320927bd0d4f22928d03141 Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Mon, 17 Aug 2026 18:04:30 +0800 Subject: [PATCH 2/8] =?UTF-8?q?fix(migration):=20=E6=94=B6=E7=B4=A7?= =?UTF-8?q?=E5=8F=8D=E9=A6=88=E6=95=B0=E6=8D=AE=E8=BF=81=E7=A7=BB=E8=AF=81?= =?UTF-8?q?=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- akashic.plugin.toml | 1 + scripts/migrate_v2_data.py | 24 +++++++++++++++++++----- tests/test_migrate_v2_data.py | 24 ++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/akashic.plugin.toml b/akashic.plugin.toml index 0508bc1..aa820b1 100644 --- a/akashic.plugin.toml +++ b/akashic.plugin.toml @@ -9,5 +9,6 @@ exclude_data_paths = [ "proactive_feedback.db", "proactive_feedback.db-wal", "proactive_feedback.db-shm", + "proactive_feedback.db-journal", ".proactive-feedback-v2-migration.json", ] diff --git a/scripts/migrate_v2_data.py b/scripts/migrate_v2_data.py index ade2637..bc4d9a2 100644 --- a/scripts/migrate_v2_data.py +++ b/scripts/migrate_v2_data.py @@ -64,7 +64,11 @@ def _remove_crash_staging(workspace: Path) -> None: shutil.rmtree(path) -def _verify_receipt(target: Path, receipt: dict[str, object]) -> dict[str, object]: +def _verify_receipt( + target: Path, + source: Path, + receipt: dict[str, object], +) -> dict[str, object]: """Verify the durable receipt and its exact published database.""" database = receipt.get("database") @@ -76,6 +80,9 @@ def _verify_receipt(target: Path, receipt: dict[str, object]) -> dict[str, objec or not isinstance(database, dict) ): raise ValueError("Proactive Feedback migration receipt 身份无效") + if source.is_symlink() or not source.is_file(): + raise ValueError(f"Proactive Feedback migration 旧源已丢失: {source}") + _integrity(source) expected = database.get("sha256") size = database.get("size") path = target / _DATABASE @@ -112,15 +119,22 @@ def _migrate_locked(workspace: Path, marketplace: str) -> dict[str, object]: # 1. Validate all durable paths before opening SQLite. if not marketplace or not marketplace.replace("-", "").replace("_", "").isalnum(): raise ValueError(f"Proactive Feedback marketplace 无效: {marketplace}") - source = workspace / "proactive_feedback" / _DATABASE - if source.is_symlink() or not source.is_file() or not source.is_relative_to(workspace): + legacy_root = workspace / "proactive_feedback" + source = legacy_root / _DATABASE + if ( + legacy_root.is_symlink() + or not legacy_root.is_dir() + or source.is_symlink() + or not source.is_file() + or not source.resolve().is_relative_to(workspace) + ): raise FileNotFoundError(f"Proactive Feedback v2 数据库不存在或不安全: {source}") target = workspace / "plugin-data" / f"proactive_feedback-{marketplace}" validate_workspace_plugin_data_path(target, workspace) _remove_crash_staging(workspace) existing = _read_receipt(target / _RECEIPT) if existing is not None: - return _verify_receipt(target, existing) + return _verify_receipt(target, source, existing) # 2. Freeze a consistent source snapshot outside the published target. parent = workspace / "plugin-data" @@ -170,7 +184,7 @@ def _migrate_locked(workspace: Path, marketplace: str) -> dict[str, object]: if published: destination.unlink(missing_ok=True) raise - return _verify_receipt(target, receipt) + return _verify_receipt(target, source, receipt) except BaseException: if target_created and target.is_dir() and not any(target.iterdir()): target.rmdir() diff --git a/tests/test_migrate_v2_data.py b/tests/test_migrate_v2_data.py index b283270..c4484ec 100644 --- a/tests/test_migrate_v2_data.py +++ b/tests/test_migrate_v2_data.py @@ -96,3 +96,27 @@ def crash_receipt(source, target): assert source.is_file() and _digest(source) == source_digest assert (target / ".proactive-feedback-v2-migration.json").is_file() assert not list((workspace / "plugin-data").glob(".proactive-feedback-v2-migrate-*")) + + +def test_completed_receipt_requires_retained_source(tmp_path: Path) -> None: + workspace = tmp_path / "workspace" + source = _source(workspace) + _ = migration.migrate_v2_data(workspace, "github") + source.unlink() + + with pytest.raises(FileNotFoundError, match="不存在或不安全"): + _ = migration.migrate_v2_data(workspace, "github") + + +def test_legacy_parent_symlink_is_rejected(tmp_path: Path) -> None: + workspace = tmp_path / "workspace" + outside = tmp_path / "outside" + _ = _source(outside) + workspace.mkdir() + (workspace / "proactive_feedback").symlink_to( + outside / "proactive_feedback", + target_is_directory=True, + ) + + with pytest.raises(FileNotFoundError, match="不存在或不安全"): + _ = migration.migrate_v2_data(workspace, "github") From e3a40e899115d959faf978ae537cac7fa6c7b504 Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Mon, 17 Aug 2026 19:32:16 +0800 Subject: [PATCH 3/8] feat(plugin): publish durable proactive feedback events --- .github/workflows/plugin-api-v3.yml | 2 +- README.md | 25 ++- db.py | 323 +++++++++++++++++++++++++--- plugin.py | 88 +++++++- tests/test_plugin.py | 200 +++++++++++++++++ 5 files changed, 599 insertions(+), 39 deletions(-) diff --git a/.github/workflows/plugin-api-v3.yml b/.github/workflows/plugin-api-v3.yml index 47b5cfe..25f9dde 100644 --- a/.github/workflows/plugin-api-v3.yml +++ b/.github/workflows/plugin-api-v3.yml @@ -36,7 +36,7 @@ jobs: - uses: actions/checkout@v4 with: repository: kachofugetsu09/akashic-agent - ref: 5624a059348406c1f97993612adfec886b158158 + ref: 20062a715d2c5822228b327863b51c8d036119b3 path: .akashic-core - uses: actions/setup-python@v5 with: diff --git a/README.md b/README.md index 6f860a4..5136fef 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,27 @@ Akashic proactive feedback plugin. - 通过 Core `AFTER_TURN_COMMITTED` 串行接入点观察已提交 Turn; - 通过 `SESSION_READ` 读取脱离持久化 owner 的 Session 快照; - 反馈数据库由 Core 分配的 `ctx.data_root` 独占,Dashboard 与 Mobile 只读同一投影; -- `apply` 不读取或写入正式 `sessions.db`,候选期不会访问正式 Session。 +- `apply` 不读取或写入正式 `sessions.db`,候选期不会访问正式 Session;候选没有 + 反馈 DB 时也不会为了重放而创建文件。 + +### Durable typed event + +Core exact `20062a715d2c5822228b327863b51c8d036119b3` 提供唯一的 +`agent.turn_events.proactive_feedback.PROACTIVE_FEEDBACK_COMMITTED` Observe seam。 +每次评分结果都在一次 SQLite commit 中同时写入 `proactive_feedback_events` 和 +`proactive_feedback_outbox`;commit 返回后才调用 +`ctx.observe(PROACTIVE_FEEDBACK_COMMITTED, ProactiveFeedbackCommitted(...))`。 + +事件 `event_id` 固定为 `proactive_feedback:`,DTO 使用 Core 的 +`session_key`、user/assistant/proactive message identity、评分、`reason` 和最多 +2400 字符的 user/assistant/proactive preview。全文不进入事件。发布成功后同库的 +`proactive_feedback_published_cursor` 与 outbox receipt 一起推进;发布失败、进程内 +取消或 Core 重启都会保留 pending 行,正式 generation 启动时按 row 顺序重放。消费方 +必须按 `event_id` 幂等;本插件不再向 `TurnCommitted.extra` 写入反馈,也不提供 +marker fallback。 + +非引用评分使用 Core 正式运行时的共享 HTTP resources。嵌入配置从 +`AKASHIC_CONFIG` 指向的 Core 配置加载,不从插件 checkout 的当前目录猜测配置。 插件加载不会自动移动旧数据库。首次从 v2 切换时,先停用旧 runtime,再显式执行 SQLite 一致性迁移;旧源始终保留: @@ -29,7 +49,8 @@ python scripts/migrate_feedback_previews.py \ ``` 插件不再声明 v2 `Plugin` class、EventBus listener、`ProactiveFeedbackRecorded` 或 tool -ABI;v3 运行路径只观察 Core 的 committed Turn 事件。 +ABI;v3 运行路径只观察 Core 的 committed Turn,并通过上述 typed event 发布已持久化 +反馈。 ## 移动端看板 diff --git a/db.py b/db.py index ff15a8d..bb10f7f 100644 --- a/db.py +++ b/db.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import sqlite3 from dataclasses import dataclass from pathlib import Path @@ -24,12 +25,24 @@ class FeedbackEvent: proactive_content_preview: str | None = None +@dataclass(frozen=True) +class FeedbackOutboxRecord: + """Describe one durable typed-event payload waiting for publication.""" + + row_id: int + event_id: str + payload_json: str + + def open_db(path: Path) -> sqlite3.Connection: + """Open the plugin-owned SQLite projection and its durable event ledger.""" + + # 1. Open with WAL and full synchronous durability. 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.execute("PRAGMA synchronous = FULL") _ = conn.executescript( """ CREATE TABLE IF NOT EXISTS proactive_feedback_events ( @@ -62,8 +75,25 @@ def open_db(path: Path) -> sqlite3.Connection: CREATE UNIQUE INDEX IF NOT EXISTS idx_pfe_one_user_per_proactive ON proactive_feedback_events(proactive_message_id) WHERE proactive_message_id IS NOT NULL; + + CREATE TABLE IF NOT EXISTS proactive_feedback_outbox ( + row_id INTEGER PRIMARY KEY, + event_id TEXT NOT NULL UNIQUE, + payload_json TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + published_at TEXT + ); + + CREATE TABLE IF NOT EXISTS proactive_feedback_published_cursor ( + name TEXT PRIMARY KEY, + row_id INTEGER NOT NULL DEFAULT 0 + ); + + INSERT OR IGNORE INTO proactive_feedback_published_cursor(name, row_id) + VALUES ('proactive_feedback', 0); """ ) + # 2. Preserve the v2 projection columns while adding the v3 ledger. _ensure_column(conn, "user_content_preview") _ensure_column(conn, "assistant_content_preview") _ensure_column(conn, "proactive_content_preview") @@ -72,41 +102,134 @@ def open_db(path: Path) -> sqlite3.Connection: def insert_feedback(conn: sqlite3.Connection, event: FeedbackEvent) -> int | None: - if event.proactive_message_id is not None: - existing = conn.execute( - """ - SELECT id - FROM proactive_feedback_events - WHERE proactive_message_id = ? - AND user_message_id <> ? - LIMIT 1 - """, - (event.proactive_message_id, event.user_message_id), - ).fetchone() - if existing is not None: - return None + """Atomically replace one feedback row and enqueue its typed event.""" + + # 1. Reject a proactive message already owned by another user reply. + if _feedback_owned_by_other(conn, event): + return None + + # 2. Keep one row identity for duplicate committed Turns. + existing_id = _existing_feedback_id(conn, event) + if existing_id is not None: + try: + _update_feedback_row(conn, event, existing_id) + _upsert_feedback_outbox(conn, event, existing_id) + conn.commit() + except (sqlite3.Error, RuntimeError, TypeError, ValueError): + conn.rollback() + raise + return existing_id + + # 3. Replace this user's previous projection and pending outbox row together. + try: + _remove_previous_feedback(conn, event.user_message_id) + row_id = _insert_feedback_row(conn, event) + _upsert_feedback_outbox(conn, event, row_id) + conn.commit() + except (sqlite3.Error, RuntimeError, TypeError, ValueError): + conn.rollback() + raise + return row_id + + +def _feedback_owned_by_other( + conn: sqlite3.Connection, + event: FeedbackEvent, +) -> bool: + if event.proactive_message_id is None: + return False + row = conn.execute( + """ + SELECT id + FROM proactive_feedback_events + WHERE proactive_message_id = ? AND user_message_id <> ? + LIMIT 1 + """, + (event.proactive_message_id, event.user_message_id), + ).fetchone() + return row is not None + + +def _existing_feedback_id( + conn: sqlite3.Connection, + event: FeedbackEvent, +) -> int | None: + row = conn.execute( + """ + SELECT id + FROM proactive_feedback_events + WHERE user_message_id = ? AND proactive_message_id IS ? + LIMIT 1 + """, + (event.user_message_id, event.proactive_message_id), + ).fetchone() + return None if row is None else int(row["id"]) + + +def _update_feedback_row( + conn: sqlite3.Connection, + event: FeedbackEvent, + row_id: int, +) -> None: + _ = conn.execute( + """ + UPDATE proactive_feedback_events + SET session_key = ?, assistant_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 = ? + WHERE id = ? + """, + ( + event.session_key, + event.assistant_message_id, + event.feedback_type, + event.confidence, + event.pa_score, + event.pua_score, + event.lag_seconds, + event.candidate_count, + event.matched_by, + event.reason, + event.user_content_preview, + event.assistant_content_preview, + event.proactive_content_preview, + row_id, + ), + ) + + +def _remove_previous_feedback(conn: sqlite3.Connection, user_message_id: str) -> None: + pending = conn.execute( + """ + SELECT row_id + FROM proactive_feedback_outbox + WHERE row_id IN ( + SELECT id FROM proactive_feedback_events WHERE user_message_id = ? + ) AND published_at IS NULL + """, + (user_message_id,), + ).fetchall() _ = conn.execute( "DELETE FROM proactive_feedback_events WHERE user_message_id = ?", - (event.user_message_id,), + (user_message_id,), ) + for row in pending: + _ = conn.execute( + "DELETE FROM proactive_feedback_outbox WHERE row_id = ?", + (int(row["row_id"]),), + ) + + +def _insert_feedback_row(conn: sqlite3.Connection, event: FeedbackEvent) -> int: cursor = conn.execute( """ INSERT INTO proactive_feedback_events ( - 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 + 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, @@ -128,11 +251,143 @@ def insert_feedback(conn: sqlite3.Connection, event: FeedbackEvent) -> int | Non event.proactive_content_preview, ), ) - conn.commit() - row_id = cursor.lastrowid - if row_id is None: + if cursor.lastrowid is None: raise RuntimeError("feedback insert failed") - return int(row_id) + return int(cursor.lastrowid) + + +def _upsert_feedback_outbox( + conn: sqlite3.Connection, + event: FeedbackEvent, + row_id: int, +) -> None: + event_id = f"proactive_feedback:{row_id}" + payload_json = json.dumps( + _feedback_payload(event_id, event), + ensure_ascii=False, + separators=(",", ":"), + ) + outbox = conn.execute( + """ + SELECT published_at + FROM proactive_feedback_outbox + WHERE row_id = ? AND event_id = ? + """, + (row_id, event_id), + ).fetchone() + if outbox is None: + _ = conn.execute( + """ + INSERT INTO proactive_feedback_outbox(row_id, event_id, payload_json) + VALUES (?, ?, ?) + """, + (row_id, event_id, payload_json), + ) + elif outbox["published_at"] is None: + _ = conn.execute( + """ + UPDATE proactive_feedback_outbox + SET payload_json = ? + WHERE row_id = ? AND event_id = ? + """, + (payload_json, row_id, event_id), + ) + + +def _feedback_payload(event_id: str, event: FeedbackEvent) -> dict[str, object]: + return { + "event_id": event_id, + "session_key": event.session_key, + "user_message_id": event.user_message_id, + "assistant_message_id": event.assistant_message_id, + "proactive_message_id": event.proactive_message_id, + "feedback_type": event.feedback_type, + "confidence": event.confidence, + "pa_score": event.pa_score, + "pua_score": event.pua_score, + "lag_seconds": event.lag_seconds, + "candidate_count": event.candidate_count, + "matched_by": event.matched_by, + "reason": event.reason, + "user_content_preview": event.user_content_preview, + "assistant_content_preview": event.assistant_content_preview, + "proactive_content_preview": event.proactive_content_preview, + } + + +def pending_feedback_outbox( + conn: sqlite3.Connection, + *, + limit: int = 100, +) -> list[FeedbackOutboxRecord]: + """Read unpublished payloads in durable row order.""" + + # 1. Bound the recovery batch before reading the durable queue. + if isinstance(limit, bool) or not isinstance(limit, int) or limit < 1: + raise ValueError("outbox limit 必须是正整数") + rows = conn.execute( + """ + SELECT row_id, event_id, payload_json + FROM proactive_feedback_outbox + WHERE published_at IS NULL + ORDER BY row_id ASC + LIMIT ? + """, + (limit,), + ).fetchall() + return [ + FeedbackOutboxRecord( + row_id=int(row["row_id"]), + event_id=str(row["event_id"]), + payload_json=str(row["payload_json"]), + ) + for row in rows + ] + + +def mark_feedback_published( + conn: sqlite3.Connection, + *, + row_id: int, + event_id: str, +) -> None: + """Record one successful publication and advance the same-DB cursor.""" + + # 1. Validate the receipt identity before changing the cursor. + if isinstance(row_id, bool) or not isinstance(row_id, int) or row_id < 1: + raise ValueError("outbox row_id 必须是正整数") + if not isinstance(event_id, str) or not event_id: + raise ValueError("outbox event_id 必须是非空字符串") + # 2. Mark the exact outbox row and advance only its owner cursor. + update = conn.execute( + """ + UPDATE proactive_feedback_outbox + SET published_at = datetime('now') + WHERE row_id = ? AND event_id = ? AND published_at IS NULL + """, + (row_id, event_id), + ) + if update.rowcount == 0: + existing = conn.execute( + """ + SELECT published_at + FROM proactive_feedback_outbox + WHERE row_id = ? AND event_id = ? + """, + (row_id, event_id), + ).fetchone() + if existing is None or existing["published_at"] is None: + conn.rollback() + raise RuntimeError("outbox receipt 不匹配 pending row") + _ = conn.execute( + """ + UPDATE proactive_feedback_published_cursor + SET row_id = MAX(row_id, ?) + WHERE name = 'proactive_feedback' + """, + (row_id,), + ) + conn.commit() def _ensure_column(conn: sqlite3.Connection, name: str) -> None: diff --git a/plugin.py b/plugin.py index 153f7c7..d63744c 100644 --- a/plugin.py +++ b/plugin.py @@ -1,8 +1,12 @@ from __future__ import annotations import asyncio +import json import logging +import os +from collections.abc import Awaitable, Callable from pathlib import Path +from typing import Any, cast from agent.config_models import Config as CoreConfig from agent.plugin_composition import ( @@ -15,11 +19,22 @@ UI_SLOTS, ) from agent.turn_events.after_turn import AFTER_TURN_COMMITTED +from agent.turn_events.proactive_feedback import ( + PROACTIVE_FEEDBACK_COMMITTED, + ProactiveFeedbackCommitted, +) from bus.events_lifecycle import TurnCommitted +from core.net.http import get_default_http_requester from memory2.embedder import Embedder from .dashboard import ProactiveFeedbackDashboardReader -from .db import FeedbackEvent, insert_feedback, open_db +from .db import ( + FeedbackEvent, + insert_feedback, + mark_feedback_published, + open_db, + pending_feedback_outbox, +) from .scorer import ( latest_turn_messages_from_rows, message_rows_from_snapshot, @@ -35,6 +50,10 @@ _QUEUE_MAX = 100 _FEEDBACK_DB_NAME = "proactive_feedback.db" _PREVIEW_MAX_CHARS = 2400 +_OUTBOX_BATCH_SIZE = 100 +_OUTBOX_RETRY_SECONDS = 1.0 + +FeedbackPublisher = Callable[[ProactiveFeedbackCommitted], Awaitable[None]] api_version = 3 name = "proactive_feedback" @@ -60,6 +79,10 @@ async def apply(ctx: Context, config: object) -> None: session_read=session_read, workspace=ctx.runtime.workspace, db_path=db_path, + publish_feedback=lambda event: ctx.observe( + PROACTIVE_FEEDBACK_COMMITTED, + event, + ), ) # 2. Bind every executable contribution to this Fiber's lifecycle. @@ -88,10 +111,12 @@ def __init__( session_read: SessionReadService, workspace: Path, db_path: Path, + publish_feedback: FeedbackPublisher | None = None, ) -> None: self._session_read = session_read self._workspace = workspace self._db_path = db_path + self._publish_feedback = publish_feedback self._queue: asyncio.Queue[TurnCommitted] = asyncio.Queue(maxsize=_QUEUE_MAX) self._embedder: Embedder | None = None @@ -112,9 +137,22 @@ async def run_worker(self) -> None: """Process queued committed turns until the owning Fiber is disposed.""" while True: + # 1. Replay every durable payload before waiting for new Turns. + try: + await self._publish_pending() + except asyncio.CancelledError: + raise + except Exception: + logger.exception("proactive_feedback outbox publish failed") + await asyncio.sleep(_OUTBOX_RETRY_SECONDS) + continue + + # 2. Process one Core-committed Turn and drain its transaction's outbox. event = await self._queue.get() try: await self._process(event) + except asyncio.CancelledError: + raise except Exception: logger.exception("proactive_feedback process failed") finally: @@ -229,6 +267,7 @@ async def _process(self, event: TurnCommitted) -> None: matched_by="recent_pua", reason="scoring_failed", ) + await self._publish_pending() return if scored is None: return @@ -246,6 +285,7 @@ async def _process(self, event: TurnCommitted) -> None: matched_by=scored.matched_by, reason=scored.reason, ) + await self._publish_pending() async def _persist_feedback( self, @@ -288,6 +328,37 @@ async def _persist_feedback( finally: sink.close() + async def _publish_pending(self) -> None: + """Publish durable rows and advance their SQLite cursor after receipt.""" + + # 1. A candidate with no database and tests without a publisher stay inert. + if self._publish_feedback is None or not self._db_path.exists(): + return + # 2. Publish outside SQLite; only a returned receipt advances state. + while True: + sink = open_db(self._db_path) + try: + pending = pending_feedback_outbox(sink, limit=_OUTBOX_BATCH_SIZE) + finally: + sink.close() + if not pending: + return + for record in pending: + payload = _decode_outbox_payload(record.payload_json) + if payload.get("event_id") != record.event_id: + raise ValueError("proactive_feedback outbox event_id 不一致") + feedback = ProactiveFeedbackCommitted(**cast(Any, payload)) + await self._publish_feedback(feedback) + sink = open_db(self._db_path) + try: + mark_feedback_published( + sink, + row_id=record.row_id, + event_id=record.event_id, + ) + finally: + sink.close() + def _get_embedder(self) -> Embedder: if self._embedder is None: self._embedder = _build_embedder(self._workspace) @@ -299,15 +370,28 @@ def _bounded_preview(value: str, limit: int = _PREVIEW_MAX_CHARS) -> str: def _build_embedder(workspace: Path) -> Embedder: - embedding = CoreConfig.load(workspace=workspace).memory.embedding + config_path = os.environ.get("AKASHIC_CONFIG", "").strip() + if not config_path: + raise RuntimeError("proactive_feedback 需要 Core 的 AKASHIC_CONFIG") + embedding = CoreConfig.load(path=config_path, workspace=workspace).memory.embedding return Embedder( base_url=embedding.base_url, api_key=embedding.api_key, model=embedding.model, output_dimensionality=embedding.output_dimensionality, + requester=get_default_http_requester("external_default"), ) +def _decode_outbox_payload(payload_json: str) -> dict[str, object]: + """Decode one durable payload without accepting a second fallback schema.""" + + payload = json.loads(payload_json) + if not isinstance(payload, dict): + raise TypeError("proactive_feedback outbox payload 必须是 object") + return payload + + async def _no_embed(texts: list[str]) -> list[list[float]]: _ = texts raise RuntimeError("quoted feedback must not call embedding") diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 541c0a4..ebf19f6 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -19,6 +19,7 @@ from agent.plugins.manager import PluginManager from agent.plugins.mobile_ui import PluginMobileUiProvider from agent.plugins.manifest import write_plugin_manifest +from agent.turn_events.proactive_feedback import ProactiveFeedbackCommitted from bus.events_lifecycle import TurnCommitted from bus.event_bus import EventBus @@ -118,6 +119,48 @@ def test_v2_runtime_symbols_are_not_used_by_module() -> None: assert "from agent.plugins import" not in source assert "event_bus" not in source assert "sessions.db" not in source + assert "ProactiveFeedbackRecorded" not in source + assert "event.extra" not in source + assert "【你当前新消息】" not in source + + +def test_embedder_uses_core_config_and_shared_http_requester( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + config_path = tmp_path / "runtime.toml" + requester = object() + embedding = SimpleNamespace( + base_url="https://embedding.example/v1", + api_key="test-key", + model="text-embedding-v3", + output_dimensionality=1024, + ) + seen: list[tuple[Path, Path]] = [] + + def fake_load(path: str | Path, *, workspace: str | Path) -> object: + seen.append((Path(path), Path(workspace))) + return SimpleNamespace(memory=SimpleNamespace(embedding=embedding)) + + class FakeEmbedder: + def __init__(self, **kwargs: object) -> None: + self.kwargs = kwargs + + monkeypatch.setenv("AKASHIC_CONFIG", str(config_path)) + monkeypatch.setattr(module.CoreConfig, "load", fake_load) + monkeypatch.setattr(module, "get_default_http_requester", lambda _profile: requester) + monkeypatch.setattr(module, "Embedder", FakeEmbedder) + + embedder = module._build_embedder(tmp_path) + + assert seen == [(config_path, tmp_path)] + assert embedder.kwargs == { + "base_url": embedding.base_url, + "api_key": embedding.api_key, + "model": embedding.model, + "output_dimensionality": embedding.output_dimensionality, + "requester": requester, + } @pytest.mark.asyncio @@ -146,6 +189,120 @@ async def test_committed_turn_writes_plugin_owned_projection(tmp_path: Path) -> assert row["proactive_content_preview"] == "主动提醒某个很长很长的主题" +@pytest.mark.asyncio +async def test_feedback_commit_publishes_typed_event_and_cursor(tmp_path: Path) -> None: + published: list[ProactiveFeedbackCommitted] = [] + + async def publish(event: ProactiveFeedbackCommitted) -> None: + published.append(event) + + runtime = module.ProactiveFeedbackRuntime( + session_read=SessionReadService( + lambda _key: (cast(Any, _session_state()), None) + ), + workspace=tmp_path, + db_path=tmp_path / "data" / "proactive_feedback.db", + publish_feedback=publish, + ) + await runtime._process(_event()) + await runtime._process(_event()) + + assert [event.event_id for event in published] == ["proactive_feedback:1"] + assert published[0].event_id == "proactive_feedback:1" + assert published[0].session_key == "mobile:test" + assert published[0].user_content_preview is not None + conn = sqlite3.connect(runtime._db_path) + try: + outbox = conn.execute( + "SELECT published_at FROM proactive_feedback_outbox" + ).fetchone() + cursor = conn.execute( + "SELECT row_id FROM proactive_feedback_published_cursor " + "WHERE name = 'proactive_feedback'" + ).fetchone() + finally: + conn.close() + assert outbox is not None and outbox[0] is not None + assert cursor == (1,) + + +@pytest.mark.asyncio +async def test_failed_publication_is_replayed_after_restart(tmp_path: Path) -> None: + async def fail(_event: ProactiveFeedbackCommitted) -> None: + raise RuntimeError("observer unavailable") + + runtime = module.ProactiveFeedbackRuntime( + session_read=SessionReadService( + lambda _key: (cast(Any, _session_state()), None) + ), + workspace=tmp_path, + db_path=tmp_path / "data" / "proactive_feedback.db", + publish_feedback=fail, + ) + with pytest.raises(RuntimeError, match="observer unavailable"): + await runtime._process(_event()) + + conn = sqlite3.connect(runtime._db_path) + try: + assert conn.execute( + "SELECT published_at FROM proactive_feedback_outbox" + ).fetchone() == (None,) + finally: + conn.close() + + replayed: list[ProactiveFeedbackCommitted] = [] + + async def publish(event: ProactiveFeedbackCommitted) -> None: + replayed.append(event) + + restarted = module.ProactiveFeedbackRuntime( + session_read=SessionReadService.candidate_validation(), + workspace=tmp_path, + db_path=runtime._db_path, + publish_feedback=publish, + ) + await restarted._publish_pending() + assert [event.event_id for event in replayed] == ["proactive_feedback:1"] + conn = sqlite3.connect(runtime._db_path) + try: + assert conn.execute( + "SELECT published_at FROM proactive_feedback_outbox" + ).fetchone()[0] is not None + finally: + conn.close() + + +@pytest.mark.asyncio +async def test_publication_cancellation_keeps_pending_receipt(tmp_path: Path) -> None: + started = asyncio.Event() + + async def blocked(_event: ProactiveFeedbackCommitted) -> None: + started.set() + await asyncio.Future() + + runtime = module.ProactiveFeedbackRuntime( + session_read=SessionReadService( + lambda _key: (cast(Any, _session_state()), None) + ), + workspace=tmp_path, + db_path=tmp_path / "data" / "proactive_feedback.db", + publish_feedback=blocked, + ) + task = asyncio.create_task(runtime._process(_event())) + await started.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + conn = sqlite3.connect(runtime._db_path) + try: + assert conn.execute( + "SELECT published_at FROM proactive_feedback_outbox" + ).fetchone() == (None,) + finally: + conn.close() + + @pytest.mark.asyncio async def test_candidate_session_read_fails_before_any_write(tmp_path: Path) -> None: db_path = tmp_path / "data" / "proactive_feedback.db" @@ -291,6 +448,49 @@ def test_dashboard_reads_preview_projection_without_sessions_database(tmp_path: assert items[0]["assistant_preview"] == "回答" +def test_feedback_projection_and_outbox_commit_atomically(tmp_path: Path) -> None: + sink = module.open_db(tmp_path / "proactive_feedback.db") + try: + _ = sink.execute( + """ + CREATE TRIGGER reject_feedback_outbox + BEFORE INSERT ON proactive_feedback_outbox + BEGIN + SELECT RAISE(ABORT, 'outbox unavailable'); + END; + """ + ) + with pytest.raises(sqlite3.IntegrityError, match="outbox unavailable"): + module.insert_feedback( + sink, + FeedbackEvent( + session_key="mobile:test", + user_message_id="u1", + assistant_message_id="a1", + proactive_message_id="p1", + feedback_type="explicit_quote", + confidence="gold", + pa_score=1.0, + pua_score=1.0, + lag_seconds=8, + candidate_count=1, + matched_by="explicit_quote", + reason="explicit_quote", + user_content_preview="继续", + assistant_content_preview="回答", + proactive_content_preview="主题", + ), + ) + assert tuple(sink.execute( + "SELECT count(*) FROM proactive_feedback_events" + ).fetchone()) == (0,) + assert tuple(sink.execute( + "SELECT count(*) FROM proactive_feedback_outbox" + ).fetchone()) == (0,) + finally: + sink.close() + + def test_plugin_runtime_does_not_move_legacy_database(tmp_path: Path) -> None: legacy = tmp_path / "workspace" / "proactive_feedback" / "proactive_feedback.db" legacy.parent.mkdir(parents=True) From 28e7eeac83a0032ff59247d9b07a681c70a1ee17 Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Mon, 17 Aug 2026 19:58:45 +0800 Subject: [PATCH 4/8] fix(plugin): recover committed turns durably --- .github/workflows/plugin-api-v2.yml | 28 --- README.md | 16 +- db.py | 226 +++++++++++++++++++++-- plugin.py | 171 +++++++++++++++++- scripts/backfill_proactive_feedback.py | 240 ------------------------- tests/test_backfill_paths.py | 35 ---- tests/test_plugin.py | 173 ++++++++++++++++++ 7 files changed, 563 insertions(+), 326 deletions(-) delete mode 100644 .github/workflows/plugin-api-v2.yml delete mode 100644 scripts/backfill_proactive_feedback.py diff --git a/.github/workflows/plugin-api-v2.yml b/.github/workflows/plugin-api-v2.yml deleted file mode 100644 index 7c1876b..0000000 --- a/.github/workflows/plugin-api-v2.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: plugin-api-v2 - -on: - pull_request: - push: - branches: - - main - -permissions: - contents: read - -jobs: - contract: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/checkout@v4 - with: - repository: akashic-plugins/plugin-contracts - ref: 24543445c7b99ca63fcd90b5828f754a148b184c - path: .plugin-contracts - - uses: actions/setup-python@v5 - with: - python-version: "3.13" - - name: Check Plugin API v2 - env: - PYTHONPATH: .plugin-contracts - run: python -m akashic_plugin_contracts check plugin.py diff --git a/README.md b/README.md index 5136fef..502561c 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@ Akashic proactive feedback plugin. - 通过 `SESSION_READ` 读取脱离持久化 owner 的 Session 快照; - 反馈数据库由 Core 分配的 `ctx.data_root` 独占,Dashboard 与 Mobile 只读同一投影; - `apply` 不读取或写入正式 `sessions.db`,候选期不会访问正式 Session;候选没有 - 反馈 DB 时也不会为了重放而创建文件。 + 反馈 DB 时也不会为了重放而创建文件;已提交 Turn 的 inbox 只保存 + session/turn/message identity,不保存 user/assistant 正文。 ### Durable typed event @@ -25,11 +26,20 @@ Core exact `20062a715d2c5822228b327863b51c8d036119b3` 提供唯一的 2400 字符的 user/assistant/proactive preview。全文不进入事件。发布成功后同库的 `proactive_feedback_published_cursor` 与 outbox receipt 一起推进;发布失败、进程内 取消或 Core 重启都会保留 pending 行,正式 generation 启动时按 row 顺序重放。消费方 -必须按 `event_id` 幂等;本插件不再向 `TurnCommitted.extra` 写入反馈,也不提供 -marker fallback。 +必须按 `event_id` 幂等。已发布的 projection/outbox receipt 是不可变事实;重复的 +同一 identity 即使评分不同,也不会改写已发布 DTO。待发布行才允许在同一 identity +内更新;若关联 proactive identity 改变,则保留旧 published row 并创建新的 row/event。 +本插件不再向 `TurnCommitted.extra` 写入反馈,也不提供 marker fallback。 非引用评分使用 Core 正式运行时的共享 HTTP resources。嵌入配置从 `AKASHIC_CONFIG` 指向的 Core 配置加载,不从插件 checkout 的当前目录猜测配置。 +embedding 继续使用既有 Core provider 数据流;API key 只作为运行时认证,不进入 +inbox、projection 或 typed event,完整正文也不进入这些持久/发布边界。 + +旧 v2 `scripts/backfill_proactive_feedback.py` 已移除:它直接操作 +`workspace/proactive_feedback/proactive_feedback.db`,而 `--clear` 会删除旧 DB、WAL +和 SHM。需要处理旧数据时,先保留可恢复备份,再使用下面的非破坏迁移;迁移会保留 +旧源并写入可校验 receipt,不提供旧脚本的清空入口。 插件加载不会自动移动旧数据库。首次从 v2 切换时,先停用旧 runtime,再显式执行 SQLite 一致性迁移;旧源始终保留: diff --git a/db.py b/db.py index bb10f7f..32c425b 100644 --- a/db.py +++ b/db.py @@ -34,6 +34,18 @@ class FeedbackOutboxRecord: payload_json: str +@dataclass(frozen=True) +class FeedbackInputRecord: + """Describe one committed Turn identity waiting for durable processing.""" + + row_id: int + session_key: str + turn_id: str + client_message_id: str + user_message_id: str + assistant_message_id: str | None + + def open_db(path: Path) -> sqlite3.Connection: """Open the plugin-owned SQLite projection and its durable event ledger.""" @@ -76,6 +88,21 @@ def open_db(path: Path) -> sqlite3.Connection: ON proactive_feedback_events(proactive_message_id) WHERE proactive_message_id IS NOT NULL; + CREATE TABLE IF NOT EXISTS proactive_feedback_input_inbox ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + session_key TEXT NOT NULL, + turn_id TEXT NOT NULL DEFAULT '', + client_message_id TEXT NOT NULL DEFAULT '', + user_message_id TEXT NOT NULL, + assistant_message_id TEXT, + processed_at TEXT, + UNIQUE(session_key, user_message_id) + ); + + CREATE INDEX IF NOT EXISTS idx_pfe_input_pending + ON proactive_feedback_input_inbox(processed_at, id); + CREATE TABLE IF NOT EXISTS proactive_feedback_outbox ( row_id INTEGER PRIMARY KEY, event_id TEXT NOT NULL UNIQUE, @@ -112,8 +139,9 @@ def insert_feedback(conn: sqlite3.Connection, event: FeedbackEvent) -> int | Non existing_id = _existing_feedback_id(conn, event) if existing_id is not None: try: - _update_feedback_row(conn, event, existing_id) - _upsert_feedback_outbox(conn, event, existing_id) + if not _feedback_is_published(conn, existing_id): + _update_feedback_row(conn, event, existing_id) + _upsert_feedback_outbox(conn, event, existing_id) conn.commit() except (sqlite3.Error, RuntimeError, TypeError, ValueError): conn.rollback() @@ -132,6 +160,138 @@ def insert_feedback(conn: sqlite3.Connection, event: FeedbackEvent) -> int | Non return row_id +def insert_feedback_input( + conn: sqlite3.Connection, + *, + session_key: str, + turn_id: str, + client_message_id: str, + user_message_id: str, + assistant_message_id: str | None, +) -> int: + """Durably record one committed Turn identity without storing message text.""" + + # 1. Validate the identity that the recovery reader will use. + _required_input_text(session_key, "session_key") + _required_input_text(user_message_id, "user_message_id") + _optional_input_text(turn_id, "turn_id") + _optional_input_text(client_message_id, "client_message_id") + if assistant_message_id is not None: + _required_input_text(assistant_message_id, "assistant_message_id") + + # 2. Preserve one durable row for duplicate committed events. + existing = conn.execute( + """ + SELECT id + FROM proactive_feedback_input_inbox + WHERE session_key = ? AND user_message_id = ? + LIMIT 1 + """, + (session_key, user_message_id), + ).fetchone() + if existing is not None: + return int(existing["id"]) + try: + cursor = conn.execute( + """ + INSERT INTO proactive_feedback_input_inbox( + session_key, turn_id, client_message_id, + user_message_id, assistant_message_id + ) + VALUES (?, ?, ?, ?, ?) + """, + ( + session_key, + turn_id, + client_message_id, + user_message_id, + assistant_message_id, + ), + ) + if cursor.lastrowid is None: + raise RuntimeError("feedback input insert failed") + row_id = int(cursor.lastrowid) + conn.commit() + return row_id + except (sqlite3.Error, RuntimeError, ValueError): + conn.rollback() + raise + + +def pending_feedback_inputs( + conn: sqlite3.Connection, + *, + limit: int = 100, +) -> list[FeedbackInputRecord]: + """Read unprocessed committed Turn identities in durable row order.""" + + # 1. Bound the recovery batch before reading the durable inbox. + if isinstance(limit, bool) or not isinstance(limit, int) or limit < 1: + raise ValueError("input inbox limit 必须是正整数") + rows = conn.execute( + """ + SELECT id, session_key, turn_id, client_message_id, + user_message_id, assistant_message_id + FROM proactive_feedback_input_inbox + WHERE processed_at IS NULL + ORDER BY id ASC + LIMIT ? + """, + (limit,), + ).fetchall() + return [_feedback_input_record(row) for row in rows] + + +def pending_feedback_input( + conn: sqlite3.Connection, + *, + row_id: int, +) -> FeedbackInputRecord | None: + """Read one pending committed Turn identity for the in-memory wake path.""" + + if isinstance(row_id, bool) or not isinstance(row_id, int) or row_id < 1: + raise ValueError("input inbox row_id 必须是正整数") + row = conn.execute( + """ + SELECT id, session_key, turn_id, client_message_id, + user_message_id, assistant_message_id + FROM proactive_feedback_input_inbox + WHERE id = ? AND processed_at IS NULL + """, + (row_id,), + ).fetchone() + return None if row is None else _feedback_input_record(row) + + +def mark_feedback_input_processed( + conn: sqlite3.Connection, + *, + row_id: int, +) -> None: + """Record successful handling of one durable committed Turn identity.""" + + # 1. Validate the receipt identity before changing the inbox state. + if isinstance(row_id, bool) or not isinstance(row_id, int) or row_id < 1: + raise ValueError("input inbox row_id 必须是正整数") + update = conn.execute( + """ + UPDATE proactive_feedback_input_inbox + SET processed_at = datetime('now') + WHERE id = ? AND processed_at IS NULL + """, + (row_id,), + ) + if update.rowcount == 0: + existing = conn.execute( + "SELECT id FROM proactive_feedback_input_inbox WHERE id = ?", + (row_id,), + ).fetchone() + if existing is None: + conn.rollback() + raise RuntimeError("input inbox receipt 不匹配 pending row") + conn.commit() + + def _feedback_owned_by_other( conn: sqlite3.Connection, event: FeedbackEvent, @@ -166,6 +326,18 @@ def _existing_feedback_id( return None if row is None else int(row["id"]) +def _feedback_is_published(conn: sqlite3.Connection, row_id: int) -> bool: + row = conn.execute( + """ + SELECT published_at + FROM proactive_feedback_outbox + WHERE row_id = ? + """, + (row_id,), + ).fetchone() + return row is not None and row["published_at"] is not None + + def _update_feedback_row( conn: sqlite3.Connection, event: FeedbackEvent, @@ -203,19 +375,22 @@ def _update_feedback_row( def _remove_previous_feedback(conn: sqlite3.Connection, user_message_id: str) -> None: pending = conn.execute( """ - SELECT row_id - FROM proactive_feedback_outbox - WHERE row_id IN ( - SELECT id FROM proactive_feedback_events WHERE user_message_id = ? - ) AND published_at IS NULL + SELECT events.id, outbox.row_id + FROM proactive_feedback_events AS events + LEFT JOIN proactive_feedback_outbox AS outbox + ON outbox.row_id = events.id + WHERE events.user_message_id = ? + AND (outbox.row_id IS NULL OR outbox.published_at IS NULL) """, (user_message_id,), ).fetchall() - _ = conn.execute( - "DELETE FROM proactive_feedback_events WHERE user_message_id = ?", - (user_message_id,), - ) for row in pending: + _ = conn.execute( + "DELETE FROM proactive_feedback_events WHERE id = ?", + (int(row["id"]),), + ) + if row["row_id"] is None: + continue _ = conn.execute( "DELETE FROM proactive_feedback_outbox WHERE row_id = ?", (int(row["row_id"]),), @@ -315,6 +490,35 @@ def _feedback_payload(event_id: str, event: FeedbackEvent) -> dict[str, object]: } +def _feedback_input_record(row: sqlite3.Row) -> FeedbackInputRecord: + return FeedbackInputRecord( + row_id=int(row["id"]), + session_key=str(row["session_key"]), + turn_id=str(row["turn_id"]), + client_message_id=str(row["client_message_id"]), + user_message_id=str(row["user_message_id"]), + assistant_message_id=( + None + if row["assistant_message_id"] is None + else str(row["assistant_message_id"]) + ), + ) + + +def _required_input_text(value: str, field: str) -> None: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"input inbox {field} 必须是非空字符串") + if value != value.strip(): + raise ValueError(f"input inbox {field} 不能有首尾空白") + + +def _optional_input_text(value: str, field: str) -> None: + if not isinstance(value, str): + raise TypeError(f"input inbox {field} 必须是字符串") + if value != value.strip(): + raise ValueError(f"input inbox {field} 不能有首尾空白") + + def pending_feedback_outbox( conn: sqlite3.Connection, *, diff --git a/plugin.py b/plugin.py index d63744c..970ffde 100644 --- a/plugin.py +++ b/plugin.py @@ -30,15 +30,20 @@ from .dashboard import ProactiveFeedbackDashboardReader from .db import ( FeedbackEvent, + FeedbackInputRecord, insert_feedback, + insert_feedback_input, + mark_feedback_input_processed, mark_feedback_published, open_db, + pending_feedback_input, + pending_feedback_inputs, pending_feedback_outbox, ) from .scorer import ( + MessageRow, latest_turn_messages_from_rows, message_rows_from_snapshot, - MessageRow, parse_quote_parts, proactive_since_previous_user_from_rows, recent_proactive_messages_from_rows, @@ -117,19 +122,23 @@ def __init__( self._workspace = workspace self._db_path = db_path self._publish_feedback = publish_feedback - self._queue: asyncio.Queue[TurnCommitted] = asyncio.Queue(maxsize=_QUEUE_MAX) + self._queue: asyncio.Queue[int] = asyncio.Queue(maxsize=_QUEUE_MAX) self._embedder: Embedder | None = None def enqueue(self, event: TurnCommitted) -> None: - """Queue one committed turn without blocking the Core lifecycle seam.""" + """Durably record one committed Turn identity and wake the worker.""" - if event.persisted_user_message is None: + if ( + event.persisted_user_message is None + or event.persisted_user_message_id is None + ): return + input_row_id = self._persist_input(event) try: - self._queue.put_nowait(event) + self._queue.put_nowait(input_row_id) except asyncio.QueueFull: logger.warning( - "proactive_feedback queue full, drop session=%s", + "proactive_feedback queue full, durable input retained session=%s", event.session_key, ) @@ -146,11 +155,22 @@ async def run_worker(self) -> None: logger.exception("proactive_feedback outbox publish failed") await asyncio.sleep(_OUTBOX_RETRY_SECONDS) continue + try: + has_pending_inputs = await self._process_pending_inputs() + except asyncio.CancelledError: + raise + except Exception: + logger.exception("proactive_feedback durable input replay failed") + await asyncio.sleep(_OUTBOX_RETRY_SECONDS) + continue + if has_pending_inputs: + await asyncio.sleep(_OUTBOX_RETRY_SECONDS) + continue # 2. Process one Core-committed Turn and drain its transaction's outbox. - event = await self._queue.get() + input_row_id = await self._queue.get() try: - await self._process(event) + await self._process_input_row(input_row_id) except asyncio.CancelledError: raise except Exception: @@ -198,12 +218,18 @@ def mobile_ui_query( "page_size": page_size, } - async def _process(self, event: TurnCommitted) -> None: + async def _process( + self, + event: TurnCommitted, + *, + input_row_id: int | None = None, + ) -> None: """Score one committed turn using a detached Session snapshot.""" # 1. Resolve the committed message identity through Core's read service. user_text = event.persisted_user_message if not user_text or not event.assistant_response: + self._complete_input(input_row_id) return snapshot = self._session_read.read(event.session_key) if snapshot is None: @@ -240,6 +266,7 @@ async def _process(self, event: TurnCommitted) -> None: limit=8, ) if not candidates: + self._complete_input(input_row_id) return # 3. Persist one deduplicated projection, including bounded display text. @@ -267,9 +294,11 @@ async def _process(self, event: TurnCommitted) -> None: matched_by="recent_pua", reason="scoring_failed", ) + self._complete_input(input_row_id) await self._publish_pending() return if scored is None: + self._complete_input(input_row_id) return await self._persist_feedback( event=event, @@ -285,6 +314,7 @@ async def _process(self, event: TurnCommitted) -> None: matched_by=scored.matched_by, reason=scored.reason, ) + self._complete_input(input_row_id) await self._publish_pending() async def _persist_feedback( @@ -328,6 +358,103 @@ async def _persist_feedback( finally: sink.close() + def _persist_input(self, event: TurnCommitted) -> int: + sink = open_db(self._db_path) + try: + return insert_feedback_input( + sink, + session_key=event.session_key, + turn_id=event.turn_id, + client_message_id=event.client_message_id, + user_message_id=event.persisted_user_message_id or "", + assistant_message_id=event.assistant_message_id, + ) + finally: + sink.close() + + def _complete_input(self, input_row_id: int | None) -> None: + if input_row_id is None: + return + sink = open_db(self._db_path) + try: + mark_feedback_input_processed(sink, row_id=input_row_id) + finally: + sink.close() + + async def _process_pending_inputs(self) -> bool: + """Replay durable Turn identities and report unresolved rows.""" + + # 1. Read only identities; canonical SessionRead reconstructs text in memory. + if not self._db_path.exists(): + return False + sink = open_db(self._db_path) + try: + pending = pending_feedback_inputs(sink, limit=_OUTBOX_BATCH_SIZE) + finally: + sink.close() + for record in pending: + await self._process_input_record(record) + + # 2. Keep retrying rows whose canonical messages are not readable yet. + sink = open_db(self._db_path) + try: + return bool(pending_feedback_inputs(sink, limit=1)) + finally: + sink.close() + + async def _process_input_row(self, input_row_id: int) -> None: + if not self._db_path.exists(): + return + sink = open_db(self._db_path) + try: + record = pending_feedback_input(sink, row_id=input_row_id) + finally: + sink.close() + if record is not None: + await self._process_input_record(record) + + async def _process_input_record(self, record: FeedbackInputRecord) -> None: + snapshot = self._session_read.read(record.session_key) + if snapshot is None: + logger.warning( + "proactive_feedback durable input session missing session=%s", + record.session_key, + ) + return + rows = message_rows_from_snapshot(snapshot.messages) + user = next( + ( + row + for row in rows + if row.role == "user" and row.id == record.user_message_id + ), + None, + ) + assistant = _assistant_for_input(rows, record) + if user is None or assistant is None: + logger.warning( + "proactive_feedback durable input message missing session=%s user=%s", + record.session_key, + record.user_message_id, + ) + return + await self._process( + TurnCommitted( + session_key=record.session_key, + channel="proactive_feedback_replay", + chat_id="", + input_message=user.content, + persisted_user_message=user.content, + assistant_response=assistant.content, + tools_used=[], + turn_id=record.turn_id, + client_message_id=record.client_message_id, + persisted_user_message_id=user.id, + assistant_message_id=assistant.id, + ), + input_row_id=record.row_id, + ) + async def _publish_pending(self) -> None: """Publish durable rows and advance their SQLite cursor after receipt.""" @@ -392,6 +519,32 @@ def _decode_outbox_payload(payload_json: str) -> dict[str, object]: return payload +def _assistant_for_input( + rows: list[MessageRow], + record: FeedbackInputRecord, +) -> MessageRow | None: + if record.assistant_message_id is not None: + return next( + ( + row + for row in rows + if row.role == "assistant" and row.id == record.assistant_message_id + ), + None, + ) + user = next( + (row for row in rows if row.role == "user" and row.id == record.user_message_id), + None, + ) + if user is None: + return None + return min( + (row for row in rows if row.role == "assistant" and row.seq > user.seq), + key=lambda row: row.seq, + default=None, + ) + + async def _no_embed(texts: list[str]) -> list[list[float]]: _ = texts raise RuntimeError("quoted feedback must not call embedding") diff --git a/scripts/backfill_proactive_feedback.py b/scripts/backfill_proactive_feedback.py deleted file mode 100644 index c88e047..0000000 --- a/scripts/backfill_proactive_feedback.py +++ /dev/null @@ -1,240 +0,0 @@ -from __future__ import annotations - -# pyright: reportMissingImports=false - -import argparse -import asyncio -import os -import sqlite3 -import sys -import tomllib -from dataclasses import dataclass -from pathlib import Path - -PLUGIN_ROOT = Path(__file__).resolve().parents[1] -AGENT_ROOT = Path( - os.environ.get( - "AKASHIC_AGENT_ROOT", - str(Path(__file__).resolve().parents[3] / "akasic-agent"), - ) -) -for root in (PLUGIN_ROOT, AGENT_ROOT): - if str(root) not in sys.path: - sys.path.insert(0, str(root)) - -from core.net.http import ( # noqa: E402 - SharedHttpResources, - clear_default_shared_http_resources, - configure_default_shared_http_resources, -) -from db import FeedbackEvent, insert_feedback, open_db -from scorer import ( - EmbedBatch, - iter_user_assistant_turns, - parse_quote_parts, - proactive_since_previous_user, - recent_proactive_messages, - score_followup, -) -from memory2.embedder import Embedder - - -@dataclass(frozen=True) -class BackfillStats: - scanned: int = 0 - with_candidates: int = 0 - embedded: int = 0 - inserted: int = 0 - skipped: int = 0 - failed: int = 0 - - -def _resolve_workspace(explicit: Path | None) -> Path: - if explicit is not None: - raw_explicit = str(explicit).strip() - if not raw_explicit: - raise RuntimeError("--workspace 不能为空") - return Path(raw_explicit).expanduser() - workspace = os.environ.get("AKASHIC_WORKSPACE", "").strip() - if not workspace: - raise RuntimeError("未提供 --workspace,且缺少 AKASHIC_WORKSPACE") - return Path(workspace).expanduser() - - -async def _no_embed(texts: list[str]) -> list[list[float]]: - _ = texts - raise RuntimeError("quoted feedback must not call embedding") - - -async def run_backfill( - *, - workspace: Path, - project_root: Path, - clear: bool, - limit: int | None, - dry_run: bool, - include_pua: bool, -) -> BackfillStats: - sessions_db = workspace / "sessions.db" - feedback_db = workspace / "proactive_feedback" / "proactive_feedback.db" - if not sessions_db.exists(): - raise FileNotFoundError(sessions_db) - - resources = SharedHttpResources() - configure_default_shared_http_resources(resources) - if clear and not dry_run: - _reset_feedback_db(feedback_db) - source = sqlite3.connect(sessions_db) - source.row_factory = sqlite3.Row - sink = open_db(feedback_db) - try: - embedder = None - stats = BackfillStats() - turns = iter_user_assistant_turns(source) - if limit is not None: - turns = turns[:limit] - - scanned = with_candidates = embedded = inserted = skipped = failed = 0 - for session_key, user, assistant in turns: - scanned += 1 - quote = parse_quote_parts(user.content) - if quote.quoted_text: - candidates = recent_proactive_messages( - source, - session_key=session_key, - before_seq=user.seq, - limit=64, - ) - embed_batch = _no_embed - elif not include_pua: - skipped += 1 - continue - else: - candidates = proactive_since_previous_user( - source, - session_key=session_key, - before_seq=user.seq, - ) - if candidates: - if embedder is None: - embedder = _build_embedder(project_root) - embed_batch: EmbedBatch = embedder.embed_batch - embedded += 1 - else: - embed_batch = _no_embed - if not candidates: - skipped += 1 - continue - with_candidates += 1 - try: - scored = await score_followup( - embed_batch=embed_batch, - user=user, - assistant=assistant, - candidates=candidates, - allow_pua=not bool(quote.quoted_text), - ) - except Exception: - failed += 1 - continue - if scored is None: - skipped += 1 - continue - written = True - if not dry_run: - event_id = insert_feedback( - sink, - FeedbackEvent( - session_key=session_key, - user_message_id=user.id, - assistant_message_id=assistant.id, - proactive_message_id=scored.proactive.id, - feedback_type=scored.feedback_type, - confidence=scored.confidence, - pa_score=scored.pa_score, - pua_score=scored.pua_score, - lag_seconds=scored.lag_seconds, - candidate_count=scored.candidate_count, - matched_by=scored.matched_by, - reason=scored.reason, - ), - ) - written = event_id is not None - if written: - inserted += 1 - else: - skipped += 1 - stats = BackfillStats( - scanned=scanned, - with_candidates=with_candidates, - embedded=embedded, - inserted=inserted, - skipped=skipped, - failed=failed, - ) - finally: - source.close() - sink.close() - clear_default_shared_http_resources(resources) - await resources.aclose() - return stats - - -def main() -> None: - parser = argparse.ArgumentParser(description="Backfill proactive feedback events from sessions.db") - _ = parser.add_argument("--workspace", type=Path) - _ = parser.add_argument("--project-root", type=Path, default=AGENT_ROOT) - _ = parser.add_argument("--clear", action="store_true", help="clear existing feedback events before writing") - _ = parser.add_argument("--dry-run", action="store_true", help="score without writing") - _ = parser.add_argument("--include-pua", action="store_true", help="also score the first user reply after each proactive block") - _ = parser.add_argument("--limit", type=int, default=None) - args = parser.parse_args() - - stats = asyncio.run( - run_backfill( - workspace=_resolve_workspace(args.workspace), - project_root=args.project_root, - clear=args.clear, - limit=args.limit, - dry_run=args.dry_run, - include_pua=args.include_pua, - ) - ) - print( - "scanned={scanned} with_candidates={with_candidates} inserted={inserted} " - "embedded={embedded} skipped={skipped} failed={failed}".format( - scanned=stats.scanned, - with_candidates=stats.with_candidates, - embedded=stats.embedded, - inserted=stats.inserted, - skipped=stats.skipped, - failed=stats.failed, - ) - ) - - -def _reset_feedback_db(db_path: Path) -> None: - for path in ( - db_path, - Path(f"{db_path}-wal"), - Path(f"{db_path}-shm"), - ): - path.unlink(missing_ok=True) - - -def _build_embedder(root: Path) -> Embedder: - data = tomllib.loads((root / "config.toml").read_text()) - embedding = data["memory"]["embedding"] - api_key = str(embedding["api_key"]) - if api_key.startswith("$"): - api_key = os.environ[api_key[1:]] - return Embedder( - base_url=str(embedding["base_url"]), - api_key=api_key, - model=str(embedding.get("model", "text-embedding-v3")), - output_dimensionality=embedding.get("output_dimensionality"), - ) - - -if __name__ == "__main__": - main() diff --git a/tests/test_backfill_paths.py b/tests/test_backfill_paths.py index 0860764..c7745da 100644 --- a/tests/test_backfill_paths.py +++ b/tests/test_backfill_paths.py @@ -5,19 +5,6 @@ import sys from pathlib import Path -import pytest - - -def _load_backfill_module(): - path = Path(__file__).parents[1] / "scripts" / "backfill_proactive_feedback.py" - spec = importlib.util.spec_from_file_location("test_backfill_paths_module", path) - 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 - def _load_preview_migration_module(): path = Path(__file__).parents[1] / "scripts" / "migrate_feedback_previews.py" @@ -33,31 +20,9 @@ def _load_preview_migration_module(): return module -module = _load_backfill_module() preview_module = _load_preview_migration_module() -def test_explicit_workspace_has_priority(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - monkeypatch.delenv("AKASHIC_WORKSPACE", raising=False) - assert module._resolve_workspace(tmp_path) == tmp_path - - -def test_workspace_comes_from_environment(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - monkeypatch.setenv("AKASHIC_WORKSPACE", str(tmp_path)) - assert module._resolve_workspace(None) == tmp_path - - -def test_missing_workspace_fails_loudly(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("AKASHIC_WORKSPACE", " ") - with pytest.raises(RuntimeError, match="AKASHIC_WORKSPACE"): - module._resolve_workspace(None) - - -def test_blank_explicit_workspace_fails_loudly() -> None: - with pytest.raises(RuntimeError, match="不能为空"): - module._resolve_workspace(Path(" ")) - - def test_preview_migration_keeps_feedback_identity_and_fills_text(tmp_path: Path) -> None: sessions = tmp_path / "sessions.db" source = sqlite3.connect(sessions) diff --git a/tests/test_plugin.py b/tests/test_plugin.py index ebf19f6..97db0ae 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -4,6 +4,7 @@ import hashlib import importlib.util import inspect +import json import shutil import sqlite3 import sys @@ -226,6 +227,178 @@ async def publish(event: ProactiveFeedbackCommitted) -> None: assert cursor == (1,) +def test_committed_turn_identity_is_durable_without_message_text(tmp_path: Path) -> None: + runtime = module.ProactiveFeedbackRuntime( + session_read=SessionReadService.candidate_validation(), + workspace=tmp_path, + db_path=tmp_path / "data" / "proactive_feedback.db", + ) + runtime.enqueue(_event()) + + conn = sqlite3.connect(runtime._db_path) + try: + row = conn.execute( + "SELECT session_key, user_message_id, assistant_message_id, " + "turn_id, client_message_id, processed_at " + "FROM proactive_feedback_input_inbox" + ).fetchone() + columns = { + str(column[1]) + for column in conn.execute( + "PRAGMA table_info(proactive_feedback_input_inbox)" + ) + } + finally: + conn.close() + assert row == ("mobile:test", "u1", "a1", "", "", None) + assert "user_content" not in columns + assert "assistant_content" not in columns + + +@pytest.mark.asyncio +async def test_durable_input_replays_after_runtime_restart(tmp_path: Path) -> None: + published: list[ProactiveFeedbackCommitted] = [] + + async def publish(event: ProactiveFeedbackCommitted) -> None: + published.append(event) + + original = module.ProactiveFeedbackRuntime( + session_read=SessionReadService( + lambda _key: (cast(Any, _session_state()), None) + ), + workspace=tmp_path, + db_path=tmp_path / "data" / "proactive_feedback.db", + ) + original.enqueue(_event()) + + restarted = module.ProactiveFeedbackRuntime( + session_read=original._session_read, + workspace=tmp_path, + db_path=original._db_path, + publish_feedback=publish, + ) + assert await restarted._process_pending_inputs() is False + assert [event.event_id for event in published] == ["proactive_feedback:1"] + + conn = sqlite3.connect(original._db_path) + try: + assert conn.execute( + "SELECT processed_at FROM proactive_feedback_input_inbox" + ).fetchone()[0] is not None + finally: + conn.close() + + +@pytest.mark.asyncio +async def test_durable_input_cancellation_keeps_pending_row( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + started = asyncio.Event() + + async def blocked_scoring(**_kwargs: object) -> None: + started.set() + await asyncio.Future() + + monkeypatch.setattr(module, "score_followup", blocked_scoring) + runtime = module.ProactiveFeedbackRuntime( + session_read=SessionReadService( + lambda _key: (cast(Any, _session_state()), None) + ), + workspace=tmp_path, + db_path=tmp_path / "data" / "proactive_feedback.db", + ) + runtime.enqueue(_event()) + task = asyncio.create_task(runtime._process_pending_inputs()) + await started.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + conn = sqlite3.connect(runtime._db_path) + try: + assert conn.execute( + "SELECT processed_at FROM proactive_feedback_input_inbox" + ).fetchone() == (None,) + finally: + conn.close() + + +def test_published_feedback_identity_is_immutable(tmp_path: Path) -> None: + sink = module.open_db(tmp_path / "proactive_feedback.db") + try: + first = FeedbackEvent( + session_key="mobile:test", + user_message_id="u1", + assistant_message_id="a1", + proactive_message_id="p1", + feedback_type="explicit_quote", + confidence="gold", + pa_score=1.0, + pua_score=0.9, + lag_seconds=8, + candidate_count=1, + matched_by="explicit_quote", + reason="first_reason", + user_content_preview="first user", + assistant_content_preview="first answer", + proactive_content_preview="first proactive", + ) + row_id = module.insert_feedback(sink, first) + assert row_id == 1 + module.mark_feedback_published( + sink, + row_id=row_id, + event_id="proactive_feedback:1", + ) + second = FeedbackEvent( + **{ + **first.__dict__, + "feedback_type": "no_topic_follow", + "confidence": "low", + "pa_score": 0.1, + "pua_score": 0.2, + "reason": "second_reason", + "user_content_preview": "second user", + "assistant_content_preview": "second answer", + "proactive_content_preview": "second proactive", + } + ) + assert module.insert_feedback(sink, second) == row_id + third = FeedbackEvent( + **{**first.__dict__, "proactive_message_id": "p2"} + ) + assert module.insert_feedback(sink, third) == 2 + assert tuple(sink.execute( + "SELECT count(*) FROM proactive_feedback_events" + ).fetchone()) == (2,) + projection = tuple(sink.execute( + "SELECT feedback_type, confidence, pa_score, pua_score, reason, " + "user_content_preview, assistant_content_preview, proactive_content_preview " + "FROM proactive_feedback_events WHERE id = 1" + ).fetchone()) + payload = json.loads( + sink.execute( + "SELECT payload_json FROM proactive_feedback_outbox " + "WHERE row_id = 1" + ).fetchone()[0] + ) + finally: + sink.close() + assert projection == ( + "explicit_quote", + "gold", + 1.0, + 0.9, + "first_reason", + "first user", + "first answer", + "first proactive", + ) + assert payload["reason"] == "first_reason" + assert payload["pa_score"] == 1.0 + + @pytest.mark.asyncio async def test_failed_publication_is_replayed_after_restart(tmp_path: Path) -> None: async def fail(_event: ProactiveFeedbackCommitted) -> None: From 3537f45aa84bc5dd57ec6783d7e0a43387b2cc22 Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Mon, 17 Aug 2026 20:28:29 +0800 Subject: [PATCH 5/8] fix(plugin): recover committed input turns on boot --- README.md | 9 +- db.py | 134 ++++++++++++++++++++++- plugin.py | 253 +++++++++++++++++++++++++++++++++++++------ tests/test_plugin.py | 156 ++++++++++++++++++++++++++ 4 files changed, 511 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 502561c..30dcae1 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,11 @@ Akashic proactive feedback plugin. - 反馈数据库由 Core 分配的 `ctx.data_root` 独占,Dashboard 与 Mobile 只读同一投影; - `apply` 不读取或写入正式 `sessions.db`,候选期不会访问正式 Session;候选没有 反馈 DB 时也不会为了重放而创建文件;已提交 Turn 的 inbox 只保存 - session/turn/message identity,不保存 user/assistant 正文。 + session/turn/message identity,不保存 user/assistant 正文。Core 正式 generation + 启动时会在最多 64 个既有 session、最多 256 个 Turn 的边界内用 `SESSION_READ` + 重新发现已提交但尚未进入 inbox 的 eligible Turn;只把 ordered user IDs 和 + assistant ID 写入 inbox,正文只在评分内存中重建。候选 generation 不执行 discovery, + 因而不会写正式 DB 或事件。 ### Durable typed event @@ -22,7 +26,8 @@ Core exact `20062a715d2c5822228b327863b51c8d036119b3` 提供唯一的 `ctx.observe(PROACTIVE_FEEDBACK_COMMITTED, ProactiveFeedbackCommitted(...))`。 事件 `event_id` 固定为 `proactive_feedback:`,DTO 使用 Core 的 -`session_key`、user/assistant/proactive message identity、评分、`reason` 和最多 +`session_key`、ordered user message identity(DTO 使用该 Turn 最后一条 user ID)、 +assistant/proactive message identity、评分、`reason` 和最多 2400 字符的 user/assistant/proactive preview。全文不进入事件。发布成功后同库的 `proactive_feedback_published_cursor` 与 outbox receipt 一起推进;发布失败、进程内 取消或 Core 重启都会保留 pending 行,正式 generation 启动时按 row 顺序重放。消费方 diff --git a/db.py b/db.py index 32c425b..6a7c48f 100644 --- a/db.py +++ b/db.py @@ -43,6 +43,7 @@ class FeedbackInputRecord: turn_id: str client_message_id: str user_message_id: str + user_message_ids: tuple[str, ...] assistant_message_id: str | None @@ -95,6 +96,7 @@ def open_db(path: Path) -> sqlite3.Connection: turn_id TEXT NOT NULL DEFAULT '', client_message_id TEXT NOT NULL DEFAULT '', user_message_id TEXT NOT NULL, + user_message_ids_json TEXT NOT NULL DEFAULT '[]', assistant_message_id TEXT, processed_at TEXT, UNIQUE(session_key, user_message_id) @@ -103,6 +105,11 @@ def open_db(path: Path) -> sqlite3.Connection: CREATE INDEX IF NOT EXISTS idx_pfe_input_pending ON proactive_feedback_input_inbox(processed_at, id); + CREATE TABLE IF NOT EXISTS proactive_feedback_session_catalog ( + session_key TEXT PRIMARY KEY, + discovered_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS proactive_feedback_outbox ( row_id INTEGER PRIMARY KEY, event_id TEXT NOT NULL UNIQUE, @@ -124,6 +131,7 @@ def open_db(path: Path) -> sqlite3.Connection: _ensure_column(conn, "user_content_preview") _ensure_column(conn, "assistant_content_preview") _ensure_column(conn, "proactive_content_preview") + _ensure_input_column(conn, "user_message_ids_json") conn.commit() return conn @@ -168,12 +176,19 @@ def insert_feedback_input( client_message_id: str, user_message_id: str, assistant_message_id: str | None, + user_message_ids: tuple[str, ...] | None = None, ) -> int: """Durably record one committed Turn identity without storing message text.""" # 1. Validate the identity that the recovery reader will use. _required_input_text(session_key, "session_key") _required_input_text(user_message_id, "user_message_id") + ordered_user_ids = ( + (user_message_id,) if user_message_ids is None else user_message_ids + ) + _validate_user_message_ids(ordered_user_ids) + if ordered_user_ids[-1] != user_message_id: + raise ValueError("input inbox user_message_id 必须是 ordered IDs 的最后一项") _optional_input_text(turn_id, "turn_id") _optional_input_text(client_message_id, "client_message_id") if assistant_message_id is not None: @@ -182,7 +197,7 @@ def insert_feedback_input( # 2. Preserve one durable row for duplicate committed events. existing = conn.execute( """ - SELECT id + SELECT id, processed_at, user_message_ids_json FROM proactive_feedback_input_inbox WHERE session_key = ? AND user_message_id = ? LIMIT 1 @@ -190,27 +205,51 @@ def insert_feedback_input( (session_key, user_message_id), ).fetchone() if existing is not None: + if existing["processed_at"] is None and _decode_user_message_ids( + existing["user_message_ids_json"], user_message_id + ) != ordered_user_ids: + _ = conn.execute( + """ + UPDATE proactive_feedback_input_inbox + SET user_message_ids_json = ?, assistant_message_id = ? + WHERE id = ? AND processed_at IS NULL + """, + ( + json.dumps(ordered_user_ids, ensure_ascii=False), + assistant_message_id, + int(existing["id"]), + ), + ) + conn.commit() return int(existing["id"]) try: cursor = conn.execute( """ INSERT INTO proactive_feedback_input_inbox( session_key, turn_id, client_message_id, - user_message_id, assistant_message_id + user_message_id, user_message_ids_json, assistant_message_id ) - VALUES (?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?) """, ( session_key, turn_id, client_message_id, user_message_id, + json.dumps(ordered_user_ids, ensure_ascii=False), assistant_message_id, ), ) if cursor.lastrowid is None: raise RuntimeError("feedback input insert failed") row_id = int(cursor.lastrowid) + _ = conn.execute( + """ + INSERT OR IGNORE INTO proactive_feedback_session_catalog(session_key) + VALUES (?) + """, + (session_key,), + ) conn.commit() return row_id except (sqlite3.Error, RuntimeError, ValueError): @@ -231,7 +270,7 @@ def pending_feedback_inputs( rows = conn.execute( """ SELECT id, session_key, turn_id, client_message_id, - user_message_id, assistant_message_id + user_message_id, user_message_ids_json, assistant_message_id FROM proactive_feedback_input_inbox WHERE processed_at IS NULL ORDER BY id ASC @@ -254,7 +293,7 @@ def pending_feedback_input( row = conn.execute( """ SELECT id, session_key, turn_id, client_message_id, - user_message_id, assistant_message_id + user_message_id, user_message_ids_json, assistant_message_id FROM proactive_feedback_input_inbox WHERE id = ? AND processed_at IS NULL """, @@ -292,6 +331,47 @@ def mark_feedback_input_processed( conn.commit() +def feedback_session_keys( + conn: sqlite3.Connection, + *, + limit: int = 64, +) -> tuple[str, ...]: + """Read the bounded durable session-key catalog used by formal recovery.""" + + if isinstance(limit, bool) or not isinstance(limit, int) or limit < 1: + raise ValueError("session catalog limit 必须是正整数") + rows = conn.execute( + """ + SELECT session_key + FROM proactive_feedback_session_catalog + ORDER BY discovered_at ASC, session_key ASC + LIMIT ? + """, + (limit,), + ).fetchall() + return tuple(str(row["session_key"]) for row in rows) + + +def feedback_identity_exists( + conn: sqlite3.Connection, + *, + session_key: str, + user_message_id: str, +) -> bool: + """Check whether a feedback projection already owns one user identity.""" + + row = conn.execute( + """ + SELECT 1 + FROM proactive_feedback_events + WHERE session_key = ? AND user_message_id = ? + LIMIT 1 + """, + (session_key, user_message_id), + ).fetchone() + return row is not None + + def _feedback_owned_by_other( conn: sqlite3.Connection, event: FeedbackEvent, @@ -491,12 +571,16 @@ def _feedback_payload(event_id: str, event: FeedbackEvent) -> dict[str, object]: def _feedback_input_record(row: sqlite3.Row) -> FeedbackInputRecord: + user_message_id = str(row["user_message_id"]) return FeedbackInputRecord( row_id=int(row["id"]), session_key=str(row["session_key"]), turn_id=str(row["turn_id"]), client_message_id=str(row["client_message_id"]), - user_message_id=str(row["user_message_id"]), + user_message_id=user_message_id, + user_message_ids=_decode_user_message_ids( + row["user_message_ids_json"], user_message_id + ), assistant_message_id=( None if row["assistant_message_id"] is None @@ -519,6 +603,44 @@ def _optional_input_text(value: str, field: str) -> None: raise ValueError(f"input inbox {field} 不能有首尾空白") +def _validate_user_message_ids(user_message_ids: tuple[str, ...]) -> None: + if not user_message_ids: + raise ValueError("input inbox user_message_ids 不能为空") + if len(set(user_message_ids)) != len(user_message_ids): + raise ValueError("input inbox user_message_ids 不能重复") + for message_id in user_message_ids: + _required_input_text(message_id, "user_message_id") + + +def _decode_user_message_ids(value: object, fallback: str) -> tuple[str, ...]: + if isinstance(value, str) and value: + try: + decoded = json.loads(value) + except json.JSONDecodeError: + decoded = None + if decoded and isinstance(decoded, list) and all( + isinstance(item, str) and item for item in decoded + ): + ids = tuple(decoded) + if len(set(ids)) == len(ids) and ids[-1] == fallback: + return ids + return (fallback,) + + +def _ensure_input_column(conn: sqlite3.Connection, name: str) -> None: + columns = { + str(row[1]) + for row in conn.execute( + "PRAGMA table_info(proactive_feedback_input_inbox)" + ) + } + if name not in columns: + _ = conn.execute( + "ALTER TABLE proactive_feedback_input_inbox " + "ADD COLUMN user_message_ids_json TEXT NOT NULL DEFAULT '[]'" + ) + + def pending_feedback_outbox( conn: sqlite3.Connection, *, diff --git a/plugin.py b/plugin.py index 970ffde..7eb7ce5 100644 --- a/plugin.py +++ b/plugin.py @@ -4,7 +4,7 @@ import json import logging import os -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Iterable from pathlib import Path from typing import Any, cast @@ -31,6 +31,8 @@ from .db import ( FeedbackEvent, FeedbackInputRecord, + feedback_identity_exists, + feedback_session_keys, insert_feedback, insert_feedback_input, mark_feedback_input_processed, @@ -42,7 +44,6 @@ ) from .scorer import ( MessageRow, - latest_turn_messages_from_rows, message_rows_from_snapshot, parse_quote_parts, proactive_since_previous_user_from_rows, @@ -57,6 +58,8 @@ _PREVIEW_MAX_CHARS = 2400 _OUTBOX_BATCH_SIZE = 100 _OUTBOX_RETRY_SECONDS = 1.0 +_DISCOVERY_SESSION_LIMIT = 64 +_DISCOVERY_TURN_LIMIT = 256 FeedbackPublisher = Callable[[ProactiveFeedbackCommitted], Awaitable[None]] @@ -117,21 +120,23 @@ def __init__( workspace: Path, db_path: Path, publish_feedback: FeedbackPublisher | None = None, + session_keys: Callable[[], Iterable[str]] | None = None, ) -> None: self._session_read = session_read self._workspace = workspace self._db_path = db_path self._publish_feedback = publish_feedback + self._session_keys = session_keys or ( + lambda: _formal_session_keys_from_read_service(session_read) + ) self._queue: asyncio.Queue[int] = asyncio.Queue(maxsize=_QUEUE_MAX) self._embedder: Embedder | None = None + self._discovery_done = False def enqueue(self, event: TurnCommitted) -> None: """Durably record one committed Turn identity and wake the worker.""" - if ( - event.persisted_user_message is None - or event.persisted_user_message_id is None - ): + if event.persisted_user_message is None or not _event_user_message_ids(event): return input_row_id = self._persist_input(event) try: @@ -145,8 +150,12 @@ def enqueue(self, event: TurnCommitted) -> None: async def run_worker(self) -> None: """Process queued committed turns until the owning Fiber is disposed.""" + # 1. Recover a Core commit that ended before AFTER_TURN_COMMITTED. + if not self._discovery_done: + self._discover_committed_inputs() + self._discovery_done = True while True: - # 1. Replay every durable payload before waiting for new Turns. + # 2. Replay every durable payload before waiting for new Turns. try: await self._publish_pending() except asyncio.CancelledError: @@ -167,7 +176,7 @@ async def run_worker(self) -> None: await asyncio.sleep(_OUTBOX_RETRY_SECONDS) continue - # 2. Process one Core-committed Turn and drain its transaction's outbox. + # 3. Process one Core-committed Turn and drain its transaction's outbox. input_row_id = await self._queue.get() try: await self._process_input_row(input_row_id) @@ -227,28 +236,21 @@ async def _process( """Score one committed turn using a detached Session snapshot.""" # 1. Resolve the committed message identity through Core's read service. - user_text = event.persisted_user_message - if not user_text or not event.assistant_response: + if not event.persisted_user_message or not event.assistant_response: self._complete_input(input_row_id) return snapshot = self._session_read.read(event.session_key) if snapshot is None: return rows = message_rows_from_snapshot(snapshot.messages) - turn = latest_turn_messages_from_rows( - rows, - user_message_id=event.persisted_user_message_id, - assistant_message_id=event.assistant_message_id, - user_content=user_text, - assistant_content=event.assistant_response, - ) + turn = _turn_for_event(rows, event) if turn is None: logger.warning( "proactive_feedback committed message missing session=%s", event.session_key, ) return - user, assistant = turn + user, assistant, candidate_before_seq = turn # 2. Preserve the v2 candidate window and quote matching semantics. quote = parse_quote_parts(user.content) @@ -256,13 +258,13 @@ async def _process( if quote.quoted_text: candidates = recent_proactive_messages_from_rows( rows, - before_seq=user.seq, + before_seq=candidate_before_seq, limit=64, ) else: candidates = proactive_since_previous_user_from_rows( rows, - before_seq=user.seq, + before_seq=candidate_before_seq, limit=8, ) if not candidates: @@ -359,6 +361,7 @@ async def _persist_feedback( sink.close() def _persist_input(self, event: TurnCommitted) -> int: + user_message_ids = _event_user_message_ids(event) sink = open_db(self._db_path) try: return insert_feedback_input( @@ -366,8 +369,11 @@ def _persist_input(self, event: TurnCommitted) -> int: session_key=event.session_key, turn_id=event.turn_id, client_message_id=event.client_message_id, - user_message_id=event.persisted_user_message_id or "", + user_message_id=( + event.persisted_user_message_id or user_message_ids[-1] + ), assistant_message_id=event.assistant_message_id, + user_message_ids=user_message_ids, ) finally: sink.close() @@ -422,14 +428,7 @@ async def _process_input_record(self, record: FeedbackInputRecord) -> None: ) return rows = message_rows_from_snapshot(snapshot.messages) - user = next( - ( - row - for row in rows - if row.role == "user" and row.id == record.user_message_id - ), - None, - ) + user = _aggregate_user_row(rows, record.user_message_ids) assistant = _assistant_for_input(rows, record) if user is None or assistant is None: logger.warning( @@ -450,6 +449,7 @@ async def _process_input_record(self, record: FeedbackInputRecord) -> None: turn_id=record.turn_id, client_message_id=record.client_message_id, persisted_user_message_id=user.id, + persisted_user_message_ids=record.user_message_ids, assistant_message_id=assistant.id, ), input_row_id=record.row_id, @@ -491,11 +491,201 @@ def _get_embedder(self) -> Embedder: self._embedder = _build_embedder(self._workspace) return self._embedder + def _discover_committed_inputs(self) -> None: + """Discover bounded eligible Turns committed before the callback fanout.""" + + # 1. Candidate generations never receive formal SessionRead data. + if getattr(self._session_read, "_lookup_existing", None) is None: + return + + # 2. Combine the durable catalog with Core's bounded existing-session list. + keys: list[str] = [] + if self._db_path.exists(): + sink = open_db(self._db_path) + try: + keys.extend( + feedback_session_keys(sink, limit=_DISCOVERY_SESSION_LIMIT) + ) + finally: + sink.close() + keys.extend(self._session_keys()) + ordered_keys = tuple(dict.fromkeys(key for key in keys if key))[ + :_DISCOVERY_SESSION_LIMIT + ] + if not ordered_keys: + return + + # 3. Read detached snapshots and persist identities only. + sink = open_db(self._db_path) + try: + discovered = 0 + for session_key in ordered_keys: + snapshot = self._session_read.read(session_key) + if snapshot is None: + continue + rows = message_rows_from_snapshot(snapshot.messages) + for user_ids, assistant_id in _iter_discovered_turns(rows): + if discovered >= _DISCOVERY_TURN_LIMIT: + return + if feedback_identity_exists( + sink, + session_key=session_key, + user_message_id=user_ids[-1], + ): + continue + _ = insert_feedback_input( + sink, + session_key=session_key, + turn_id="", + client_message_id="", + user_message_id=user_ids[-1], + user_message_ids=user_ids, + assistant_message_id=assistant_id, + ) + discovered += 1 + finally: + sink.close() + def _bounded_preview(value: str, limit: int = _PREVIEW_MAX_CHARS) -> str: return value[:limit] +def _event_user_message_ids(event: TurnCommitted) -> tuple[str, ...]: + if event.persisted_user_message_ids: + return tuple(event.persisted_user_message_ids) + if event.persisted_user_message_id is not None: + return (event.persisted_user_message_id,) + return () + + +def _turn_for_event( + rows: list[MessageRow], + event: TurnCommitted, +) -> tuple[MessageRow, MessageRow, int] | None: + user_ids = _event_user_message_ids(event) + user = _aggregate_user_row( + rows, + user_ids, + expected_content=event.persisted_user_message, + ) + if user is None: + return None + assistant = _assistant_for_event(rows, event) + if assistant is None: + return None + first_user = min( + (row.seq for row in rows if row.role == "user" and row.id in user_ids), + default=user.seq, + ) + return user, assistant, first_user + + +def _aggregate_user_row( + rows: list[MessageRow], + user_message_ids: tuple[str, ...], + *, + expected_content: str | None = None, +) -> MessageRow | None: + if not user_message_ids or len(set(user_message_ids)) != len(user_message_ids): + return None + by_id = {row.id: row for row in rows if row.role == "user"} + user_rows = [by_id[message_id] for message_id in user_message_ids if message_id in by_id] + if len(user_rows) != len(user_message_ids): + return None + content = "\n\n".join(row.content for row in user_rows) + if expected_content is not None and content != expected_content: + return None + last = user_rows[-1] + return MessageRow( + id=last.id, + seq=last.seq, + role=last.role, + content=content, + extra=last.extra, + ts=last.ts, + ) + + +def _assistant_for_event( + rows: list[MessageRow], + event: TurnCommitted, +) -> MessageRow | None: + candidates = [row for row in rows if row.role == "assistant"] + if event.assistant_message_id is not None: + candidates = [ + row for row in candidates if row.id == event.assistant_message_id + ] + else: + candidates = [ + row for row in candidates if row.content == event.assistant_response + ] + if event.assistant_response: + candidates = [ + row for row in candidates if row.content == event.assistant_response + ] + return max(candidates, key=lambda row: row.seq, default=None) + + +def _iter_discovered_turns( + rows: list[MessageRow], +) -> Iterable[tuple[tuple[str, ...], str]]: + """Yield bounded eligible committed Turn identities from a detached snapshot.""" + + ordered = sorted(rows, key=lambda row: row.seq) + previous_assistant_seq = -1 + for assistant in (row for row in ordered if row.role == "assistant"): + users = [ + row + for row in ordered + if row.role == "user" + and previous_assistant_seq < row.seq < assistant.seq + ] + if users: + user_ids = tuple(row.id for row in users) + aggregate = "\n\n".join(row.content for row in users) + quote = parse_quote_parts(aggregate) + candidates = ( + recent_proactive_messages_from_rows( + ordered, + before_seq=users[0].seq, + limit=64, + ) + if quote.quoted_text + else proactive_since_previous_user_from_rows( + ordered, + before_seq=users[0].seq, + limit=8, + ) + ) + if candidates: + yield user_ids, assistant.id + previous_assistant_seq = assistant.seq + + +def _formal_session_keys_from_read_service( + session_read: SessionReadService, +) -> tuple[str, ...]: + """Return only the Core-owned bounded key catalog; snapshots still use SESSION_READ.""" + + public_catalog = getattr(session_read, "list_session_keys", None) + if callable(public_catalog): + catalog = cast(Callable[[], Iterable[object]], public_catalog) + return tuple(str(key) for key in catalog()) + lookup = getattr(session_read, "_lookup_existing", None) + owner = getattr(lookup, "__self__", None) + manager = getattr(owner, "_session_manager", None) + list_sessions = getattr(manager, "list_sessions", None) + if not callable(list_sessions): + return () + keys: list[str] = [] + catalog = cast(Callable[[], list[object]], list_sessions) + for item in catalog()[:_DISCOVERY_SESSION_LIMIT]: + if isinstance(item, dict) and isinstance(item.get("key"), str): + keys.append(item["key"]) + return tuple(keys) + + def _build_embedder(workspace: Path) -> Embedder: config_path = os.environ.get("AKASHIC_CONFIG", "").strip() if not config_path: @@ -532,10 +722,7 @@ def _assistant_for_input( ), None, ) - user = next( - (row for row in rows if row.role == "user" and row.id == record.user_message_id), - None, - ) + user = _aggregate_user_row(rows, record.user_message_ids) if user is None: return None return min( diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 97db0ae..0151c36 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -105,6 +105,65 @@ def _snapshot(*, quoted: bool = True) -> SessionReadSnapshot: ) +def _two_user_event() -> TurnCommitted: + first = "被回复消息:主动提醒某个很长很长的主题" + second = "【你当前新消息】我继续这个主题" + return TurnCommitted( + session_key="mobile:test", + channel="test", + chat_id="chat", + input_message=f"{first}\n\n{second}", + persisted_user_message=f"{first}\n\n{second}", + assistant_response="我接着回答这个主题", + tools_used=[], + persisted_user_message_id="u2", + persisted_user_message_ids=("u1", "u2"), + assistant_message_id="a1", + ) + + +def _two_user_snapshot() -> SessionReadSnapshot: + return SessionReadSnapshot( + session_key="mobile:test", + messages=( + { + "id": "p1", + "seq": 1, + "role": "assistant", + "content": "主动提醒某个很长很长的主题", + "extra": '{"proactive": true}', + "ts": "2026-08-17T00:00:00+00:00", + }, + { + "id": "u1", + "seq": 2, + "role": "user", + "content": "被回复消息:主动提醒某个很长很长的主题", + "extra": None, + "ts": "2026-08-17T00:00:10+00:00", + }, + { + "id": "u2", + "seq": 3, + "role": "user", + "content": "【你当前新消息】我继续这个主题", + "extra": None, + "ts": "2026-08-17T00:00:11+00:00", + }, + { + "id": "a1", + "seq": 4, + "role": "assistant", + "content": "我接着回答这个主题", + "extra": None, + "ts": "2026-08-17T00:00:12+00:00", + }, + ), + compaction_generation=0, + consolidated_through_seq=None, + ) + + def test_module_exports_pure_v3_contract() -> None: assert module.api_version == 3 assert module.name == "proactive_feedback" @@ -289,6 +348,103 @@ async def publish(event: ProactiveFeedbackCommitted) -> None: conn.close() +@pytest.mark.asyncio +async def test_durable_input_keeps_ordered_two_user_ids_and_scores_one_turn( + tmp_path: Path, +) -> None: + published: list[ProactiveFeedbackCommitted] = [] + + async def publish(event: ProactiveFeedbackCommitted) -> None: + published.append(event) + + session_read = SessionReadService( + lambda _key: ( + cast(Any, SimpleNamespace( + messages=[dict(message) for message in _two_user_snapshot().messages], + last_consolidated=0, + )), + None, + ) + ) + original = module.ProactiveFeedbackRuntime( + session_read=session_read, + workspace=tmp_path, + db_path=tmp_path / "data" / "proactive_feedback.db", + ) + original.enqueue(_two_user_event()) + + restarted = module.ProactiveFeedbackRuntime( + session_read=session_read, + workspace=tmp_path, + db_path=original._db_path, + publish_feedback=publish, + ) + assert await restarted._process_pending_inputs() is False + + conn = sqlite3.connect(original._db_path) + try: + inbox = conn.execute( + "SELECT user_message_id, user_message_ids_json, processed_at " + "FROM proactive_feedback_input_inbox" + ).fetchone() + projection = conn.execute( + "SELECT user_message_id, user_content_preview " + "FROM proactive_feedback_events" + ).fetchone() + finally: + conn.close() + assert inbox == ("u2", '["u1", "u2"]', inbox[2]) + assert inbox[2] is not None + assert projection[0] == "u2" + assert "被回复消息" in projection[1] and "当前新消息" in projection[1] + assert [event.event_id for event in published] == ["proactive_feedback:1"] + + +@pytest.mark.asyncio +async def test_formal_boot_discovers_committed_turn_without_callback_once( + tmp_path: Path, +) -> None: + published: list[ProactiveFeedbackCommitted] = [] + published_event = asyncio.Event() + + async def publish(event: ProactiveFeedbackCommitted) -> None: + published.append(event) + published_event.set() + + session_read = SessionReadService( + lambda _key: ( + cast(Any, _session_state()), + None, + ) + ) + restarted = module.ProactiveFeedbackRuntime( + session_read=session_read, + workspace=tmp_path, + db_path=tmp_path / "data" / "proactive_feedback.db", + publish_feedback=publish, + session_keys=lambda: ("mobile:test",), + ) + + worker = asyncio.create_task(restarted.run_worker()) + await asyncio.wait_for(published_event.wait(), timeout=1) + worker.cancel() + with pytest.raises(asyncio.CancelledError): + await worker + restarted._discover_committed_inputs() + + assert [event.event_id for event in published] == ["proactive_feedback:1"] + conn = sqlite3.connect(restarted._db_path) + try: + assert conn.execute( + "SELECT count(*) FROM proactive_feedback_input_inbox" + ).fetchone() == (1,) + assert conn.execute( + "SELECT count(*) FROM proactive_feedback_events" + ).fetchone() == (1,) + finally: + conn.close() + + @pytest.mark.asyncio async def test_durable_input_cancellation_keeps_pending_row( monkeypatch: pytest.MonkeyPatch, From cafed559c2a6e98538b997c9fb1dcbf908dbef6e Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Mon, 17 Aug 2026 20:51:14 +0800 Subject: [PATCH 6/8] =?UTF-8?q?fix(plugin):=20=E6=94=B6=E7=B4=A7=E5=80=99?= =?UTF-8?q?=E9=80=89=E5=86=99=E5=85=A5=E5=B9=B6=E4=BF=AE=E5=A4=8D=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E5=8F=91=E7=8E=B0=E7=AA=97=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugin.py | 52 +++++++++++++++++++++++++++-------- tests/test_plugin.py | 65 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 104 insertions(+), 13 deletions(-) diff --git a/plugin.py b/plugin.py index 7eb7ce5..0d4c6c8 100644 --- a/plugin.py +++ b/plugin.py @@ -136,8 +136,13 @@ def __init__( def enqueue(self, event: TurnCommitted) -> None: """Durably record one committed Turn identity and wake the worker.""" + # 1. Candidate validation has no formal Session or plugin data owner. + if getattr(self._session_read, "_lookup_existing", None) is None: + raise RuntimeError("候选验证期禁止写入 proactive_feedback") if event.persisted_user_message is None or not _event_user_message_ids(event): return + + # 2. Persist the identity before waking the in-memory worker. input_row_id = self._persist_input(event) try: self._queue.put_nowait(input_row_id) @@ -498,20 +503,30 @@ def _discover_committed_inputs(self) -> None: if getattr(self._session_read, "_lookup_existing", None) is None: return - # 2. Combine the durable catalog with Core's bounded existing-session list. - keys: list[str] = [] + # 2. Prefer Core's current key catalog; the durable catalog only fills gaps. + ordered_keys = list( + _bounded_session_keys( + self._session_keys(), + limit=_DISCOVERY_SESSION_LIMIT, + ) + ) if self._db_path.exists(): sink = open_db(self._db_path) try: - keys.extend( - feedback_session_keys(sink, limit=_DISCOVERY_SESSION_LIMIT) + catalog_keys = feedback_session_keys( + sink, + limit=_DISCOVERY_SESSION_LIMIT, ) finally: sink.close() - keys.extend(self._session_keys()) - ordered_keys = tuple(dict.fromkeys(key for key in keys if key))[ - :_DISCOVERY_SESSION_LIMIT - ] + seen = set(ordered_keys) + for session_key in catalog_keys: + if len(ordered_keys) >= _DISCOVERY_SESSION_LIMIT: + break + if session_key in seen: + continue + ordered_keys.append(session_key) + seen.add(session_key) if not ordered_keys: return @@ -671,19 +686,32 @@ def _formal_session_keys_from_read_service( public_catalog = getattr(session_read, "list_session_keys", None) if callable(public_catalog): catalog = cast(Callable[[], Iterable[object]], public_catalog) - return tuple(str(key) for key in catalog()) + return _bounded_session_keys(catalog(), limit=_DISCOVERY_SESSION_LIMIT) lookup = getattr(session_read, "_lookup_existing", None) owner = getattr(lookup, "__self__", None) manager = getattr(owner, "_session_manager", None) list_sessions = getattr(manager, "list_sessions", None) if not callable(list_sessions): return () - keys: list[str] = [] catalog = cast(Callable[[], list[object]], list_sessions) - for item in catalog()[:_DISCOVERY_SESSION_LIMIT]: + keys: list[str] = [] + for item in catalog(): if isinstance(item, dict) and isinstance(item.get("key"), str): keys.append(item["key"]) - return tuple(keys) + return _bounded_session_keys(keys, limit=_DISCOVERY_SESSION_LIMIT) + + +def _bounded_session_keys( + values: Iterable[object], + *, + limit: int, +) -> tuple[str, ...]: + """Bound a key catalog while preserving both current-list edges.""" + + unique = tuple(dict.fromkeys(str(value) for value in values if value)) + if len(unique) <= limit: + return unique + return (*unique[: limit - 1], unique[-1]) def _build_embedder(workspace: Path) -> Embedder: diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 0151c36..86219e8 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -288,7 +288,9 @@ async def publish(event: ProactiveFeedbackCommitted) -> None: def test_committed_turn_identity_is_durable_without_message_text(tmp_path: Path) -> None: runtime = module.ProactiveFeedbackRuntime( - session_read=SessionReadService.candidate_validation(), + session_read=SessionReadService( + lambda _key: (cast(Any, _session_state()), None) + ), workspace=tmp_path, db_path=tmp_path / "data" / "proactive_feedback.db", ) @@ -314,6 +316,20 @@ def test_committed_turn_identity_is_durable_without_message_text(tmp_path: Path) assert "assistant_content" not in columns +def test_candidate_enqueue_fails_before_any_write(tmp_path: Path) -> None: + db_path = tmp_path / "data" / "proactive_feedback.db" + runtime = module.ProactiveFeedbackRuntime( + session_read=SessionReadService.candidate_validation(), + workspace=tmp_path, + db_path=db_path, + ) + + with pytest.raises(RuntimeError, match="候选验证期禁止写入"): + runtime.enqueue(_event()) + + assert not db_path.exists() + + @pytest.mark.asyncio async def test_durable_input_replays_after_runtime_restart(tmp_path: Path) -> None: published: list[ProactiveFeedbackCommitted] = [] @@ -445,6 +461,53 @@ async def publish(event: ProactiveFeedbackCommitted) -> None: conn.close() +def test_formal_boot_prioritizes_new_session_over_old_catalog_window( + tmp_path: Path, +) -> None: + old_keys = tuple(f"old:{index:02d}" for index in range(64)) + new_key = "mobile:new" + read_keys: list[str] = [] + + def lookup(session_key: str) -> tuple[Any, None]: + read_keys.append(session_key) + return cast(Any, _session_state()), None + + db_path = tmp_path / "data" / "proactive_feedback.db" + sink = module.open_db(db_path) + try: + for index, session_key in enumerate(old_keys): + module.insert_feedback_input( + sink, + session_key=session_key, + turn_id="", + client_message_id="", + user_message_id=f"old-u{index}", + assistant_message_id="old-a", + ) + finally: + sink.close() + + runtime = module.ProactiveFeedbackRuntime( + session_read=SessionReadService(lookup), + workspace=tmp_path, + db_path=db_path, + session_keys=lambda: (*old_keys, new_key), + ) + runtime._discover_committed_inputs() + + assert new_key in read_keys + assert len(read_keys) == 64 + conn = sqlite3.connect(db_path) + try: + assert conn.execute( + "SELECT count(*) FROM proactive_feedback_input_inbox " + "WHERE session_key = ?", + (new_key,), + ).fetchone() == (1,) + finally: + conn.close() + + @pytest.mark.asyncio async def test_durable_input_cancellation_keeps_pending_row( monkeypatch: pytest.MonkeyPatch, From 41be8198159f42d90f7d891ec8aecacbeae6d60b Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Mon, 17 Aug 2026 21:09:50 +0800 Subject: [PATCH 7/8] =?UTF-8?q?fix(plugin):=20=E5=85=AC=E5=B9=B3=E8=BD=AE?= =?UTF-8?q?=E8=BD=AC=E5=8F=8D=E9=A6=88=E5=90=AF=E5=8A=A8=E5=8F=91=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugin.py | 49 ++++++++++++++-------- tests/test_plugin.py | 99 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 17 deletions(-) diff --git a/plugin.py b/plugin.py index 0d4c6c8..fea3111 100644 --- a/plugin.py +++ b/plugin.py @@ -4,7 +4,7 @@ import json import logging import os -from collections.abc import Awaitable, Callable, Iterable +from collections.abc import Awaitable, Callable, Iterable, Iterator from pathlib import Path from typing import Any, cast @@ -530,34 +530,49 @@ def _discover_committed_inputs(self) -> None: if not ordered_keys: return - # 3. Read detached snapshots and persist identities only. + # 3. Read detached snapshots and retain one ordered stream per session. sink = open_db(self._db_path) try: - discovered = 0 + turn_streams: list[ + tuple[str, Iterator[tuple[tuple[str, ...], str]]] + ] = [] for session_key in ordered_keys: snapshot = self._session_read.read(session_key) if snapshot is None: continue rows = message_rows_from_snapshot(snapshot.messages) - for user_ids, assistant_id in _iter_discovered_turns(rows): - if discovered >= _DISCOVERY_TURN_LIMIT: + turn_streams.append((session_key, iter(_iter_discovered_turns(rows)))) + + # 4. Rotate sessions so one history cannot consume the whole boot budget. + examined = 0 + while turn_streams and examined < _DISCOVERY_TURN_LIMIT: + next_streams: list[ + tuple[str, Iterator[tuple[tuple[str, ...], str]]] + ] = [] + for session_key, turns in turn_streams: + if examined >= _DISCOVERY_TURN_LIMIT: return - if feedback_identity_exists( - sink, - session_key=session_key, - user_message_id=user_ids[-1], - ): + try: + user_ids, assistant_id = next(turns) + except StopIteration: continue - _ = insert_feedback_input( + examined += 1 + if not feedback_identity_exists( sink, session_key=session_key, - turn_id="", - client_message_id="", user_message_id=user_ids[-1], - user_message_ids=user_ids, - assistant_message_id=assistant_id, - ) - discovered += 1 + ): + _ = insert_feedback_input( + sink, + session_key=session_key, + turn_id="", + client_message_id="", + user_message_id=user_ids[-1], + user_message_ids=user_ids, + assistant_message_id=assistant_id, + ) + next_streams.append((session_key, turns)) + turn_streams = next_streams finally: sink.close() diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 86219e8..b27f8a2 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -164,6 +164,42 @@ def _two_user_snapshot() -> SessionReadSnapshot: ) +def _multi_turn_session_state(session_key: str, *, count: int = 5) -> object: + messages: list[dict[str, object]] = [] + seq = 1 + for index in range(count): + messages.extend( + [ + { + "id": f"{session_key}:p{index}", + "seq": seq, + "role": "assistant", + "content": f"主动提醒 {index}", + "extra": '{"proactive": true}', + "ts": "2026-08-17T00:00:00+00:00", + }, + { + "id": f"{session_key}:u{index}", + "seq": seq + 1, + "role": "user", + "content": f"继续主题 {index}", + "extra": None, + "ts": "2026-08-17T00:00:01+00:00", + }, + { + "id": f"{session_key}:a{index}", + "seq": seq + 2, + "role": "assistant", + "content": f"回答主题 {index}", + "extra": None, + "ts": "2026-08-17T00:00:02+00:00", + }, + ] + ) + seq += 3 + return SimpleNamespace(messages=messages, last_consolidated=0) + + def test_module_exports_pure_v3_contract() -> None: assert module.api_version == 3 assert module.name == "proactive_feedback" @@ -508,6 +544,69 @@ def lookup(session_key: str) -> tuple[Any, None]: conn.close() +def test_formal_boot_rotates_turns_across_sessions_with_pending_history( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + old_keys = tuple(f"old:{index:02d}" for index in range(64)) + new_key = "mobile:new" + read_keys: list[str] = [] + + def lookup(session_key: str) -> tuple[Any, None]: + read_keys.append(session_key) + count = 1 if session_key == new_key else 5 + return cast(Any, _multi_turn_session_state(session_key, count=count)), None + + db_path = tmp_path / "data" / "proactive_feedback.db" + sink = module.open_db(db_path) + try: + for session_key in old_keys: + for index in range(5): + module.insert_feedback_input( + sink, + session_key=session_key, + turn_id="", + client_message_id="", + user_message_id=f"{session_key}:u{index}", + assistant_message_id=f"{session_key}:a{index}", + ) + finally: + sink.close() + + insert_calls = 0 + real_insert = module.insert_feedback_input + + def track_insert(*args: Any, **kwargs: Any) -> int: + nonlocal insert_calls + insert_calls += 1 + return real_insert(*args, **kwargs) + + monkeypatch.setattr(module, "insert_feedback_input", track_insert) + runtime = module.ProactiveFeedbackRuntime( + session_read=SessionReadService(lookup), + workspace=tmp_path, + db_path=db_path, + session_keys=lambda: (*old_keys, new_key), + ) + runtime._discover_committed_inputs() + + assert new_key in read_keys + assert len(read_keys) == 64 + assert insert_calls <= module._DISCOVERY_TURN_LIMIT + conn = sqlite3.connect(db_path) + try: + assert conn.execute( + "SELECT count(*) FROM proactive_feedback_input_inbox" + ).fetchone() == (len(old_keys) * 5 + 1,) + assert conn.execute( + "SELECT count(*) FROM proactive_feedback_input_inbox " + "WHERE session_key = ?", + (new_key,), + ).fetchone() == (1,) + finally: + conn.close() + + @pytest.mark.asyncio async def test_durable_input_cancellation_keeps_pending_row( monkeypatch: pytest.MonkeyPatch, From 83d6eb7f4bc9e5b05a8ab247cb83ea1f7a073741 Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Thu, 20 Aug 2026 17:47:19 +0800 Subject: [PATCH 8/8] fix(plugin): remove legacy mobile query name --- .github/workflows/plugin-api-v3.yml | 2 +- plugin.py | 4 ++-- tests/test_plugin.py | 7 +------ 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/.github/workflows/plugin-api-v3.yml b/.github/workflows/plugin-api-v3.yml index 25f9dde..59d0648 100644 --- a/.github/workflows/plugin-api-v3.yml +++ b/.github/workflows/plugin-api-v3.yml @@ -36,7 +36,7 @@ jobs: - uses: actions/checkout@v4 with: repository: kachofugetsu09/akashic-agent - ref: 20062a715d2c5822228b327863b51c8d036119b3 + ref: 07d5e622dfd62badb519c19c45b62c2066a7a087 path: .akashic-core - uses: actions/setup-python@v5 with: diff --git a/plugin.py b/plugin.py index fea3111..3e49f51 100644 --- a/plugin.py +++ b/plugin.py @@ -105,7 +105,7 @@ async def apply(ctx: Context, config: object) -> None: description="主动消息是否被继续,以及对应的回应链路", ), ), - query=runtime.mobile_ui_query, + query=runtime.query_mobile, ) await ctx.spawn(runtime.run_worker(), name="proactive_feedback_worker") @@ -192,7 +192,7 @@ async def run_worker(self) -> None: finally: self._queue.task_done() - def mobile_ui_query( + def query_mobile( self, method: str, payload: dict[str, object], diff --git a/tests/test_plugin.py b/tests/test_plugin.py index b27f8a2..2685e2b 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -1025,12 +1025,7 @@ async def test_manager_stable_candidate_ui_dashboard_and_cleanup(tmp_path: Path) workspace=tmp_path / "workspace", installed_cache_root=tmp_path / "home" / "cache", ) - dashboard_host = PluginDashboardHost( - workspace=tmp_path / "workspace", - memory_admin=object(), - memory_store=object(), - core_routes=(), - ) + dashboard_host = PluginDashboardHost(core_routes=()) try: await manager.load_all() stable = manager.current_snapshot