362ead7f0d
create_note service accepts a new description kwarg and forwards it to the Note constructor. PUT/PATCH/POST routes include description in the field whitelist. update_note already passed **fields through setattr, so the new column is reachable without touching that signature.
566 lines
19 KiB
Python
566 lines
19 KiB
Python
import asyncio
|
|
import logging
|
|
import re
|
|
|
|
from fabledassistant.services.embeddings import upsert_note_embedding
|
|
|
|
from quart import Blueprint, Response, jsonify, request
|
|
|
|
from fabledassistant.auth import login_required, get_current_user_id
|
|
from fabledassistant.routes.utils import not_found, parse_iso_date, parse_pagination
|
|
from fabledassistant.config import Config
|
|
from fabledassistant.services.assist import build_assist_messages
|
|
from fabledassistant.services.generation_buffer import (
|
|
GenerationState,
|
|
create_assist_buffer,
|
|
get_assist_buffer,
|
|
)
|
|
from fabledassistant.services.generation_task import run_assist_generation
|
|
from fabledassistant.services.notes import (
|
|
build_note_graph,
|
|
convert_note_to_task,
|
|
convert_task_to_note,
|
|
create_note,
|
|
delete_note,
|
|
get_all_tags,
|
|
get_backlinks,
|
|
get_note,
|
|
get_note_by_title,
|
|
get_note_for_user,
|
|
get_or_create_note_by_title,
|
|
list_notes,
|
|
update_note,
|
|
)
|
|
from fabledassistant.services.settings import get_setting
|
|
from fabledassistant.services.tag_suggestions import suggest_tags
|
|
from fabledassistant.services.note_drafts import upsert_draft, get_draft, delete_draft
|
|
from fabledassistant.services.note_versions import list_versions, get_version
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
notes_bp = Blueprint("notes", __name__, url_prefix="/api/notes")
|
|
|
|
|
|
@notes_bp.route("", methods=["GET"])
|
|
@login_required
|
|
async def list_notes_route():
|
|
uid = get_current_user_id()
|
|
q = request.args.get("q")
|
|
tag = request.args.getlist("tag")
|
|
sort = request.args.get("sort", "updated_at")
|
|
order = request.args.get("order", "desc")
|
|
limit, offset = parse_pagination()
|
|
|
|
# Default to non-task notes only; ?is_task=true for tasks, ?all=true for everything
|
|
is_task: bool | None = False
|
|
if request.args.get("all", "").lower() == "true":
|
|
is_task = None
|
|
elif request.args.get("is_task", "").lower() == "true":
|
|
is_task = True
|
|
|
|
project_id = request.args.get("project_id", type=int)
|
|
milestone_id = request.args.get("milestone_id", type=int)
|
|
parent_id = request.args.get("parent_id", type=int)
|
|
no_project = request.args.get("no_project", "").lower() == "true"
|
|
|
|
# type= shorthand used by web frontend (?type=task or ?type=note)
|
|
type_param = request.args.get("type")
|
|
if type_param == "task":
|
|
is_task = True
|
|
elif type_param == "note":
|
|
is_task = False
|
|
|
|
status = request.args.getlist("status") or None
|
|
priority = request.args.getlist("priority") or None
|
|
|
|
notes, total = await list_notes(
|
|
uid, q=q, tags=tag or None, is_task=is_task, sort=sort, order=order,
|
|
limit=limit, offset=offset,
|
|
project_id=project_id, milestone_id=milestone_id, parent_id=parent_id,
|
|
no_project=no_project, status=status, priority=priority,
|
|
)
|
|
return jsonify({"notes": [n.to_dict() for n in notes], "total": total})
|
|
|
|
|
|
@notes_bp.route("", methods=["POST"])
|
|
@login_required
|
|
async def create_note_route():
|
|
uid = get_current_user_id()
|
|
data = await request.get_json()
|
|
body = data.get("body", "")
|
|
tags = data.get("tags", [])
|
|
|
|
# Optional task fields
|
|
status = data.get("status")
|
|
priority = data.get("priority")
|
|
due_date = parse_iso_date(data.get("due_date"), "due_date")
|
|
if isinstance(due_date, tuple):
|
|
return due_date
|
|
|
|
project_id = data.get("project_id")
|
|
if project_id is None and data.get("project"):
|
|
from fabledassistant.services.projects import get_project_by_title as _gpbt
|
|
proj = await _gpbt(uid, data["project"])
|
|
if proj:
|
|
project_id = proj.id
|
|
|
|
note_type = data.get("note_type", "note")
|
|
entity_meta = data.get("metadata") or None
|
|
|
|
try:
|
|
note = await create_note(
|
|
uid,
|
|
title=data.get("title", ""),
|
|
body=body,
|
|
description=data.get("description"),
|
|
tags=tags,
|
|
parent_id=data.get("parent_id"),
|
|
project_id=project_id,
|
|
milestone_id=data.get("milestone_id"),
|
|
status=status,
|
|
priority=priority,
|
|
due_date=due_date,
|
|
note_type=note_type,
|
|
entity_meta=entity_meta,
|
|
)
|
|
except ValueError as e:
|
|
return jsonify({"error": str(e)}), 400
|
|
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
|
|
if text:
|
|
asyncio.create_task(upsert_note_embedding(note.id, uid, text))
|
|
return jsonify(note.to_dict()), 201
|
|
|
|
|
|
@notes_bp.route("/tags", methods=["GET"])
|
|
@login_required
|
|
async def list_tags_route():
|
|
uid = get_current_user_id()
|
|
q = request.args.get("q")
|
|
tags = await get_all_tags(uid, q=q)
|
|
return jsonify({"tags": tags})
|
|
|
|
|
|
@notes_bp.route("/suggest-tags", methods=["POST"])
|
|
@login_required
|
|
async def suggest_tags_route():
|
|
uid = get_current_user_id()
|
|
data = await request.get_json()
|
|
title = data.get("title", "")
|
|
body = data.get("body", "")
|
|
current_tags = data.get("current_tags", [])
|
|
tags = await suggest_tags(uid, title, body, current_tags=current_tags)
|
|
return jsonify({"suggested_tags": tags})
|
|
|
|
|
|
@notes_bp.route("/<int:note_id>/append-tag", methods=["POST"])
|
|
@login_required
|
|
async def append_tag_route(note_id: int):
|
|
uid = get_current_user_id()
|
|
data = await request.get_json()
|
|
tag = data.get("tag", "").strip().replace(" ", "-")
|
|
if not tag:
|
|
return jsonify({"error": "tag is required"}), 400
|
|
|
|
note = await get_note(uid, note_id)
|
|
if note is None:
|
|
return not_found("Note")
|
|
|
|
existing = list(note.tags or [])
|
|
if tag not in existing:
|
|
existing.append(tag)
|
|
updated = await update_note(uid, note_id, tags=existing)
|
|
return jsonify(updated.to_dict())
|
|
|
|
|
|
@notes_bp.route("/by-title", methods=["GET"])
|
|
@login_required
|
|
async def get_note_by_title_route():
|
|
uid = get_current_user_id()
|
|
title = request.args.get("title", "")
|
|
if not title:
|
|
return jsonify({"error": "title parameter is required"}), 400
|
|
note = await get_note_by_title(uid, title)
|
|
if note is None:
|
|
return not_found("Note")
|
|
return jsonify(note.to_dict())
|
|
|
|
|
|
@notes_bp.route("/resolve-title", methods=["POST"])
|
|
@login_required
|
|
async def resolve_title_route():
|
|
uid = get_current_user_id()
|
|
data = await request.get_json()
|
|
title = data.get("title", "").strip()
|
|
if not title:
|
|
return jsonify({"error": "title is required"}), 400
|
|
note = await get_or_create_note_by_title(uid, title)
|
|
return jsonify(note.to_dict())
|
|
|
|
|
|
@notes_bp.route("/<int:note_id>", methods=["GET"])
|
|
@login_required
|
|
async def get_note_route(note_id: int):
|
|
uid = get_current_user_id()
|
|
result = await get_note_for_user(uid, note_id)
|
|
if result is None:
|
|
return not_found("Note")
|
|
note, permission = result
|
|
data = note.to_dict()
|
|
data["permission"] = permission
|
|
return jsonify(data)
|
|
|
|
|
|
@notes_bp.route("/<int:note_id>", methods=["PUT"])
|
|
@login_required
|
|
async def update_note_route(note_id: int):
|
|
uid = get_current_user_id()
|
|
data = await request.get_json()
|
|
fields = {}
|
|
for key in ("title", "body", "description", "parent_id", "project_id", "milestone_id", "status", "priority", "note_type"):
|
|
if key in data:
|
|
fields[key] = data[key]
|
|
if "metadata" in data:
|
|
fields["entity_meta"] = data["metadata"] or None
|
|
|
|
if "due_date" in data:
|
|
if data["due_date"]:
|
|
result = parse_iso_date(data["due_date"], "due_date")
|
|
if isinstance(result, tuple):
|
|
return result
|
|
fields["due_date"] = result
|
|
else:
|
|
fields["due_date"] = None
|
|
|
|
if "tags" in data:
|
|
fields["tags"] = data["tags"]
|
|
try:
|
|
note = await update_note(uid, note_id, **fields)
|
|
except ValueError as e:
|
|
return jsonify({"error": str(e)}), 400
|
|
if note is None:
|
|
return not_found("Note")
|
|
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
|
|
if text:
|
|
asyncio.create_task(upsert_note_embedding(note.id, uid, text))
|
|
return jsonify(note.to_dict())
|
|
|
|
|
|
@notes_bp.route("/<int:note_id>", methods=["PATCH"])
|
|
@login_required
|
|
async def patch_note_route(note_id: int):
|
|
uid = get_current_user_id()
|
|
data = await request.get_json()
|
|
fields = {}
|
|
for key in ("title", "body", "description", "parent_id", "project_id", "milestone_id", "status", "priority", "note_type"):
|
|
if key in data:
|
|
fields[key] = data[key]
|
|
if "metadata" in data:
|
|
fields["entity_meta"] = data["metadata"] or None
|
|
if "due_date" in data:
|
|
if data["due_date"]:
|
|
result = parse_iso_date(data["due_date"], "due_date")
|
|
if isinstance(result, tuple):
|
|
return result
|
|
fields["due_date"] = result
|
|
else:
|
|
fields["due_date"] = None
|
|
if "tags" in data:
|
|
fields["tags"] = data["tags"]
|
|
try:
|
|
note = await update_note(uid, note_id, **fields)
|
|
except ValueError as e:
|
|
return jsonify({"error": str(e)}), 400
|
|
if note is None:
|
|
return not_found("Note")
|
|
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
|
|
if text:
|
|
asyncio.create_task(upsert_note_embedding(note.id, uid, text))
|
|
return jsonify(note.to_dict())
|
|
|
|
|
|
@notes_bp.route("/<int:note_id>", methods=["DELETE"])
|
|
@login_required
|
|
async def delete_note_route(note_id: int):
|
|
uid = get_current_user_id()
|
|
deleted = await delete_note(uid, note_id)
|
|
if not deleted:
|
|
return not_found("Note")
|
|
return "", 204
|
|
|
|
|
|
@notes_bp.route("/<int:note_id>/convert-to-task", methods=["POST"])
|
|
@login_required
|
|
async def convert_note_to_task_route(note_id: int):
|
|
uid = get_current_user_id()
|
|
try:
|
|
note = await convert_note_to_task(uid, note_id)
|
|
return jsonify(note.to_dict()), 200
|
|
except ValueError as e:
|
|
return jsonify({"error": str(e)}), 404
|
|
|
|
|
|
@notes_bp.route("/<int:note_id>/convert-to-note", methods=["POST"])
|
|
@login_required
|
|
async def convert_task_to_note_route(note_id: int):
|
|
uid = get_current_user_id()
|
|
try:
|
|
note = await convert_task_to_note(uid, note_id)
|
|
return jsonify(note.to_dict()), 200
|
|
except ValueError as e:
|
|
return jsonify({"error": str(e)}), 404
|
|
|
|
|
|
@notes_bp.route("/<int:note_id>/backlinks", methods=["GET"])
|
|
@login_required
|
|
async def get_backlinks_route(note_id: int):
|
|
uid = get_current_user_id()
|
|
links = await get_backlinks(uid, note_id)
|
|
return jsonify({"backlinks": links})
|
|
|
|
|
|
@notes_bp.route("/assist", methods=["POST"])
|
|
@login_required
|
|
async def assist_route():
|
|
"""Launch a background assist generation task."""
|
|
uid = get_current_user_id()
|
|
data = await request.get_json()
|
|
|
|
body = data.get("body", "")
|
|
target_section = data.get("target_section", "")
|
|
instruction = data.get("instruction", "")
|
|
whole_doc = bool(data.get("whole_doc", False))
|
|
project_id = data.get("project_id")
|
|
note_id = data.get("note_id")
|
|
|
|
if not whole_doc and not target_section:
|
|
return jsonify({"error": "target_section is required for section mode"}), 400
|
|
if not instruction:
|
|
return jsonify({"error": "instruction is required"}), 400
|
|
|
|
# Fetch related project notes for context (up to 5, excluding current note).
|
|
# Glossary-tagged notes are prioritised so the model knows canonical definitions.
|
|
context_notes: list[dict] = []
|
|
if project_id:
|
|
try:
|
|
pid = int(project_id)
|
|
# 1) Glossary notes first (up to 3)
|
|
glossary_notes, _ = await list_notes(
|
|
uid, project_id=pid, tags=["definition"], sort="updated_at", order="desc", limit=3
|
|
)
|
|
seen_ids: set[int] = set()
|
|
for n in glossary_notes:
|
|
if n.id == note_id:
|
|
continue
|
|
seen_ids.add(n.id)
|
|
context_notes.append({
|
|
"title": n.title or "Untitled",
|
|
"tags": n.tags or [],
|
|
"body": n.body or "",
|
|
})
|
|
# 2) Fill remaining slots with recently-updated notes
|
|
if len(context_notes) < 5:
|
|
recent_notes, _ = await list_notes(
|
|
uid, project_id=pid, sort="updated_at", order="desc",
|
|
limit=5 - len(context_notes) + len(seen_ids) + 1,
|
|
)
|
|
for n in recent_notes:
|
|
if n.id == note_id or n.id in seen_ids:
|
|
continue
|
|
if len(context_notes) >= 5:
|
|
break
|
|
seen_ids.add(n.id)
|
|
context_notes.append({
|
|
"title": n.title or "Untitled",
|
|
"tags": n.tags or [],
|
|
"body": n.body or "",
|
|
})
|
|
except Exception:
|
|
logger.warning("Failed to fetch project context notes for assist", exc_info=True)
|
|
|
|
model = await get_setting(uid, "default_model", Config.OLLAMA_MODEL) or Config.OLLAMA_MODEL
|
|
messages = build_assist_messages(
|
|
body, target_section, instruction,
|
|
whole_doc=whole_doc,
|
|
context_notes=context_notes or None,
|
|
)
|
|
|
|
buf = create_assist_buffer(uid)
|
|
asyncio.create_task(run_assist_generation(buf, messages, model))
|
|
|
|
return jsonify({"status": "started"}), 202
|
|
|
|
|
|
@notes_bp.route("/assist/stream", methods=["GET"])
|
|
@login_required
|
|
async def assist_stream_route():
|
|
"""SSE endpoint that tails the assist generation buffer."""
|
|
uid = get_current_user_id()
|
|
|
|
buf = get_assist_buffer(uid)
|
|
if buf is None:
|
|
return jsonify({"error": "No active assist generation"}), 404
|
|
|
|
last_id_str = request.headers.get("Last-Event-ID") or request.args.get("last_event_id")
|
|
try:
|
|
last_id = int(last_id_str) if last_id_str is not None else -1
|
|
except (ValueError, TypeError):
|
|
last_id = -1
|
|
|
|
async def stream():
|
|
cursor = last_id
|
|
while True:
|
|
pending = buf.events_after(cursor)
|
|
for event in pending:
|
|
yield buf.format_sse(event)
|
|
cursor = event.index
|
|
|
|
if buf.state != GenerationState.RUNNING:
|
|
break
|
|
|
|
got_new = await buf.wait_for_event(cursor, timeout=15.0)
|
|
if not got_new:
|
|
yield ": keepalive\n\n"
|
|
|
|
# Final drain: deliver any events appended between the last yield
|
|
# and the state check above (e.g. the 'done' event).
|
|
for event in buf.events_after(cursor):
|
|
yield buf.format_sse(event)
|
|
|
|
return Response(
|
|
stream(),
|
|
content_type="text/event-stream",
|
|
headers={
|
|
"Cache-Control": "no-cache",
|
|
"X-Accel-Buffering": "no",
|
|
},
|
|
)
|
|
|
|
|
|
# ── Link suggestions ─────────────────────────────────────────────────────────
|
|
|
|
_WIKILINK_RE = re.compile(r'\[\[[^\]]+\]\]')
|
|
|
|
|
|
def _find_unlinked_terms(body: str, note_titles: list[tuple[int, str]]) -> list[dict]:
|
|
"""Return project note titles that appear in body as plain text (not inside [[...]])."""
|
|
linked_ranges = [(m.start(), m.end()) for m in _WIKILINK_RE.finditer(body)]
|
|
|
|
def in_wikilink(start: int, end: int) -> bool:
|
|
return any(ls <= start and end <= le for ls, le in linked_ranges)
|
|
|
|
suggestions = []
|
|
for note_id, title in note_titles:
|
|
title = title.strip()
|
|
if len(title) < 3:
|
|
continue
|
|
pattern = re.compile(r'\b' + re.escape(title) + r'\b', re.IGNORECASE)
|
|
count = sum(1 for m in pattern.finditer(body) if not in_wikilink(m.start(), m.end()))
|
|
if count > 0:
|
|
suggestions.append({"note_id": note_id, "title": title, "count": count})
|
|
|
|
suggestions.sort(key=lambda x: x["count"], reverse=True)
|
|
return suggestions
|
|
|
|
|
|
@notes_bp.route("/link-suggestions", methods=["POST"])
|
|
@login_required
|
|
async def link_suggestions_route():
|
|
"""Find project note titles that appear unlinked in the given body text."""
|
|
uid = get_current_user_id()
|
|
data = await request.get_json()
|
|
|
|
body_text = data.get("body", "")
|
|
project_id = data.get("project_id")
|
|
exclude_note_id = data.get("exclude_note_id")
|
|
|
|
if not project_id or not body_text:
|
|
return jsonify({"suggestions": []})
|
|
|
|
try:
|
|
project_notes, _ = await list_notes(
|
|
uid, project_id=int(project_id), sort="title", order="asc", limit=200
|
|
)
|
|
titles = [
|
|
(n.id, n.title)
|
|
for n in project_notes
|
|
if n.id != exclude_note_id and n.title
|
|
]
|
|
suggestions = _find_unlinked_terms(body_text, titles)
|
|
except Exception:
|
|
logger.warning("Failed to compute link suggestions", exc_info=True)
|
|
suggestions = []
|
|
|
|
return jsonify({"suggestions": suggestions})
|
|
|
|
|
|
# ── Draft routes ─────────────────────────────────────────────────────────────
|
|
|
|
@notes_bp.route("/<int:note_id>/draft", methods=["GET"])
|
|
@login_required
|
|
async def get_draft_route(note_id: int):
|
|
uid = get_current_user_id()
|
|
draft = await get_draft(uid, note_id)
|
|
if draft is None:
|
|
return jsonify({"error": "No draft found"}), 404
|
|
return jsonify(draft.to_dict())
|
|
|
|
|
|
@notes_bp.route("/<int:note_id>/draft", methods=["PUT"])
|
|
@login_required
|
|
async def upsert_draft_route(note_id: int):
|
|
uid = get_current_user_id()
|
|
# Verify note ownership
|
|
note = await get_note(uid, note_id)
|
|
if note is None:
|
|
return not_found("Note")
|
|
data = await request.get_json()
|
|
draft = await upsert_draft(
|
|
user_id=uid,
|
|
note_id=note_id,
|
|
proposed_body=data.get("proposed_body", ""),
|
|
original_body=data.get("original_body", ""),
|
|
instruction=data.get("instruction", ""),
|
|
scope=data.get("scope", "document"),
|
|
)
|
|
return jsonify(draft.to_dict()), 200
|
|
|
|
|
|
@notes_bp.route("/<int:note_id>/draft", methods=["DELETE"])
|
|
@login_required
|
|
async def delete_draft_route(note_id: int):
|
|
uid = get_current_user_id()
|
|
await delete_draft(uid, note_id)
|
|
return "", 204
|
|
|
|
|
|
# ── Version routes ────────────────────────────────────────────────────────────
|
|
|
|
@notes_bp.route("/<int:note_id>/versions", methods=["GET"])
|
|
@login_required
|
|
async def list_versions_route(note_id: int):
|
|
uid = get_current_user_id()
|
|
versions = await list_versions(uid, note_id)
|
|
return jsonify({"versions": [v.to_dict(include_body=False) for v in versions]})
|
|
|
|
|
|
@notes_bp.route("/<int:note_id>/versions/<int:version_id>", methods=["GET"])
|
|
@login_required
|
|
async def get_version_route(note_id: int, version_id: int):
|
|
uid = get_current_user_id()
|
|
version = await get_version(uid, note_id, version_id)
|
|
if version is None:
|
|
return not_found("Version")
|
|
return jsonify(version.to_dict(include_body=True))
|
|
|
|
|
|
# ── Graph route ────────────────────────────────────────────────────────────────
|
|
|
|
@notes_bp.route("/graph", methods=["GET"])
|
|
@login_required
|
|
async def graph_route():
|
|
uid = get_current_user_id()
|
|
project_id = request.args.get("project_id", type=int)
|
|
shared_tags = request.args.get("shared_tags", "false").lower() == "true"
|
|
graph = await build_note_graph(uid, project_id=project_id, include_shared_tags=shared_tags)
|
|
return jsonify(graph)
|