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("//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("/", 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("/", 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("/", 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("/", 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("//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("//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("//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("//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("//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("//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("//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("//versions/", 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)