Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 0 additions & 28 deletions .github/workflows/plugin-api-v2.yml

This file was deleted.

70 changes: 70 additions & 0 deletions .github/workflows/plugin-api-v3.yml
Original file line number Diff line number Diff line change
@@ -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: 07d5e622dfd62badb519c19c45b62c2066a7a087
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
65 changes: 65 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,71 @@

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;候选没有
反馈 DB 时也不会为了重放而创建文件;已提交 Turn 的 inbox 只保存
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

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:<row_id>`,DTO 使用 Core 的
`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 顺序重放。消费方
必须按 `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 一致性迁移;旧源始终保留:

```bash
python scripts/migrate_v2_data.py \
--workspace <workspace> \
--marketplace github
```

迁移完成后,可用下面的独立命令补齐历史事件的文本预览;它只更新插件自己的可选投影列,不删除消息:

```bash
python scripts/migrate_feedback_previews.py \
--sessions-db <workspace>/sessions.db \
--feedback-db <workspace>/plugin-data/<proactive-feedback-data-root>/proactive_feedback.db
```

插件不再声明 v2 `Plugin` class、EventBus listener、`ProactiveFeedbackRecorded` 或 tool
ABI;v3 运行路径只观察 Core 的 committed Turn,并通过上述 typed event 发布已持久化
反馈。

## 移动端看板

插件通过 Akashic 的通用移动 UI 生命周期注册“主动反馈”入口,不要求 Agent 核心识别
Expand Down
14 changes: 14 additions & 0 deletions akashic.plugin.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
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.db-journal",
".proactive-feedback-v2-migration.json",
]
113 changes: 55 additions & 58 deletions dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -103,16 +113,16 @@ 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
LIMIT ? OFFSET ?
""",
(*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():
Expand All @@ -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]:
Expand Down Expand Up @@ -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"""
Expand All @@ -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"],
Expand All @@ -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:
Expand Down
Loading
Loading