diff --git a/app/agents/note_agent.py b/app/agents/note_agent.py index 19a690a..4cf75fb 100644 --- a/app/agents/note_agent.py +++ b/app/agents/note_agent.py @@ -1,13 +1,14 @@ -"""Note agent — Markdown note management (list, get, create, update, delete).""" +"""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.llm import embed +from app.core.note_summarizer import generate_note_summary from app.core.ws_context import execute_on_client _UUID_RE = re.compile( @@ -19,9 +20,21 @@ 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, optionally scoped to a project by project_id.""" + """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", @@ -31,7 +44,7 @@ async def list_notes(project_id: str = "") -> str: rows = result.get("rows", []) if not rows: return "No notes found." - lines = [f"- {r['title']} (id: {r['id']})" for r in rows] + lines = [f" - [{r['id']}] {r['title']}{_fmt_summary(r)}" for r in rows] return f"Found {len(rows)} note(s):\n" + "\n".join(lines) @@ -66,14 +79,10 @@ async def create_note( }, ) row = result["row"] - # Index the note content in the vector store. - vector = await embed(content) - await execute_on_client( - action="vector_upsert", - data={"id": row["id"], "projectId": row.get("projectId"), "content": content}, - vector=vector, - ) - return f"Note created: '{row['title']}' (id: {row['id']})." + 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 @@ -82,7 +91,8 @@ async def update_note( title: str = "", content: str = "", ) -> str: - """Update an existing note. Only pass fields that should change. + """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. """ @@ -97,17 +107,63 @@ async def update_note( data={"id": note_id, "updates": updates}, ) row = result["row"] - # Re-index if content changed. if content: - vector = await embed(content) - await execute_on_client( - action="vector_upsert", - data={"id": note_id, "projectId": row.get("projectId"), "content": content}, - vector=vector, - ) + 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.""" @@ -115,11 +171,32 @@ async def delete_note(note_id: str) -> str: 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, ] diff --git a/app/api/routes/agents.py b/app/api/routes/agents.py index f170c82..4bc2eed 100644 --- a/app/api/routes/agents.py +++ b/app/api/routes/agents.py @@ -20,10 +20,13 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel + from app.api.deps import get_current_user from app.billing.tier_manager import FEATURES from app.core.agent_runner import is_agent_running, run_local_agent from app.core.device_manager import device_manager +from app.core.note_summarizer import generate_note_summary from app.db import get_session from app.models import AgentRunLog, LocalAgentConfig from app.schemas import ( @@ -230,3 +233,25 @@ async def trigger_agent_run( ) return _to_run_log_response(run_log) + + +# ── Note summary endpoint ────────────────────────────────────────────────────── + + +class NoteSummarizeRequest(BaseModel): + title: str + content: str + + +class NoteSummarizeResponse(BaseModel): + summary: str + + +@router.post("/notes/summarize", response_model=NoteSummarizeResponse) +async def summarize_note( + body: NoteSummarizeRequest, + current_user: UserProfile = Depends(get_current_user), +) -> NoteSummarizeResponse: + """Generate an AI summary for a note. Used by the Electron backfill on startup.""" + summary = await generate_note_summary(body.title, body.content) + return NoteSummarizeResponse(summary=summary) diff --git a/app/core/agent_runner.py b/app/core/agent_runner.py index 7f66143..c2d6507 100644 --- a/app/core/agent_runner.py +++ b/app/core/agent_runner.py @@ -658,9 +658,14 @@ async def run_local_agent( # ── Phase B: single LLM call ───────────────────────── extraction_rules = _get_extraction_rules(agent_config, content_type) no_match_behavior = _get_no_match_behavior(agent_config) - global_rules_lines = "\n".join( - f"- {r}" for r in agent_config.get("global_rules", []) - ) + base_global_rules = list(agent_config.get("global_rules", [])) + if "notes" in config.data_types: + base_global_rules.append( + "For notes: when updating an existing note use `propose_note_edit` " + "(type=append/insert/replace) so the user can review AI changes. " + "Only call `update_note` for complete content replacement without review." + ) + global_rules_lines = "\n".join(f"- {r}" for r in base_global_rules) metadata_section = _format_metadata(preprocessed.metadata) system_prompt = compile_prompt( diff --git a/app/core/llm.py b/app/core/llm.py index 1647d2c..b74bc34 100644 --- a/app/core/llm.py +++ b/app/core/llm.py @@ -111,6 +111,7 @@ _AGENT_MODEL_SETTINGS: dict[str, Callable[[], str]] = { "memory-extractor": lambda: settings.LLM_MODEL_MEMORY_EXTRACTOR or "gpt-4o-mini", "memory-miner": lambda: settings.LLM_MODEL_MEMORY_MINER or "gpt-4o-mini", "memory-auditor": lambda: settings.LLM_MODEL_MEMORY_AUDITOR or settings.LLM_MODEL, + "note-summarizer": lambda: "gpt-4o-mini", } diff --git a/app/core/note_summarizer.py b/app/core/note_summarizer.py new file mode 100644 index 0000000..d5be210 --- /dev/null +++ b/app/core/note_summarizer.py @@ -0,0 +1,51 @@ +"""Note summarizer — generates a compact AI summary for a note. + +Called fire-and-forget from create_note / update_note tools so the +``notes.ai_summary`` column stays current without blocking the agent loop. +""" + +from __future__ import annotations + +import logging + +from langchain_core.messages import HumanMessage, SystemMessage + +from app.core.langfuse_client import get_prompt_or_fallback +from app.core.llm import get_agent_llm + +logger = logging.getLogger(__name__) + +_FALLBACK_PROMPT = """\ +Summarize this note in <=250 characters. Be terse and dense. +Keep proper nouns, dates, decisions, and action items. +Do not start with "This note". +Respond with the summary text only — no intro, no labels. + +Title: {title} +Content: {content}""" + +_MAX_CONTENT_CHARS = 4000 + + +async def generate_note_summary(title: str, content: str) -> str: + """Return a <=250-char summary of *title* + *content*. + + Uses the Langfuse ``note_summary`` prompt (hot-swappable) with a local + fallback. Truncates *content* to 4000 chars before sending to avoid + token waste on large notes. + """ + template, _ = get_prompt_or_fallback("note_summary", _FALLBACK_PROMPT) + trimmed = content[:_MAX_CONTENT_CHARS] + system_prompt = template.format(title=title, content=trimmed) + + try: + llm = get_agent_llm("note-summarizer") + response = await llm.ainvoke([ + SystemMessage(content=system_prompt), + HumanMessage(content="Generate the summary."), + ]) + text = response.content if isinstance(response.content, str) else "" + return text.strip()[:250] + except Exception as exc: + logger.warning("note_summarizer: failed to generate summary: %s", exc) + return ""