"""Knowledge service — unified query across notes, people, places, and lists.""" import logging from sqlalchemy import func, select from fabledassistant.models import async_session from fabledassistant.models.note import Note logger = logging.getLogger(__name__) _SNIPPET_LEN = 200 def _note_to_item(note: Note) -> dict: meta = note.entity_meta or {} item: dict = { "id": note.id, "note_type": note.entity_type, "title": note.title, "snippet": (note.body or "")[:_SNIPPET_LEN], "tags": note.tags or [], "project_id": note.project_id, "metadata": meta, "created_at": note.created_at.isoformat(), "updated_at": note.updated_at.isoformat(), } # Type-specific convenience fields if note.entity_type == "person": item["relationship"] = meta.get("relationship", "") item["email"] = meta.get("email", "") item["phone"] = meta.get("phone", "") elif note.entity_type == "place": item["address"] = meta.get("address", "") item["phone"] = meta.get("phone", "") item["hours"] = meta.get("hours", "") elif note.entity_type == "list": # Parse markdown task list syntax into structured items body = note.body or "" list_items = [] for line in body.split("\n"): stripped = line.strip() if stripped.startswith("- [ ] ") or stripped.startswith("- [x] ") or stripped.startswith("- [X] "): checked_item = not stripped.startswith("- [ ] ") list_items.append({"text": stripped[6:], "checked": checked_item}) item["list_items"] = list_items item["item_count"] = len(list_items) item["checked_count"] = sum(1 for i in list_items if i["checked"]) item["body"] = body return item async def query_knowledge( user_id: int, note_type: str | None, tags: list[str], sort: str, q: str | None, limit: int, offset: int, ) -> tuple[list[dict], int]: """Query knowledge objects (non-task notes) with filters. Returns (items, total_count). """ # Semantic search path — scores take priority over sort if q: return await _semantic_knowledge_search( user_id, q, note_type=note_type, tags=tags, limit=limit, offset=offset ) async with async_session() as session: base = ( select(Note) .where(Note.user_id == user_id) .where(Note.status.is_(None)) # exclude tasks ) if note_type: base = base.where(Note.note_type == note_type) else: # Exclude tasks — already done above; also exclude any legacy nulls base = base.where(Note.note_type.in_(["note", "person", "place", "list"])) for tag in tags: base = base.where(Note.tags.contains([tag])) # Count before pagination count_stmt = select(func.count()).select_from(base.subquery()) total: int = (await session.execute(count_stmt)).scalar_one() # Apply sort if sort == "created": base = base.order_by(Note.created_at.desc()) elif sort == "alpha": base = base.order_by(Note.title.asc()) elif sort == "type": base = base.order_by(Note.note_type.asc(), Note.updated_at.desc()) else: # modified (default) base = base.order_by(Note.updated_at.desc()) rows = list((await session.execute(base.limit(limit).offset(offset))).scalars().all()) return [_note_to_item(n) for n in rows], total async def _semantic_knowledge_search( user_id: int, q: str, note_type: str | None, tags: list[str], limit: int, offset: int, ) -> tuple[list[dict], int]: """Semantic search over knowledge objects, with SQL filters applied post-rank.""" try: from fabledassistant.services.embeddings import semantic_search_notes # Fetch a larger candidate set to allow for filtering candidates = await semantic_search_notes( user_id=user_id, query=q, limit=min(200, limit * 8), threshold=0.3, is_task=False, ) except Exception: logger.warning("Semantic search unavailable, falling back to SQL", exc_info=True) return await query_knowledge(user_id, note_type, tags, "modified", None, limit, offset) results = [] for _score, note in candidates: if note_type and note.entity_type != note_type: continue if tags and not all(t in (note.tags or []) for t in tags): continue results.append(note) total = len(results) page_items = results[offset: offset + limit] return [_note_to_item(n) for n in page_items], total async def get_knowledge_tags(user_id: int, note_type: str | None = None) -> list[str]: """Return all distinct tags used across knowledge objects for this user.""" from sqlalchemy.dialects.postgresql import array async with async_session() as session: stmt = ( select(func.unnest(Note.tags).label("tag")) .where(Note.user_id == user_id) .where(Note.status.is_(None)) ) if note_type: stmt = stmt.where(Note.note_type == note_type) else: stmt = stmt.where(Note.note_type.in_(["note", "person", "place", "list"])) stmt = ( select(func.unnest(Note.tags).label("tag")) .where(Note.user_id == user_id) .where(Note.status.is_(None)) .distinct() .order_by("tag") ) if note_type: stmt = stmt.where(Note.note_type == note_type) rows = list((await session.execute(stmt)).scalars().all()) return [r for r in rows if r] async def query_knowledge_ids( user_id: int, note_type: str | None, tags: list[str], sort: str, q: str | None, limit: int = 100, offset: int = 0, ) -> tuple[list[int], int]: """Return note IDs only — cheap query for the two-tier pagination feed.""" if q: # Re-use semantic search, extract IDs in rank order items, total = await _semantic_knowledge_search( user_id, q, note_type=note_type, tags=tags, limit=limit, offset=offset, ) return [item["id"] for item in items], total async with async_session() as session: base = ( select(Note.id) .where(Note.user_id == user_id) .where(Note.status.is_(None)) ) if note_type: base = base.where(Note.note_type == note_type) else: base = base.where(Note.note_type.in_(["note", "person", "place", "list"])) for tag in tags: base = base.where(Note.tags.contains([tag])) count_stmt = select(func.count()).select_from(base.subquery()) total: int = (await session.execute(count_stmt)).scalar_one() if sort == "created": base = base.order_by(Note.created_at.desc()) elif sort == "alpha": base = base.order_by(Note.title.asc()) elif sort == "type": base = base.order_by(Note.note_type.asc(), Note.updated_at.desc()) else: base = base.order_by(Note.updated_at.desc()) ids = list((await session.execute(base.limit(limit).offset(offset))).scalars().all()) return ids, total async def get_knowledge_by_ids(user_id: int, ids: list[int]) -> list[dict]: """Fetch full items for the given IDs, preserving the requested order.""" if not ids: return [] async with async_session() as session: stmt = ( select(Note) .where(Note.user_id == user_id) .where(Note.id.in_(ids)) ) rows = list((await session.execute(stmt)).scalars().all()) by_id = {n.id: n for n in rows} return [_note_to_item(by_id[i]) for i in ids if i in by_id] async def get_people_and_places_context(user_id: int) -> str: """Return a compact summary of known people and places for LLM system prompt injection.""" async with async_session() as session: stmt = ( select(Note) .where(Note.user_id == user_id) .where(Note.note_type.in_(["person", "place"])) .where(Note.status.is_(None)) .order_by(Note.title.asc()) .limit(50) ) rows = list((await session.execute(stmt)).scalars().all()) if not rows: return "" people = [n for n in rows if n.entity_type == "person"] places = [n for n in rows if n.entity_type == "place"] lines = [] if people: parts = [] for p in people: meta = p.entity_meta or {} rel = meta.get("relationship", "") parts.append(f"{p.title}" + (f" ({rel})" if rel else "")) lines.append("Known people: " + ", ".join(parts)) if places: parts = [] for p in places: meta = p.entity_meta or {} addr = meta.get("address", "") parts.append(f"{p.title}" + (f" – {addr}" if addr else "")) lines.append("Known places: " + "; ".join(parts)) return "\n".join(lines)