345 lines
13 KiB
Python
345 lines
13 KiB
Python
"""
|
|||
|
|
Copyright 2024-2026 ReplyPier
|
||
|
|
|
||
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||
|
|
you may not use this file except in compliance with the License.
|
||
|
|
You may obtain a copy of the License at
|
||
|
|
|
||
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
||
|
|
|
||
|
|
Unless required by applicable law or agreed to in writing, software
|
||
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
|
|
See the License for the specific language governing permissions and
|
||
|
|
limitations under the License.
|
||
|
|
"""
|
||
|
|
|
||
|
|
"""Shared helpers for reading, re-embedding and deleting knowledge sub-pages.
|
||
|
|
|
||
|
|
A knowledge "source" (``Knowledge`` row) stores its crawled/extracted content as
|
||
|
|
rows in the pgvector table ``{schema}."{table}"``. Each row is a *chunk*; a
|
||
|
|
*page* (aka sub-page) is the group of chunks that share a base ``id`` — the page
|
||
|
|
URL/name — with optional ``_N`` chunk suffixes (e.g. ``site.com/docs`` and
|
||
|
|
``site.com/docs_1``, ``site.com/docs_2``).
|
||
|
|
|
||
|
|
These helpers centralise the embed/upsert/delete logic that the knowledge API
|
||
|
|
endpoints previously duplicated inline, so editing a chunk, adding a sub-page and
|
||
|
|
replacing a whole page all go through one code path.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from typing import Any, Dict, List, Optional
|
||
|
|
from uuid import UUID
|
||
|
|
|
||
|
|
from agno.document import Document
|
||
|
|
from sqlalchemy import text
|
||
|
|
from sqlalchemy.orm import Session
|
||
|
|
|
||
|
|
from app.core.logger import get_logger
|
||
|
|
from app.knowledge.chunking import CHUNK_META_KEYS, chunk_page
|
||
|
|
from app.knowledge.knowledge_base import KnowledgeManager
|
||
|
|
from app.models.knowledge import Knowledge, SourceType
|
||
|
|
from app.models.knowledge_to_agent import KnowledgeToAgent
|
||
|
|
from app.repositories.knowledge import KnowledgeRepository
|
||
|
|
from app.repositories.knowledge_to_agent import KnowledgeToAgentRepository
|
||
|
|
|
||
|
|
logger = get_logger(__name__)
|
||
|
|
|
||
|
|
# SQL expression that maps a chunk ``id`` back to its page id by stripping the
|
||
|
|
# trailing chunk suffix: ``<page>::<n>`` (app.knowledge.chunking) or the legacy
|
||
|
|
# ``<page>_<n>`` agno's PDF reader produced. Only a *numeric* suffix is
|
||
|
|
# stripped, so legitimate underscores in a page name (e.g. ``getting_started``)
|
||
|
|
# are kept. This is the single source of truth for page grouping — the
|
||
|
|
# source-listing queries import it too, and ``page_id_of`` / the frontend
|
||
|
|
# ``basePageId`` mirror it — so page membership is identical everywhere
|
||
|
|
# edit/delete/list operate.
|
||
|
|
PAGE_ID_EXPR = (
|
||
|
|
"CASE WHEN id ~ '::[0-9]+$' THEN substring(id from '^(.*)::[0-9]+$') "
|
||
|
|
"WHEN id ~ '_[0-9]+$' THEN substring(id from '^(.*)_[0-9]+$') ELSE id END"
|
||
|
|
)
|
||
|
|
|
||
|
|
# Chunk ids within a page sort naturally (length first) so ``page::2`` comes
|
||
|
|
# before ``page::10`` where plain text ordering would not. The bare page id, if
|
||
|
|
# any, sorts first.
|
||
|
|
NATURAL_ID_ORDER = "length(id), id"
|
||
|
|
|
||
|
|
|
||
|
|
def get_manager(organization_id) -> KnowledgeManager:
|
||
|
|
"""Build a KnowledgeManager bound to an org's vector table (no agent link)."""
|
||
|
|
return KnowledgeManager(org_id=organization_id, agent_id=None)
|
||
|
|
|
||
|
|
|
||
|
|
def agent_ids_for(knowledge: Knowledge) -> List[str]:
|
||
|
|
"""Return the agent ids linked to a knowledge source (for vector filters)."""
|
||
|
|
return [str(link.agent_id) for link in knowledge.agent_links]
|
||
|
|
|
||
|
|
|
||
|
|
def embed_document(
|
||
|
|
manager: KnowledgeManager,
|
||
|
|
source: str,
|
||
|
|
doc_id: str,
|
||
|
|
content: str,
|
||
|
|
meta_data: Optional[Dict[str, Any]] = None,
|
||
|
|
) -> Document:
|
||
|
|
"""Create a Document for ``doc_id`` and embed it with the org's embedder."""
|
||
|
|
doc = Document(
|
||
|
|
name=source,
|
||
|
|
id=doc_id,
|
||
|
|
content=content,
|
||
|
|
meta_data=meta_data or {},
|
||
|
|
)
|
||
|
|
doc.embed(embedder=manager.vector_db.embedder)
|
||
|
|
return doc
|
||
|
|
|
||
|
|
|
||
|
|
def embed_page(
|
||
|
|
manager: KnowledgeManager,
|
||
|
|
source: str,
|
||
|
|
page_id: str,
|
||
|
|
content: str,
|
||
|
|
meta_data: Optional[Dict[str, Any]] = None,
|
||
|
|
) -> List[Document]:
|
||
|
|
"""Chunk a page and embed every chunk. Embedding happens before any write,
|
||
|
|
so an embedder failure changes nothing in the store."""
|
||
|
|
page = Document(name=source, id=page_id, content=content, meta_data=meta_data or {})
|
||
|
|
chunks = chunk_page(page)
|
||
|
|
for chunk in chunks:
|
||
|
|
chunk.embed(embedder=manager.vector_db.embedder)
|
||
|
|
return chunks
|
||
|
|
|
||
|
|
|
||
|
|
def delete_page_chunks_except(
|
||
|
|
db: Session, knowledge: Knowledge, page_id: str, keep_ids: List[str]
|
||
|
|
) -> int:
|
||
|
|
"""Delete the chunks of a page whose id is not in ``keep_ids``. Used after
|
||
|
|
a page's new chunks are stored: whatever the old layout was (one bare row,
|
||
|
|
``_N`` rows, ``::N`` rows), only the new set survives. Caller commits."""
|
||
|
|
sql = (
|
||
|
|
f'DELETE FROM {knowledge.schema}."{knowledge.table_name}" '
|
||
|
|
f"WHERE name = :source AND {PAGE_ID_EXPR} = :page_id "
|
||
|
|
"AND NOT (id = ANY(:keep_ids))"
|
||
|
|
)
|
||
|
|
result = db.execute(
|
||
|
|
text(sql), {"source": knowledge.source, "page_id": page_id, "keep_ids": keep_ids}
|
||
|
|
)
|
||
|
|
return result.rowcount
|
||
|
|
|
||
|
|
|
||
|
|
def count_subpages(db: Session, knowledge: Knowledge) -> int:
|
||
|
|
"""Count the distinct sub-pages stored for a knowledge source.
|
||
|
|
|
||
|
|
Counts pages, not raw chunks, so a page split into several ``_N`` chunks
|
||
|
|
counts once — matching the ``max_sub_pages`` plan limit's intent.
|
||
|
|
"""
|
||
|
|
query = text(
|
||
|
|
f"SELECT COUNT(DISTINCT {PAGE_ID_EXPR}) AS count "
|
||
|
|
f'FROM {knowledge.schema}."{knowledge.table_name}" WHERE name = :source'
|
||
|
|
)
|
||
|
|
row = db.execute(query, {"source": knowledge.source}).fetchone()
|
||
|
|
return row.count if row else 0
|
||
|
|
|
||
|
|
|
||
|
|
def get_page_chunks(db: Session, knowledge: Knowledge, page_id: str) -> List[Any]:
|
||
|
|
"""Return all chunk rows (id, content, meta_data) belonging to a page."""
|
||
|
|
query = text(
|
||
|
|
f'SELECT id, content, meta_data FROM {knowledge.schema}."{knowledge.table_name}" '
|
||
|
|
f"WHERE name = :source AND {PAGE_ID_EXPR} = :page_id "
|
||
|
|
f"ORDER BY {NATURAL_ID_ORDER}"
|
||
|
|
)
|
||
|
|
return db.execute(
|
||
|
|
query, {"source": knowledge.source, "page_id": page_id}
|
||
|
|
).fetchall()
|
||
|
|
|
||
|
|
|
||
|
|
def delete_page_chunks(db: Session, knowledge: Knowledge, page_id: str) -> int:
|
||
|
|
"""Delete every chunk of a page. Returns the number of rows removed. The
|
||
|
|
caller commits the transaction."""
|
||
|
|
sql = (
|
||
|
|
f'DELETE FROM {knowledge.schema}."{knowledge.table_name}" '
|
||
|
|
f"WHERE name = :source AND {PAGE_ID_EXPR} = :page_id"
|
||
|
|
)
|
||
|
|
result = db.execute(text(sql), {"source": knowledge.source, "page_id": page_id})
|
||
|
|
return result.rowcount
|
||
|
|
|
||
|
|
|
||
|
|
def replace_page(
|
||
|
|
db: Session,
|
||
|
|
knowledge: Knowledge,
|
||
|
|
page_id: str,
|
||
|
|
content: str,
|
||
|
|
title: Optional[str] = None,
|
||
|
|
) -> int:
|
||
|
|
"""Replace a page's content with freshly chunked and embedded rows.
|
||
|
|
|
||
|
|
Metadata (including the page ``url`` and agent linkage) is preserved from the
|
||
|
|
existing chunks where available, so the edited page stays attributed to the
|
||
|
|
same agents and keeps its source URL.
|
||
|
|
|
||
|
|
Ordering is chosen so the page can never be lost:
|
||
|
|
|
||
|
|
1. Chunk and embed the new content (if this raises, nothing has changed).
|
||
|
|
2. ``upsert`` the new chunks on the vector store's own connection. Ids that
|
||
|
|
already exist are overwritten in place; the new content is now durably
|
||
|
|
stored before anything is deleted.
|
||
|
|
3. Delete every other row of the page — the bare ``page_id`` row of a page
|
||
|
|
that now spans several chunks, or trailing chunks of a page that shrank —
|
||
|
|
and commit.
|
||
|
|
|
||
|
|
A failure at step 2 leaves the original page intact; a failure at step 3
|
||
|
|
leaves the correct new content in place plus at most some stale extra chunks
|
||
|
|
(no data loss). This function commits the request session itself.
|
||
|
|
|
||
|
|
Returns the number of chunks the page had before the replace (0 if the page
|
||
|
|
does not exist).
|
||
|
|
"""
|
||
|
|
existing = get_page_chunks(db, knowledge, page_id)
|
||
|
|
if not existing:
|
||
|
|
return 0
|
||
|
|
|
||
|
|
# Preserve the first chunk's metadata (url, etc.); refresh agent linkage.
|
||
|
|
# Per-chunk keys are recomputed by chunk_page — carrying them over would
|
||
|
|
# leave a page edited down to one row claiming to be "part 1 of 5".
|
||
|
|
base_meta: Dict[str, Any] = {
|
||
|
|
key: value
|
||
|
|
for key, value in (existing[0].meta_data or {}).items()
|
||
|
|
if key not in CHUNK_META_KEYS
|
||
|
|
}
|
||
|
|
agent_ids = agent_ids_for(knowledge)
|
||
|
|
base_meta["agent_id"] = agent_ids
|
||
|
|
if title is not None:
|
||
|
|
base_meta["title"] = title
|
||
|
|
|
||
|
|
manager = get_manager(knowledge.organization_id)
|
||
|
|
chunks = embed_page(manager, knowledge.source, page_id, content, base_meta)
|
||
|
|
filters = {
|
||
|
|
"name": knowledge.source,
|
||
|
|
"agent_id": agent_ids,
|
||
|
|
"org_id": str(knowledge.organization_id),
|
||
|
|
}
|
||
|
|
|
||
|
|
# Persist the new content first, then clear every other row of the page.
|
||
|
|
manager.vector_db.upsert(chunks, filters=filters)
|
||
|
|
removed_extra = delete_page_chunks_except(
|
||
|
|
db, knowledge, page_id, [chunk.id for chunk in chunks]
|
||
|
|
)
|
||
|
|
db.commit()
|
||
|
|
|
||
|
|
logger.info(
|
||
|
|
f"Replaced page '{page_id}' ({len(existing)} -> {len(chunks)} chunk(s), "
|
||
|
|
f"{removed_extra} old removed) for source '{knowledge.source}'"
|
||
|
|
)
|
||
|
|
return len(existing)
|
||
|
|
|
||
|
|
|
||
|
|
def reembed_chunk(
|
||
|
|
db: Session,
|
||
|
|
knowledge: Knowledge,
|
||
|
|
chunk_id: str,
|
||
|
|
content: str,
|
||
|
|
) -> None:
|
||
|
|
"""Re-embed one chunk's content in place (scoped to its source). Caller commits."""
|
||
|
|
manager = get_manager(knowledge.organization_id)
|
||
|
|
doc = embed_document(manager, knowledge.source, chunk_id, content)
|
||
|
|
update_query = text(
|
||
|
|
f'UPDATE {knowledge.schema}."{knowledge.table_name}" '
|
||
|
|
"SET content = :content, embedding = :embedding "
|
||
|
|
"WHERE id = :chunk_id AND name = :source"
|
||
|
|
)
|
||
|
|
db.execute(
|
||
|
|
update_query,
|
||
|
|
{
|
||
|
|
"content": content,
|
||
|
|
"embedding": doc.embedding,
|
||
|
|
"chunk_id": chunk_id,
|
||
|
|
"source": knowledge.source,
|
||
|
|
},
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def create_text_source(
|
||
|
|
db: Session,
|
||
|
|
organization_id: UUID,
|
||
|
|
title: str,
|
||
|
|
content: str,
|
||
|
|
agent_id: Optional[UUID] = None,
|
||
|
|
) -> Knowledge:
|
||
|
|
"""Create a knowledge source from pasted text and embed it as one page.
|
||
|
|
|
||
|
|
Unlike a URL/PDF source (which is crawled asynchronously via the queue), a
|
||
|
|
text source is indexed inline and is immediately ``synced``. Returns the new
|
||
|
|
``Knowledge`` row. Caller is responsible for the enclosing request context;
|
||
|
|
the vector write commits on its own session.
|
||
|
|
"""
|
||
|
|
manager = KnowledgeManager(
|
||
|
|
org_id=organization_id, agent_id=str(agent_id) if agent_id else None
|
||
|
|
)
|
||
|
|
agent_ids = [str(agent_id)] if agent_id else []
|
||
|
|
|
||
|
|
# Embed first — an embedding failure then creates no source row at all.
|
||
|
|
chunks = embed_page(
|
||
|
|
manager, title, title, content, {"agent_id": agent_ids, "url": title, "title": title}
|
||
|
|
)
|
||
|
|
|
||
|
|
repo = KnowledgeRepository(db)
|
||
|
|
# KnowledgeRepository.create dedupes on (org, source); track whether the row
|
||
|
|
# already existed so we only compensate-delete a row we actually created.
|
||
|
|
pre_existing = bool(repo.get_by_sources(organization_id, [title]))
|
||
|
|
knowledge = repo.create(
|
||
|
|
Knowledge(
|
||
|
|
organization_id=organization_id,
|
||
|
|
source=title,
|
||
|
|
source_type=SourceType.CUSTOM,
|
||
|
|
table_name=manager.vector_db.table_name,
|
||
|
|
schema=manager.vector_db.schema,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
try:
|
||
|
|
if agent_id is not None:
|
||
|
|
KnowledgeToAgentRepository(db).create(
|
||
|
|
KnowledgeToAgent(knowledge_id=knowledge.id, agent_id=agent_id)
|
||
|
|
)
|
||
|
|
# The org's vector table is otherwise created only by URL/PDF ingestion, so
|
||
|
|
# an org whose first source is pasted text has none yet. create() is a
|
||
|
|
# no-op when it exists and tolerates a concurrent creator.
|
||
|
|
manager.vector_db.create()
|
||
|
|
# upsert (not insert) is idempotent on the id, so a raced duplicate title
|
||
|
|
# overwrites its own chunks instead of raising a duplicate-key error.
|
||
|
|
manager.vector_db.upsert(
|
||
|
|
chunks,
|
||
|
|
filters={"name": title, "agent_id": agent_ids, "org_id": str(organization_id)},
|
||
|
|
)
|
||
|
|
except Exception:
|
||
|
|
# Don't leave a chunkless orphan source behind if indexing fails.
|
||
|
|
if not pre_existing:
|
||
|
|
repo.delete(knowledge.id)
|
||
|
|
raise
|
||
|
|
logger.info(f"Created text knowledge source '{title}' for org {organization_id}")
|
||
|
|
return knowledge
|
||
|
|
|
||
|
|
|
||
|
|
def insert_subpage(
|
||
|
|
knowledge: Knowledge,
|
||
|
|
subpage_name: str,
|
||
|
|
content: str,
|
||
|
|
url: Optional[str] = None,
|
||
|
|
) -> None:
|
||
|
|
"""Embed and store a brand-new sub-page as one or more chunks.
|
||
|
|
|
||
|
|
``subpage_name`` is the page id/title; ``url`` is an optional source link
|
||
|
|
stored in metadata so the UI can render it (and derive the title from the
|
||
|
|
name rather than from a URL the user pasted). The write happens on the vector
|
||
|
|
store's own session (which commits itself); there is nothing to commit on the
|
||
|
|
request session. The caller has already checked the page does not exist;
|
||
|
|
``upsert`` keeps a retry from failing on a half-written page.
|
||
|
|
"""
|
||
|
|
agent_ids = agent_ids_for(knowledge)
|
||
|
|
meta_data: Dict[str, Any] = {"agent_id": agent_ids, "title": subpage_name}
|
||
|
|
if url:
|
||
|
|
meta_data["url"] = url
|
||
|
|
manager = get_manager(knowledge.organization_id)
|
||
|
|
chunks = embed_page(manager, knowledge.source, subpage_name, content, meta_data)
|
||
|
|
filters = {
|
||
|
|
"name": knowledge.source,
|
||
|
|
"agent_id": agent_ids,
|
||
|
|
"org_id": str(knowledge.organization_id),
|
||
|
|
}
|
||
|
|
manager.vector_db.upsert(chunks, filters=filters)
|