207 lines
6.4 KiB
Python
207 lines
6.4 KiB
Python
"""Note agent — Markdown note management (list, get, create, update, propose edit)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import re
|
|
from typing import Any
|
|
|
|
from langchain_core.tools import tool
|
|
|
|
from app.core.note_summarizer import generate_note_summary
|
|
from app.core.ws_context import execute_on_client
|
|
|
|
_UUID_RE = re.compile(
|
|
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$"
|
|
)
|
|
|
|
|
|
def _is_uuid(value: str) -> bool:
|
|
return bool(_UUID_RE.match(value))
|
|
|
|
|
|
def _fmt_summary(row: dict) -> str:
|
|
summary = (row.get("aiSummary") or row.get("ai_summary") or "").strip()
|
|
if summary:
|
|
return f" — {summary}"
|
|
snippet = (row.get("content") or "")[:120].replace("\n", " ").strip()
|
|
return f" — {snippet}" if snippet else ""
|
|
|
|
|
|
@tool
|
|
async def list_notes(project_id: str = "") -> str:
|
|
"""List notes with AI summaries, optionally scoped to a project by project_id.
|
|
|
|
Returns id, title, and ai_summary for each note so you can decide which
|
|
note to read in full with get_note before creating or updating.
|
|
"""
|
|
normalized_project_id = project_id if (project_id and _is_uuid(project_id)) else ""
|
|
result = await execute_on_client(
|
|
action="select",
|
|
table="notes",
|
|
filters={"projectId": normalized_project_id or None},
|
|
)
|
|
rows = result.get("rows", [])
|
|
if not rows:
|
|
return "No notes found."
|
|
lines = [f" - [{r['id']}] {r['title']}{_fmt_summary(r)}" for r in rows]
|
|
return f"Found {len(rows)} note(s):\n" + "\n".join(lines)
|
|
|
|
|
|
@tool
|
|
async def get_note(note_id: str) -> str:
|
|
"""Fetch a single note by its UUID to read its full Markdown content."""
|
|
result = await execute_on_client(action="get", table="notes", data={"id": note_id})
|
|
row = result.get("row")
|
|
if not row:
|
|
return f"Note {note_id} not found."
|
|
return f"Note '{row['title']}' (id: {row['id']}):\n\n{row['content']}"
|
|
|
|
|
|
@tool
|
|
async def create_note(
|
|
title: str,
|
|
content: str,
|
|
project_id: str = "",
|
|
) -> str:
|
|
"""Create a new note.
|
|
title: note heading (required)
|
|
content: Markdown body text (required)
|
|
project_id: optional UUID linking this note to a project
|
|
"""
|
|
result = await execute_on_client(
|
|
action="insert",
|
|
table="notes",
|
|
data={
|
|
"title": title,
|
|
"content": content,
|
|
"projectId": project_id or None,
|
|
},
|
|
)
|
|
row = result["row"]
|
|
note_id: str = row["id"]
|
|
# Generate summary asynchronously — fire-and-forget.
|
|
asyncio.create_task(_refresh_summary(note_id, title, content))
|
|
return f"Note created: '{row['title']}' (id: {note_id})."
|
|
|
|
|
|
@tool
|
|
async def update_note(
|
|
note_id: str,
|
|
title: str = "",
|
|
content: str = "",
|
|
) -> str:
|
|
"""Update an existing note directly (no approval required).
|
|
Use propose_note_edit instead when human review is needed.
|
|
note_id: UUID of the note (required)
|
|
If you need to preserve existing content, call get_note first.
|
|
"""
|
|
updates: dict[str, Any] = {}
|
|
if title:
|
|
updates["title"] = title
|
|
if content:
|
|
updates["content"] = content
|
|
result = await execute_on_client(
|
|
action="update",
|
|
table="notes",
|
|
data={"id": note_id, "updates": updates},
|
|
)
|
|
row = result["row"]
|
|
if content:
|
|
new_title = title or row.get("title", "")
|
|
asyncio.create_task(_refresh_summary(note_id, new_title, content))
|
|
return f"Note updated: '{row['title']}' (id: {row['id']})."
|
|
|
|
|
|
@tool
|
|
async def propose_note_edit(
|
|
note_id: str,
|
|
edit_type: str,
|
|
proposed_content: str,
|
|
reasoning: str = "",
|
|
anchor_before: str = "",
|
|
anchor_text: str = "",
|
|
agent_id: str = "",
|
|
run_id: str = "",
|
|
) -> str:
|
|
"""Propose an AI edit to an existing note, pending human approval.
|
|
|
|
Use this instead of update_note when review_required is true.
|
|
The user will see the proposal highlighted before it is merged.
|
|
|
|
note_id: UUID of the target note (required)
|
|
edit_type: 'append' | 'insert' | 'replace'
|
|
- append: adds proposed_content at the end of the note
|
|
- insert: inserts proposed_content immediately after anchor_before text
|
|
- replace: replaces the first occurrence of anchor_text with proposed_content
|
|
proposed_content: the new Markdown text to add or substitute (required)
|
|
reasoning: brief explanation shown to the user (recommended)
|
|
anchor_before: for 'insert' — the text snippet that precedes the insertion point
|
|
anchor_text: for 'replace' — the exact text to be replaced
|
|
agent_id: agent identifier (for traceability)
|
|
run_id: run identifier (for traceability)
|
|
"""
|
|
if edit_type not in ("append", "insert", "replace"):
|
|
return f"Invalid edit_type '{edit_type}'. Use 'append', 'insert', or 'replace'."
|
|
|
|
result = await execute_on_client(
|
|
action="propose_note_edit",
|
|
data={
|
|
"noteId": note_id,
|
|
"type": edit_type,
|
|
"proposedContent": proposed_content,
|
|
"reasoning": reasoning or None,
|
|
"anchorBefore": anchor_before or None,
|
|
"anchorText": anchor_text or None,
|
|
"agentId": agent_id or None,
|
|
"runId": run_id or None,
|
|
},
|
|
)
|
|
edit_id = result.get("id", "?")
|
|
return (
|
|
f"Edit proposal created (id: {edit_id}) for note {note_id}. "
|
|
f"Status: pending user approval."
|
|
)
|
|
|
|
|
|
@tool
|
|
async def delete_note(note_id: str) -> str:
|
|
"""Delete a note permanently by its UUID."""
|
|
await execute_on_client(action="delete", table="notes", data={"id": note_id})
|
|
return f"Note {note_id} deleted."
|
|
|
|
|
|
async def _refresh_summary(note_id: str, title: str, content: str) -> None:
|
|
"""Generate and persist the AI summary for a note. Fire-and-forget."""
|
|
try:
|
|
summary = await generate_note_summary(title, content)
|
|
if summary:
|
|
await execute_on_client(
|
|
action="update",
|
|
table="notes",
|
|
data={
|
|
"id": note_id,
|
|
"updates": {
|
|
"aiSummary": summary,
|
|
"aiSummaryUpdatedAt": int(__import__("time").time() * 1000),
|
|
},
|
|
},
|
|
)
|
|
except Exception:
|
|
pass # fire-and-forget; errors logged by generate_note_summary
|
|
|
|
|
|
NOTE_TOOLS: list[Any] = [
|
|
list_notes,
|
|
get_note,
|
|
create_note,
|
|
update_note,
|
|
propose_note_edit,
|
|
delete_note,
|
|
]
|
|
|
|
NOTE_READ_TOOLS: list[Any] = [
|
|
list_notes,
|
|
get_note,
|
|
]
|