Phase 5: Multi-user authentication with session cookies, bcrypt passwords, first-user-is-admin pattern, per-user data isolation, backup/restore, Docker Swarm production stack with secrets and network isolation. Phase 5.1: Chat UX improvements: - Background generation architecture (GenerationBuffer + asyncio task) with SSE fan-out, reconnect support, and periodic DB flushes - LLM-generated conversation titles (first exchange + every 10th message) - Stop generation button with cancel_event and partial content preservation - Relative timestamps in sidebar (5m ago, 3h ago, then dates) - Empty chat auto-cleanup on navigation away - Save-as-note uses LLM for title generation, tags notes with "chat" - Summarize-as-note also tags with "chat" Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
271 lines
9.2 KiB
Python
271 lines
9.2 KiB
Python
import logging
|
|
from datetime import date, datetime, timezone
|
|
|
|
from sqlalchemy import func, or_, select, text
|
|
|
|
from fabledassistant.models import async_session
|
|
from fabledassistant.models.note import Note, TaskPriority, TaskStatus
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
async def create_note(
|
|
user_id: int,
|
|
title: str = "",
|
|
body: str = "",
|
|
tags: list[str] | None = None,
|
|
parent_id: int | None = None,
|
|
status: str | None = None,
|
|
priority: str | None = None,
|
|
due_date: date | None = None,
|
|
) -> Note:
|
|
async with async_session() as session:
|
|
note = Note(
|
|
user_id=user_id,
|
|
title=title,
|
|
body=body,
|
|
tags=tags or [],
|
|
parent_id=parent_id,
|
|
status=status,
|
|
priority=priority,
|
|
due_date=due_date,
|
|
)
|
|
session.add(note)
|
|
await session.commit()
|
|
await session.refresh(note)
|
|
return note
|
|
|
|
|
|
async def get_note(user_id: int, note_id: int) -> Note | None:
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
select(Note).where(Note.id == note_id, Note.user_id == user_id)
|
|
)
|
|
return result.scalars().first()
|
|
|
|
|
|
async def list_notes(
|
|
user_id: int,
|
|
q: str | None = None,
|
|
tags: list[str] | None = None,
|
|
is_task: bool | None = None,
|
|
status: str | None = None,
|
|
priority: str | None = None,
|
|
sort: str = "updated_at",
|
|
order: str = "desc",
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
) -> tuple[list[Note], int]:
|
|
async with async_session() as session:
|
|
query = select(Note).where(Note.user_id == user_id)
|
|
count_query = select(func.count(Note.id)).where(Note.user_id == user_id)
|
|
|
|
# Filter by task vs note
|
|
if is_task is True:
|
|
query = query.where(Note.status.isnot(None))
|
|
count_query = count_query.where(Note.status.isnot(None))
|
|
elif is_task is False:
|
|
query = query.where(Note.status.is_(None))
|
|
count_query = count_query.where(Note.status.is_(None))
|
|
|
|
if q:
|
|
terms = q.split()
|
|
for term in terms:
|
|
escaped_term = term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
pattern = f"%{escaped_term}%"
|
|
term_filter = or_(Note.title.ilike(pattern), Note.body.ilike(pattern))
|
|
query = query.where(term_filter)
|
|
count_query = count_query.where(term_filter)
|
|
|
|
if tags:
|
|
for i, tag in enumerate(tags):
|
|
param_tag = f"tag_{i}"
|
|
param_prefix = f"tag_prefix_{i}"
|
|
tag_filter = text(
|
|
f"EXISTS (SELECT 1 FROM unnest(notes.tags) AS t"
|
|
f" WHERE t = :{param_tag} OR t LIKE :{param_prefix})"
|
|
).bindparams(**{param_tag: tag, param_prefix: tag + "/%"})
|
|
query = query.where(tag_filter)
|
|
count_query = count_query.where(tag_filter)
|
|
|
|
if status:
|
|
query = query.where(Note.status == status)
|
|
count_query = count_query.where(Note.status == status)
|
|
|
|
if priority:
|
|
query = query.where(Note.priority == priority)
|
|
count_query = count_query.where(Note.priority == priority)
|
|
|
|
sort_col = getattr(Note, sort, Note.updated_at)
|
|
if order == "asc":
|
|
query = query.order_by(sort_col.asc())
|
|
else:
|
|
query = query.order_by(sort_col.desc())
|
|
|
|
query = query.limit(limit).offset(offset)
|
|
|
|
total = await session.scalar(count_query) or 0
|
|
result = await session.execute(query)
|
|
notes = list(result.scalars().all())
|
|
return notes, total
|
|
|
|
|
|
async def get_note_by_title(user_id: int, title: str) -> Note | None:
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
select(Note).where(
|
|
Note.user_id == user_id,
|
|
func.lower(Note.title) == func.lower(title.strip()),
|
|
).limit(1)
|
|
)
|
|
return result.scalars().first()
|
|
|
|
|
|
async def get_or_create_note_by_title(user_id: int, title: str) -> Note:
|
|
title = title.strip()
|
|
note = await get_note_by_title(user_id, title)
|
|
if note:
|
|
return note
|
|
return await create_note(user_id, title=title)
|
|
|
|
|
|
async def update_note(user_id: int, note_id: int, **fields: object) -> Note | None:
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
select(Note).where(Note.id == note_id, Note.user_id == user_id)
|
|
)
|
|
note = result.scalars().first()
|
|
if note is None:
|
|
return None
|
|
for key, value in fields.items():
|
|
if not hasattr(note, key):
|
|
continue
|
|
if key == "status" and isinstance(value, str):
|
|
value = TaskStatus(value).value
|
|
elif key == "priority" and isinstance(value, str):
|
|
value = TaskPriority(value).value
|
|
setattr(note, key, value)
|
|
note.updated_at = datetime.now(timezone.utc)
|
|
await session.commit()
|
|
await session.refresh(note)
|
|
return note
|
|
|
|
|
|
async def delete_note(user_id: int, note_id: int) -> bool:
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
select(Note).where(Note.id == note_id, Note.user_id == user_id)
|
|
)
|
|
note = result.scalars().first()
|
|
if note is None:
|
|
return False
|
|
await session.delete(note)
|
|
await session.commit()
|
|
return True
|
|
|
|
|
|
async def get_all_tags(user_id: int, q: str | None = None) -> list[str]:
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
text(
|
|
"SELECT DISTINCT unnest(tags) AS tag FROM notes"
|
|
" WHERE tags != '{}' AND user_id = :user_id"
|
|
).bindparams(user_id=user_id)
|
|
)
|
|
all_tags = [row[0] for row in result.fetchall()]
|
|
|
|
if q:
|
|
q_lower = q.lower()
|
|
all_tags = [t for t in all_tags if q_lower in t.lower()]
|
|
|
|
return sorted(all_tags)
|
|
|
|
|
|
async def convert_note_to_task(user_id: int, note_id: int) -> Note:
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
select(Note).where(Note.id == note_id, Note.user_id == user_id)
|
|
)
|
|
note = result.scalars().first()
|
|
if note is None:
|
|
logger.warning("convert_note_to_task: note %d not found", note_id)
|
|
raise ValueError("Note not found")
|
|
note.status = TaskStatus.todo.value
|
|
note.priority = TaskPriority.none.value
|
|
note.updated_at = datetime.now(timezone.utc)
|
|
await session.commit()
|
|
await session.refresh(note)
|
|
logger.info("Converted note %d to task", note_id)
|
|
return note
|
|
|
|
|
|
async def convert_task_to_note(user_id: int, note_id: int) -> Note:
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
select(Note).where(Note.id == note_id, Note.user_id == user_id)
|
|
)
|
|
note = result.scalars().first()
|
|
if note is None:
|
|
logger.warning("convert_task_to_note: note %d not found", note_id)
|
|
raise ValueError("Note not found")
|
|
note.status = None
|
|
note.priority = None
|
|
note.due_date = None
|
|
note.updated_at = datetime.now(timezone.utc)
|
|
await session.commit()
|
|
await session.refresh(note)
|
|
logger.info("Converted task %d to note", note_id)
|
|
return note
|
|
|
|
|
|
async def search_notes_for_context(
|
|
user_id: int,
|
|
keywords: list[str],
|
|
exclude_ids: set[int] | None = None,
|
|
limit: int = 3,
|
|
) -> list[Note]:
|
|
"""Search notes by keywords with OR logic. Optimized for context building — no count query."""
|
|
async with async_session() as session:
|
|
keyword_filters = []
|
|
for kw in keywords:
|
|
escaped = kw.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
pattern = f"%{escaped}%"
|
|
keyword_filters.append(or_(Note.title.ilike(pattern), Note.body.ilike(pattern)))
|
|
|
|
query = select(Note).where(Note.user_id == user_id, or_(*keyword_filters))
|
|
if exclude_ids:
|
|
query = query.where(Note.id.notin_(exclude_ids))
|
|
query = query.order_by(Note.updated_at.desc()).limit(limit)
|
|
|
|
result = await session.execute(query)
|
|
return list(result.scalars().all())
|
|
|
|
|
|
async def get_backlinks(user_id: int, note_id: int) -> list[dict]:
|
|
note = await get_note(user_id, note_id)
|
|
if note is None:
|
|
return []
|
|
|
|
title = note.title
|
|
if not title:
|
|
return []
|
|
|
|
async with async_session() as session:
|
|
escaped = title.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
pattern = f"%[[{escaped}]]%"
|
|
pattern_alias = f"%[[{escaped}|%"
|
|
|
|
results = await session.execute(
|
|
select(Note.id, Note.title, Note.status).where(
|
|
Note.user_id == user_id,
|
|
Note.id != note_id,
|
|
or_(Note.body.like(pattern), Note.body.like(pattern_alias)),
|
|
)
|
|
)
|
|
backlinks: list[dict] = []
|
|
for row in results.fetchall():
|
|
link_type = "task" if row[2] is not None else "note"
|
|
backlinks.append({"type": link_type, "id": row[0], "title": row[1]})
|
|
|
|
return backlinks
|