Compare commits
4
Commits
2c929a0435
...
fb1ae915e4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb1ae915e4 | ||
|
|
c2b2694ea3 | ||
|
|
7b5a75989a | ||
|
|
1babe59843 |
@@ -78,6 +78,13 @@ descendants) to the trash and returns a deleted_batch_id. Use list_trash() to
|
||||
see trashed batches, restore(deleted_batch_id) to undo a deletion, and
|
||||
purge_trash(deleted_batch_id, confirmed=True) for a permanent delete. Trash
|
||||
auto-purges after the operator's retention window.
|
||||
|
||||
Scribe stores reusable Processes — saved prompts/workflows (note_type
|
||||
"process"), e.g. a drift audit or a DRY pass. When the operator says "run the
|
||||
X process" or otherwise references a saved process, call list_processes() /
|
||||
get_process(name) and follow the returned prompt verbatim, including any
|
||||
"clarify first" steps it contains. Author a new one with create_process(title,
|
||||
body); edit with update_process.
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ to a FastMCP instance. `register_all(mcp)` is the single entry point called
|
||||
from `mcp.server.build_mcp_server`.
|
||||
"""
|
||||
from fabledassistant.mcp.tools import (
|
||||
entities, events, milestones, notes, projects, recent, rulebooks, search, tags, tasks, trash,
|
||||
entities, events, milestones, notes, processes, projects, recent, rulebooks, search, tags, tasks, trash,
|
||||
)
|
||||
|
||||
|
||||
@@ -20,5 +20,6 @@ def register_all(mcp) -> None:
|
||||
tags.register(mcp)
|
||||
recent.register(mcp)
|
||||
entities.register(mcp)
|
||||
processes.register(mcp)
|
||||
rulebooks.register(mcp)
|
||||
trash.register(mcp)
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Stored-process MCP tools: reusable saved prompts (note_type='process').
|
||||
|
||||
A process is a Note whose body is a prompt the operator fires later
|
||||
("run the X process"). Mirrors entities.py — the tools wrap notes_svc directly.
|
||||
get_process is the fire mechanism: it returns the full prompt for Claude to run.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fabledassistant.mcp._context import current_user_id
|
||||
from fabledassistant.services import knowledge as knowledge_svc
|
||||
from fabledassistant.services import notes as notes_svc
|
||||
|
||||
|
||||
async def list_processes(q: str = "", tag: str = "", limit: int = 50) -> dict:
|
||||
"""List stored processes (reusable saved prompts).
|
||||
|
||||
Args:
|
||||
q: Free-text search across title + body (optional).
|
||||
tag: Filter to a single tag (optional).
|
||||
limit: Max results (1-100).
|
||||
|
||||
Returns {"processes": [{id, title, tags, preview}], "total": int}.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
items, total = await knowledge_svc.query_knowledge(
|
||||
user_id=uid, note_type="process", tags=[tag] if tag else [],
|
||||
sort="modified", q=q or None, limit=max(1, min(limit, 100)), offset=0,
|
||||
)
|
||||
procs = [{"id": it["id"], "title": it["title"], "tags": it.get("tags", []),
|
||||
"preview": it.get("snippet", "")} for it in items]
|
||||
return {"processes": procs, "total": total}
|
||||
|
||||
|
||||
async def create_process(title: str, body: str, tags: list[str] | None = None) -> dict:
|
||||
"""Create a stored process (a reusable saved prompt).
|
||||
|
||||
Args:
|
||||
title: Process name, e.g. "Drift Audit" (required).
|
||||
body: The full prompt to run later (markdown). Required.
|
||||
tags: Plain-string tags, no # prefix.
|
||||
"""
|
||||
if not (title or "").strip() or not (body or "").strip():
|
||||
raise ValueError("create_process requires a non-empty title and body")
|
||||
uid = current_user_id()
|
||||
note = await notes_svc.create_note(
|
||||
uid, title=title.strip(), body=body, note_type="process", tags=tags,
|
||||
)
|
||||
return note.to_dict()
|
||||
|
||||
|
||||
async def get_process(name_or_id: str) -> dict:
|
||||
"""Fetch a stored process by name or id and return its full prompt — the
|
||||
fire mechanism. The operator says "run the <name> process"; call this and
|
||||
follow the returned body (including any 'clarify first' steps it contains).
|
||||
|
||||
Resolution: numeric id → exact (case-insensitive) title → substring. On an
|
||||
ambiguous substring match, the best (most-recent) match is returned with an
|
||||
`other_matches` list so you can disambiguate with the operator.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
note, candidates = await notes_svc.resolve_process(uid, name_or_id)
|
||||
if note is None:
|
||||
raise ValueError(f"process {name_or_id!r} not found")
|
||||
out = note.to_dict()
|
||||
if candidates:
|
||||
out["other_matches"] = candidates
|
||||
return out
|
||||
|
||||
|
||||
async def update_process(process_id: int, title: str = "", body: str = "",
|
||||
tags: list[str] | None = None) -> dict:
|
||||
"""Update a stored process. Only provided fields change — empty title/body
|
||||
leave that field unchanged; pass tags to replace the tag set."""
|
||||
uid = current_user_id()
|
||||
note = await notes_svc.get_note(uid, process_id)
|
||||
if note is None or note.note_type != "process":
|
||||
raise ValueError(f"process {process_id} not found")
|
||||
fields: dict = {}
|
||||
if title.strip():
|
||||
fields["title"] = title.strip()
|
||||
if body.strip():
|
||||
fields["body"] = body
|
||||
if tags is not None:
|
||||
fields["tags"] = tags
|
||||
updated = await notes_svc.update_note(uid, process_id, **fields)
|
||||
return updated.to_dict()
|
||||
|
||||
|
||||
def register(mcp) -> None:
|
||||
for fn in (list_processes, create_process, get_process, update_process):
|
||||
mcp.tool(name=fn.__name__)(fn)
|
||||
@@ -10,7 +10,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
knowledge_bp = Blueprint("knowledge", __name__, url_prefix="/api/knowledge")
|
||||
|
||||
_VALID_TYPES = {"note", "person", "place", "list", "task", "plan"}
|
||||
_VALID_TYPES = {"note", "person", "place", "list", "task", "plan", "process"}
|
||||
_VALID_SORTS = {"modified", "created", "alpha", "type"}
|
||||
|
||||
|
||||
|
||||
@@ -237,7 +237,7 @@ async def get_knowledge_counts(user_id: int, tags: list[str] | None = None) -> d
|
||||
.where(Note.user_id == user_id)
|
||||
.where(Note.status.is_(None))
|
||||
.where(Note.deleted_at.is_(None))
|
||||
.where(Note.note_type.in_(["note", "person", "place", "list"]))
|
||||
.where(Note.note_type.in_(["note", "person", "place", "list", "process"]))
|
||||
.group_by(Note.note_type)
|
||||
)
|
||||
if tags:
|
||||
@@ -273,9 +273,9 @@ async def get_knowledge_counts(user_id: int, tags: list[str] | None = None) -> d
|
||||
plan_stmt = plan_stmt.where(Note.tags.contains([tag]))
|
||||
counts["plan"] = (await session.execute(plan_stmt)).scalar_one()
|
||||
|
||||
for t in ("note", "person", "place", "list", "task", "plan"):
|
||||
for t in ("note", "person", "place", "list", "task", "plan", "process"):
|
||||
counts.setdefault(t, 0)
|
||||
counts["total"] = sum(counts[t] for t in ("note", "person", "place", "list", "task"))
|
||||
counts["total"] = sum(counts[t] for t in ("note", "person", "place", "list", "task", "process"))
|
||||
return counts
|
||||
|
||||
|
||||
|
||||
@@ -409,6 +409,39 @@ async def convert_task_to_note(user_id: int, note_id: int) -> Note:
|
||||
return note
|
||||
|
||||
|
||||
async def resolve_process(user_id: int, name_or_id) -> tuple[Note | None, list[dict]]:
|
||||
"""Resolve a stored process by id or name.
|
||||
|
||||
Owner-scoped, note_type='process', non-trashed. Precedence: numeric id →
|
||||
exact case-insensitive title → substring. Returns (note, other_candidates);
|
||||
on a substring tie with no exact hit, `note` is the most-recently-updated
|
||||
match and `other_candidates` lists the rest as [{id, title}] so the caller
|
||||
can disambiguate. Returns (None, []) when nothing matches.
|
||||
"""
|
||||
async with async_session() as session:
|
||||
base = select(Note).where(
|
||||
Note.user_id == user_id,
|
||||
Note.note_type == "process",
|
||||
Note.deleted_at.is_(None),
|
||||
)
|
||||
s = str(name_or_id).strip()
|
||||
if s.isdigit():
|
||||
row = (await session.execute(base.where(Note.id == int(s)))).scalars().first()
|
||||
if row is not None:
|
||||
return row, []
|
||||
exact = (await session.execute(
|
||||
base.where(func.lower(Note.title) == s.lower()).order_by(Note.updated_at.desc())
|
||||
)).scalars().first()
|
||||
if exact is not None:
|
||||
return exact, []
|
||||
matches = (await session.execute(
|
||||
base.where(Note.title.ilike(f"%{s}%")).order_by(Note.updated_at.desc())
|
||||
)).scalars().all()
|
||||
if not matches:
|
||||
return None, []
|
||||
return matches[0], [{"id": n.id, "title": n.title} for n in matches[1:]]
|
||||
|
||||
|
||||
async def get_notes_by_ids(user_id: int, note_ids: list[int]) -> dict[int, Note]:
|
||||
"""Batch fetch notes by ID list. Returns {note_id: Note}."""
|
||||
if not note_ids:
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Tests for MCP process tools — patches the service layer."""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from fabledassistant.mcp._context import _user_id_ctx
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _bind_user():
|
||||
token = _user_id_ctx.set(7)
|
||||
yield
|
||||
_user_id_ctx.reset(token)
|
||||
|
||||
|
||||
def _fake_note(id=1, title="Drift Audit", note_type="process"):
|
||||
n = MagicMock()
|
||||
n.id = id
|
||||
n.title = title
|
||||
n.note_type = note_type
|
||||
n.to_dict.return_value = {"id": id, "title": title, "note_type": note_type}
|
||||
return n
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_process_requires_title_and_body():
|
||||
from fabledassistant.mcp.tools.processes import create_process
|
||||
with pytest.raises(ValueError):
|
||||
await create_process(title="", body="something")
|
||||
with pytest.raises(ValueError):
|
||||
await create_process(title="X", body=" ")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_process_sets_note_type():
|
||||
created = _fake_note()
|
||||
with patch("fabledassistant.services.notes.create_note",
|
||||
AsyncMock(return_value=created)) as mock_create:
|
||||
from fabledassistant.mcp.tools.processes import create_process
|
||||
out = await create_process(title="Drift Audit", body="the prompt", tags=["audit"])
|
||||
assert out["note_type"] == "process"
|
||||
# the service was asked to create a process
|
||||
assert mock_create.await_args.kwargs["note_type"] == "process"
|
||||
assert mock_create.await_args.kwargs["title"] == "Drift Audit"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_process_returns_body_and_candidates():
|
||||
note = _fake_note(id=7)
|
||||
with patch("fabledassistant.services.notes.resolve_process",
|
||||
AsyncMock(return_value=(note, [{"id": 9, "title": "Drift Audit Notes"}]))):
|
||||
from fabledassistant.mcp.tools.processes import get_process
|
||||
out = await get_process("drift")
|
||||
assert out["id"] == 7
|
||||
assert out["other_matches"] == [{"id": 9, "title": "Drift Audit Notes"}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_process_not_found_raises():
|
||||
with patch("fabledassistant.services.notes.resolve_process",
|
||||
AsyncMock(return_value=(None, []))):
|
||||
from fabledassistant.mcp.tools.processes import get_process
|
||||
with pytest.raises(ValueError):
|
||||
await get_process("missing")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_process_rejects_non_process_note():
|
||||
plain = _fake_note(id=3, note_type="note")
|
||||
with patch("fabledassistant.services.notes.get_note",
|
||||
AsyncMock(return_value=plain)):
|
||||
from fabledassistant.mcp.tools.processes import update_process
|
||||
with pytest.raises(ValueError):
|
||||
await update_process(process_id=3, title="x")
|
||||
|
||||
|
||||
def test_register_attaches_four_tools():
|
||||
from fabledassistant.mcp.tools import processes
|
||||
names: list[str] = []
|
||||
|
||||
class FakeMcp:
|
||||
def tool(self, name):
|
||||
names.append(name)
|
||||
def deco(fn):
|
||||
return fn
|
||||
return deco
|
||||
|
||||
processes.register(FakeMcp())
|
||||
assert set(names) == {
|
||||
"list_processes", "create_process", "get_process", "update_process",
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"""get_knowledge_counts includes the 'process' type and counts it in total."""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _make_mock_session():
|
||||
s = AsyncMock()
|
||||
s.__aenter__ = AsyncMock(return_value=s)
|
||||
s.__aexit__ = AsyncMock(return_value=False)
|
||||
return s
|
||||
|
||||
|
||||
def _grouped(rows):
|
||||
r = MagicMock()
|
||||
r.all.return_value = rows
|
||||
return r
|
||||
|
||||
|
||||
def _scalar(n):
|
||||
r = MagicMock()
|
||||
r.scalar_one.return_value = n
|
||||
return r
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_counts_include_process_in_facet_and_total():
|
||||
session = _make_mock_session()
|
||||
# 1) grouped non-task counts, 2) task count, 3) plan count
|
||||
session.execute = AsyncMock(side_effect=[
|
||||
_grouped([("note", 3), ("process", 2)]),
|
||||
_scalar(1), # tasks
|
||||
_scalar(0), # plans
|
||||
])
|
||||
with patch("fabledassistant.services.knowledge.async_session") as cls:
|
||||
cls.return_value = session
|
||||
from fabledassistant.services.knowledge import get_knowledge_counts
|
||||
counts = await get_knowledge_counts(user_id=1)
|
||||
|
||||
assert counts["process"] == 2
|
||||
# facet keys all present (setdefault)
|
||||
for key in ("note", "person", "place", "list", "task", "plan", "process"):
|
||||
assert key in counts
|
||||
# total = note(3) + person(0) + place(0) + list(0) + task(1) + process(2)
|
||||
assert counts["total"] == 6
|
||||
@@ -0,0 +1,87 @@
|
||||
"""resolve_process precedence (id → exact title → substring) in services/notes.py.
|
||||
|
||||
Mocks async_session — no real DB, matching the other notes-service tests.
|
||||
"""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _make_mock_session():
|
||||
s = AsyncMock()
|
||||
s.__aenter__ = AsyncMock(return_value=s)
|
||||
s.__aexit__ = AsyncMock(return_value=False)
|
||||
return s
|
||||
|
||||
|
||||
def _result(first=None, all_=None):
|
||||
"""A SQLAlchemy-result mock exposing .scalars().first()/.all()."""
|
||||
r = MagicMock()
|
||||
r.scalars.return_value.first.return_value = first
|
||||
r.scalars.return_value.all.return_value = all_ or []
|
||||
return r
|
||||
|
||||
|
||||
def _note(id, title):
|
||||
n = MagicMock()
|
||||
n.id = id
|
||||
n.title = title
|
||||
return n
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_process_by_numeric_id():
|
||||
note = _note(5, "Drift Audit")
|
||||
session = _make_mock_session()
|
||||
# numeric id → first execute (id lookup) hits
|
||||
session.execute = AsyncMock(side_effect=[_result(first=note)])
|
||||
with patch("fabledassistant.services.notes.async_session") as cls:
|
||||
cls.return_value = session
|
||||
from fabledassistant.services.notes import resolve_process
|
||||
found, candidates = await resolve_process(1, "5")
|
||||
assert found is note
|
||||
assert candidates == []
|
||||
assert session.execute.await_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_process_exact_title_beats_substring():
|
||||
note = _note(7, "Drift Audit")
|
||||
session = _make_mock_session()
|
||||
# non-digit → exact-title query (first execute) hits; substring never runs
|
||||
session.execute = AsyncMock(side_effect=[_result(first=note)])
|
||||
with patch("fabledassistant.services.notes.async_session") as cls:
|
||||
cls.return_value = session
|
||||
from fabledassistant.services.notes import resolve_process
|
||||
found, candidates = await resolve_process(1, "Drift Audit")
|
||||
assert found is note
|
||||
assert candidates == []
|
||||
assert session.execute.await_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_process_substring_returns_candidates():
|
||||
n1 = _note(7, "Drift Audit Remediation")
|
||||
n2 = _note(9, "Drift Audit Notes")
|
||||
session = _make_mock_session()
|
||||
# exact miss, then substring returns two (most-recent first)
|
||||
session.execute = AsyncMock(side_effect=[_result(first=None), _result(all_=[n1, n2])])
|
||||
with patch("fabledassistant.services.notes.async_session") as cls:
|
||||
cls.return_value = session
|
||||
from fabledassistant.services.notes import resolve_process
|
||||
found, candidates = await resolve_process(1, "drift")
|
||||
assert found is n1
|
||||
assert candidates == [{"id": 9, "title": "Drift Audit Notes"}]
|
||||
assert session.execute.await_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_process_no_match():
|
||||
session = _make_mock_session()
|
||||
session.execute = AsyncMock(side_effect=[_result(first=None), _result(all_=[])])
|
||||
with patch("fabledassistant.services.notes.async_session") as cls:
|
||||
cls.return_value = session
|
||||
from fabledassistant.services.notes import resolve_process
|
||||
found, candidates = await resolve_process(1, "nope")
|
||||
assert found is None
|
||||
assert candidates == []
|
||||
Reference in New Issue
Block a user