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
6 changes: 3 additions & 3 deletions plugin/dashboard/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,9 @@ export default function DashboardPage() {
};
}, [reflexioUrl]);

const currentPlaybooks = (playbooks ?? []).filter(
(p) => p.status == null || p.status === "CURRENT",
);
// CURRENT playbooks arrive as `status: null` (response_model_exclude_none
// strips the field). Anything else (e.g. "archived", "pending") is excluded.
const currentPlaybooks = (playbooks ?? []).filter((p) => p.status == null);
const learningInteractionTotal = (sessions ?? []).reduce(
(acc, s) => acc + s.learning_interaction_count,
0,
Expand Down
8 changes: 1 addition & 7 deletions plugin/dashboard/app/playbooks/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { reflexio } from "@/lib/reflexio-client";
import { useSettings } from "@/hooks/use-settings";
import { formatTimestamp, truncateId } from "@/lib/format";
import { cn } from "@/lib/utils";
import { statusLabel } from "@/lib/status";
import type { UserPlaybook } from "@/lib/types";

type FormState = { content: string; trigger: string; rationale: string };
Expand All @@ -46,13 +47,6 @@ function displayName(name: string | null | undefined): string | null {
return name;
}

function statusLabel(p: UserPlaybook): "CURRENT" | "ARCHIVED" | "PENDING" {
if (!p.status) return "CURRENT";
if (p.status === "ARCHIVED") return "ARCHIVED";
if (p.status === "PENDING") return "PENDING";
return "CURRENT";
}

export default function PlaybookDetailPage({
params,
}: {
Expand Down
8 changes: 1 addition & 7 deletions plugin/dashboard/app/playbooks/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,9 @@ import {
import { reflexio } from "@/lib/reflexio-client";
import { useSettings } from "@/hooks/use-settings";
import { formatRelative } from "@/lib/format";
import { statusLabel } from "@/lib/status";
import type { UserPlaybook } from "@/lib/types";

function statusLabel(p: UserPlaybook): "CURRENT" | "ARCHIVED" | "PENDING" {
if (!p.status) return "CURRENT";
if (p.status === "ARCHIVED") return "ARCHIVED";
if (p.status === "PENDING") return "PENDING";
return "CURRENT";
}

export default function PlaybooksPage() {
const { reflexioUrl } = useSettings();
const [playbooks, setPlaybooks] = useState<UserPlaybook[] | null>(null);
Expand Down
8 changes: 1 addition & 7 deletions plugin/dashboard/app/profiles/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,9 @@ import { reflexio } from "@/lib/reflexio-client";
import { useSettings } from "@/hooks/use-settings";
import { formatTimestamp, truncateId } from "@/lib/format";
import { cn } from "@/lib/utils";
import { statusLabel as status } from "@/lib/status";
import type { UserProfile } from "@/lib/types";

function status(p: UserProfile): "CURRENT" | "ARCHIVED" | "PENDING" {
if (!p.status) return "CURRENT";
if (p.status === "ARCHIVED") return "ARCHIVED";
if (p.status === "PENDING") return "PENDING";
return "CURRENT";
}

export default function ProfileDetailPage({
params,
}: {
Expand Down
14 changes: 14 additions & 0 deletions plugin/dashboard/lib/status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Wire status comes from Python's Status StrEnum: lowercase strings
// ("archived", "pending"). CURRENT rows are omitted from JSON entirely
// (response_model_exclude_none) and arrive as `null`/undefined.
// Normalize to the uppercase label the dashboard renders.

export type StatusLabel = "CURRENT" | "ARCHIVED" | "PENDING";

export function statusLabel(p: { status?: string | null }): StatusLabel {
if (!p.status) return "CURRENT";
const s = String(p.status).toLowerCase();
if (s === "archived") return "ARCHIVED";
if (s === "pending") return "PENDING";
return "CURRENT";
}
8 changes: 6 additions & 2 deletions plugin/dashboard/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@ export type UserActionType =
| "PRAISE"
| "STOP";

export type PlaybookStatus = "PENDING" | "CURRENT" | "ARCHIVED";
// Wire format from the reflexio API. CURRENT rows arrive as `status: null`
// (response_model_exclude_none strips it from the JSON entirely), so the type
// only enumerates the non-null values. The values are lowercase because they
// come from Python's Status StrEnum.
export type PlaybookStatus = "pending" | "archived";

export type ProfileStatus = "CURRENT" | "ARCHIVED" | "PENDING";
export type ProfileStatus = "pending" | "archived";

export interface ToolUsed {
tool_name: string;
Expand Down
4 changes: 2 additions & 2 deletions plugin/scripts/backend-service.sh
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,8 @@ case "$CMD" in
# claude-smart users. Reflexio's library defaults are much higher
# (250k/50k) for server deployments; here we override only in the
# claude-smart plugin context. Users can still override via env.
export INTERACTION_CLEANUP_THRESHOLD="${INTERACTION_CLEANUP_THRESHOLD:-1000}"
export INTERACTION_CLEANUP_DELETE_COUNT="${INTERACTION_CLEANUP_DELETE_COUNT:-500}"
export INTERACTION_CLEANUP_THRESHOLD="${INTERACTION_CLEANUP_THRESHOLD:-500}"
export INTERACTION_CLEANUP_DELETE_COUNT="${INTERACTION_CLEANUP_DELETE_COUNT:-200}"

# --no-reload: uvicorn's reloader forks a supervisor; makes PGID
# bookkeeping harder and we don't need hot-reload for a user-facing
Expand Down
61 changes: 56 additions & 5 deletions plugin/src/claude_smart/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@

_TOOL_DATA_FIELD_MAX_LEN = 256

_VALID_CITATION_KINDS = frozenset({"playbook", "profile"})


def _truncate_tool_data_field(value: Any) -> Any:
"""Truncate a single tool_data field value to ``_TOOL_DATA_FIELD_MAX_LEN``.
Expand Down Expand Up @@ -166,6 +168,50 @@ def read_all(session_id: str) -> list[dict[str, Any]]:
return records


def _to_wire_citations(cited_items: Any) -> list[dict[str, str]]:
"""Map local ``cited_items`` to the wire ``Citation`` shape.

Local entries (from ``events.stop._resolve_cited_items``) carry
``{id, kind, title, real_id}``; reflexio's ``InteractionData.citations``
wants ``{kind, real_id, tag, title}`` where ``tag`` is the rank id
(``r1-301``-style) we already keep under ``id``. Entries without a
``real_id`` (unresolved injections) are dropped — the server can't
join them back to a stored row.

Args:
cited_items (Any): The list-of-dicts blob attached to an Assistant
turn record, or ``None`` when the turn cited nothing.

Returns:
list[dict[str, str]]: Citation dicts ready to be folded into an
``InteractionData`` payload. Empty when ``cited_items`` is
missing, malformed, or contains nothing resolvable.
"""
if not isinstance(cited_items, list):
return []
out: list[dict[str, str]] = []
for item in cited_items:
if not isinstance(item, dict):
continue
real_id = item.get("real_id")
kind = item.get("kind")
if not isinstance(real_id, str) or not real_id:
continue
if kind not in _VALID_CITATION_KINDS:
continue
tag = item.get("id")
title = item.get("title")
out.append(
{
"kind": kind,
"real_id": real_id,
"tag": tag if isinstance(tag, str) else "",
"title": title if isinstance(title, str) else "",
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)
return out


def unpublished_slice(
records: Iterable[dict[str, Any]],
) -> tuple[int, list[dict[str, Any]]]:
Expand Down Expand Up @@ -212,14 +258,19 @@ def unpublished_slice(
pending_tools.append(tool_entry)
continue
if role in {"User", "Assistant"}:
# ``cited_items`` is local-only metadata for the dashboard's
# "used" badge; reflexio's InteractionData has no slot for it.
# ``cited_items`` is local-only metadata (dashboard "used" badge);
# map it onto the wire's ``citations`` field — reflexio uses those
# to drive playbook/profile reflection in the publish flow.
turn = {
k: v for k, v in rec.items() if k not in {"role", "ts", "cited_items"}
}
turn["role"] = role
if role == "Assistant" and pending_tools:
turn["tools_used"] = pending_tools
pending_tools = []
if role == "Assistant":
citations = _to_wire_citations(rec.get("cited_items"))
if citations:
turn["citations"] = citations
if pending_tools:
turn["tools_used"] = pending_tools
pending_tools = []
turns.append(turn)
return published, turns
2 changes: 1 addition & 1 deletion reflexio
Submodule reflexio updated 26 files
+1 −1 pyproject.toml
+2 −0 reflexio/__init__.py
+52 −0 reflexio/lib/_reflection.py
+2 −0 reflexio/lib/reflexio_lib.py
+29 −0 reflexio/models/api_schema/domain/entities.py
+25 −1 reflexio/models/config_schema.py
+53 −0 reflexio/server/prompt/prompt_bank/memory_reflection/v1.0.0.prompt.md
+42 −0 reflexio/server/services/generation_service.py
+17 −0 reflexio/server/services/reflection/__init__.py
+183 −0 reflexio/server/services/reflection/reflection_extractor.py
+463 −0 reflexio/server/services/reflection/reflection_service.py
+115 −0 reflexio/server/services/reflection/reflection_service_utils.py
+53 −1 reflexio/server/services/storage/disk_storage/_playbook.py
+59 −1 reflexio/server/services/storage/disk_storage/_profiles.py
+15 −0 reflexio/server/services/storage/sqlite_storage/_base.py
+29 −0 reflexio/server/services/storage/sqlite_storage/_playbook.py
+37 −4 reflexio/server/services/storage/sqlite_storage/_profiles.py
+46 −0 reflexio/server/services/storage/storage_base/_playbook.py
+45 −0 reflexio/server/services/storage/storage_base/_profiles.py
+0 −0 tests/server/services/reflection/__init__.py
+114 −0 tests/server/services/reflection/test_generation_service_wiring.py
+664 −0 tests/server/services/reflection/test_reflection_service.py
+246 −0 tests/server/services/storage/test_disk_storage_reflection_methods.py
+106 −0 tests/server/services/storage/test_storage_contract_playbook.py
+134 −0 tests/server/services/storage/test_storage_contract_profiles.py
+1 −1 uv.lock
109 changes: 108 additions & 1 deletion tests/test_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ def test_unpublished_slice_includes_truncated_tool_output() -> None:


def test_unpublished_slice_strips_cited_items(session_dir) -> None:
"""``cited_items`` is dashboard-only metadata; reflexio must not receive it."""
"""``cited_items`` is local-only — never lands on the wire under that key."""
records = [
{"role": "User", "content": "hi"},
{
Expand All @@ -293,6 +293,113 @@ def test_unpublished_slice_strips_cited_items(session_dir) -> None:
assert "cited_items" not in turns[-1]


def test_unpublished_slice_maps_cited_items_to_citations() -> None:
"""Resolved cited_items become a wire-shaped ``citations`` list on the turn."""
records = [
{"role": "User", "content": "hi"},
{
"role": "Assistant",
"content": "ok",
"cited_items": [
{
"id": "r1-ab12",
"kind": "playbook",
"title": "rule X",
"real_id": "pb_42",
},
{
"id": "p1-cd34",
"kind": "profile",
"title": "user role",
"real_id": "prof_7",
},
],
},
]
_, turns = state.unpublished_slice(records)
assert turns[-1]["citations"] == [
{"kind": "playbook", "real_id": "pb_42", "tag": "r1-ab12", "title": "rule X"},
{
"kind": "profile",
"real_id": "prof_7",
"tag": "p1-cd34",
"title": "user role",
},
]


def test_unpublished_slice_omits_citations_when_empty() -> None:
"""Empty / unresolvable cited_items → no ``citations`` key on the turn.

Producing a key with ``[]`` would inflate every published Assistant
record; absence is meaningful.
"""
records = [
{"role": "User", "content": "hi"},
{
"role": "Assistant",
"content": "no real_id",
"cited_items": [{"id": "r1-ab12", "kind": "playbook", "title": "t"}],
},
{
"role": "Assistant",
"content": "empty list",
"cited_items": [],
},
{
"role": "Assistant",
"content": "no cited_items at all",
},
]
_, turns = state.unpublished_slice(records)
for turn in turns[1:]:
assert "citations" not in turn


def test_to_wire_citations_filters_invalid_kinds() -> None:
"""Items with unknown ``kind`` are dropped (server has a Literal there)."""
items = [
{"id": "r1-ab12", "kind": "playbook", "title": "ok", "real_id": "pb_1"},
{"id": "x1-0001", "kind": "agent_playbook", "title": "junk", "real_id": "ap_1"},
{"id": "y1-0002", "kind": "", "title": "junk2", "real_id": "z_1"},
]
result = state._to_wire_citations(items)
assert [c["kind"] for c in result] == ["playbook"]
assert result[0]["real_id"] == "pb_1"


def test_to_wire_citations_drops_unresolved_real_id() -> None:
"""Entries without ``real_id`` (unresolved injections) cannot round-trip."""
items = [
{"id": "r1-ab12", "kind": "playbook", "title": "no real_id"},
{"id": "p1-cd34", "kind": "profile", "title": "empty", "real_id": ""},
{"id": "r1-9999", "kind": "playbook", "title": "ok", "real_id": "pb_9"},
]
result = state._to_wire_citations(items)
assert len(result) == 1
assert result[0]["real_id"] == "pb_9"


def test_to_wire_citations_handles_non_list_input() -> None:
"""None / dict / str inputs return ``[]`` without raising."""
assert state._to_wire_citations(None) == []
assert state._to_wire_citations({"id": "r1-ab12"}) == []
assert state._to_wire_citations("oops") == []


def test_to_wire_citations_skips_non_dict_items() -> None:
"""A list that mixes dicts and junk only emits the dict entries."""
items = [
"a-string",
None,
42,
{"id": "r1-ab12", "kind": "playbook", "title": "ok", "real_id": "pb_1"},
]
result = state._to_wire_citations(items)
assert len(result) == 1
assert result[0]["tag"] == "r1-ab12"


def _append_worker(state_dir: str, session_id: str, payload: str) -> None:
# Child processes inherit env after fork, so CLAUDE_SMART_STATE_DIR is
# already set. Belt-and-suspenders: reassert it.
Expand Down
Loading