refactor: rename the application package to docsgpt

The backend import package is now docsgpt, the name it will carry on PyPI;
application was far too generic to install into anyone's site-packages.
git mv plus a mechanical rewrite of every import, dotted string and path
reference: 734 Python files, the compose files, Dockerfile, workflows, docs,
setup scripts, devcontainer, k8s manifests, vscode config, pytest and coverage
config, .gitignore. Behaviour is unchanged.

Kept for one release:
- A top-level application package whose meta-path finder resolves
  application.x.y to the already-imported docsgpt.x.y object, so old imports
  and entry points (celery -A application.app.celery,
  uvicorn application.asgi:asgi_app) keep working with a FutureWarning.
- Celery registers every application.* task name as an alias of its
  docsgpt.* task on start-up, so messages queued by the previous release still
  run. The redbeat key prefix moves to redbeat:docsgpt:v2: so schedule entries
  the previous release wrote are left unread instead of firing twice.

The backend image builds from the repository root (docker build -f
docsgpt/Dockerfile .) so it can ship the alias package; a root .dockerignore
allow-lists docsgpt/ and application/ and keeps caches, local data, .env
files, the sample index files and the Dockerfile out. Compose and the image
workflows point at the new context.
This commit is contained in:
Alex committed 2026-09-07 10:20:43 +01:00
1 parent 3e7f1f91b4
commit 574f96341e
984 files changed
+9847 -9682

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
"""Sources module."""
from .chunks import sources_chunks_ns
from .retrieval_test import sources_search_ns
from .routes import sources_ns
from .upload import sources_upload_ns
__all__ = [
"sources_ns",
"sources_chunks_ns",
"sources_search_ns",
"sources_upload_ns",
]
+311
View File
@@ -0,0 +1,311 @@
"""Source document management chunk management."""
from flask import current_app, jsonify, make_response, request
from flask_restx import fields, Namespace, Resource
from docsgpt.api import api
from docsgpt.api.user.base import get_vector_store
from docsgpt.api.user.team_sharing import effective_write_owner
from docsgpt.storage.db.repositories.sources import SourcesRepository
from docsgpt.storage.db.session import db_readonly
from docsgpt.utils import check_required_fields, num_tokens_from_string
sources_chunks_ns = Namespace(
"sources", description="Source document management operations", path="/api"
)
def _resolve_source(doc_id: str, user: str):
"""Resolve a source (UUID or legacy ObjectId) for the caller.
Returns the row dict (with PG UUID in ``id``) or ``None`` if missing.
"""
with db_readonly() as conn:
return SourcesRepository(conn).get_any(doc_id, user)
def _resolve_source_for_write(doc_id: str, user: str):
"""Resolve a source the caller may WRITE chunks on.
Returns the row dict when ``user`` owns the source, or when they hold a
team ``editor`` grant (adding/removing/editing documents is editor-allowed
— the vector partition is keyed by source_id, owner-agnostic). Returns
``None`` for viewer-only / no access. Source deletion stays owner-only and
is handled elsewhere.
"""
with db_readonly() as conn:
doc = SourcesRepository(conn).get_any(doc_id, user)
if doc is not None:
return doc
owner = effective_write_owner(conn, "source", doc_id, user)
if not owner:
return None
return SourcesRepository(conn).get_any(doc_id, owner)
@sources_chunks_ns.route("/get_chunks")
class GetChunks(Resource):
@api.doc(
description="Retrieves chunks from a document, optionally filtered by file path and search term",
params={
"id": "The document ID",
"page": "Page number for pagination",
"per_page": "Number of chunks per page",
"path": "Optional: Filter chunks by relative file path",
"search": "Optional: Search term to filter chunks by title or content",
},
)
def get(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
doc_id = request.args.get("id")
page = int(request.args.get("page", 1))
per_page = int(request.args.get("per_page", 10))
path = request.args.get("path")
search_term = request.args.get("search", "").strip().lower()
if not doc_id:
return make_response(jsonify({"error": "Invalid doc_id"}), 400)
try:
doc = _resolve_source(doc_id, user)
except Exception as e:
current_app.logger.error(f"Error resolving source: {e}", exc_info=True)
return make_response(jsonify({"error": "Invalid doc_id"}), 400)
if not doc:
return make_response(
jsonify({"error": "Document not found or access denied"}), 404
)
resolved_id = str(doc["id"])
try:
store = get_vector_store(resolved_id)
chunks = store.get_chunks()
filtered_chunks = []
for chunk in chunks:
metadata = chunk.get("metadata", {})
if path:
chunk_source = metadata.get("source", "")
chunk_file_path = metadata.get("file_path", "")
source_match = chunk_source and chunk_source.endswith(path)
file_path_match = chunk_file_path and chunk_file_path.endswith(path)
if not (source_match or file_path_match):
continue
if search_term:
text_match = search_term in chunk.get("text", "").lower()
title_match = search_term in metadata.get("title", "").lower()
if not (text_match or title_match):
continue
filtered_chunks.append(chunk)
chunks = filtered_chunks
total_chunks = len(chunks)
start = (page - 1) * per_page
end = start + per_page
paginated_chunks = chunks[start:end]
return make_response(
jsonify(
{
"page": page,
"per_page": per_page,
"total": total_chunks,
"chunks": paginated_chunks,
"path": path if path else None,
"search": search_term if search_term else None,
}
),
200,
)
except Exception as e:
current_app.logger.error(f"Error getting chunks: {e}", exc_info=True)
return make_response(jsonify({"success": False}), 500)
@sources_chunks_ns.route("/add_chunk")
class AddChunk(Resource):
@api.expect(
api.model(
"AddChunkModel",
{
"id": fields.String(required=True, description="Document ID"),
"text": fields.String(required=True, description="Text of the chunk"),
"metadata": fields.Raw(
required=False,
description="Metadata associated with the chunk",
),
},
)
)
@api.doc(
description="Adds a new chunk to the document",
)
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
data = request.get_json()
required_fields = ["id", "text"]
missing_fields = check_required_fields(data, required_fields)
if missing_fields:
return missing_fields
doc_id = data.get("id")
text = data.get("text")
metadata = data.get("metadata", {})
token_count = num_tokens_from_string(text)
metadata["token_count"] = token_count
try:
doc = _resolve_source_for_write(doc_id, user)
except Exception as e:
current_app.logger.error(f"Error resolving source: {e}", exc_info=True)
return make_response(jsonify({"error": "Invalid doc_id"}), 400)
if not doc:
return make_response(jsonify({"error": "Source not accessible"}), 403)
try:
store = get_vector_store(str(doc["id"]))
chunk_id = store.add_chunk(text, metadata)
return make_response(
jsonify({"message": "Chunk added successfully", "chunk_id": chunk_id}),
201,
)
except Exception as e:
current_app.logger.error(f"Error adding chunk: {e}", exc_info=True)
return make_response(jsonify({"success": False}), 500)
@sources_chunks_ns.route("/delete_chunk")
class DeleteChunk(Resource):
@api.doc(
description="Deletes a specific chunk from the document.",
params={"id": "The document ID", "chunk_id": "The ID of the chunk to delete"},
)
def delete(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
doc_id = request.args.get("id")
chunk_id = request.args.get("chunk_id")
try:
doc = _resolve_source_for_write(doc_id, user)
except Exception as e:
current_app.logger.error(f"Error resolving source: {e}", exc_info=True)
return make_response(jsonify({"error": "Invalid doc_id"}), 400)
if not doc:
return make_response(jsonify({"error": "Source not accessible"}), 403)
try:
store = get_vector_store(str(doc["id"]))
deleted = store.delete_chunk(chunk_id)
if deleted:
return make_response(
jsonify({"message": "Chunk deleted successfully"}), 200
)
else:
return make_response(
jsonify({"message": "Chunk not found or could not be deleted"}),
404,
)
except Exception as e:
current_app.logger.error(f"Error deleting chunk: {e}", exc_info=True)
return make_response(jsonify({"success": False}), 500)
@sources_chunks_ns.route("/update_chunk")
class UpdateChunk(Resource):
@api.expect(
api.model(
"UpdateChunkModel",
{
"id": fields.String(required=True, description="Document ID"),
"chunk_id": fields.String(
required=True, description="Chunk ID to update"
),
"text": fields.String(
required=False, description="New text of the chunk"
),
"metadata": fields.Raw(
required=False,
description="Updated metadata associated with the chunk",
),
},
)
)
@api.doc(
description="Updates an existing chunk in the document.",
)
def put(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
data = request.get_json()
required_fields = ["id", "chunk_id"]
missing_fields = check_required_fields(data, required_fields)
if missing_fields:
return missing_fields
doc_id = data.get("id")
chunk_id = data.get("chunk_id")
text = data.get("text")
metadata = data.get("metadata")
if text is not None:
token_count = num_tokens_from_string(text)
if metadata is None:
metadata = {}
metadata["token_count"] = token_count
try:
doc = _resolve_source_for_write(doc_id, user)
except Exception as e:
current_app.logger.error(f"Error resolving source: {e}", exc_info=True)
return make_response(jsonify({"error": "Invalid doc_id"}), 400)
if not doc:
return make_response(jsonify({"error": "Source not accessible"}), 403)
try:
store = get_vector_store(str(doc["id"]))
chunks = store.get_chunks()
existing_chunk = next((c for c in chunks if c["doc_id"] == chunk_id), None)
if not existing_chunk:
return make_response(jsonify({"error": "Chunk not found"}), 404)
new_text = text if text is not None else existing_chunk["text"]
if metadata is not None:
new_metadata = existing_chunk["metadata"].copy()
new_metadata.update(metadata)
else:
new_metadata = existing_chunk["metadata"].copy()
if text is not None:
new_metadata["token_count"] = num_tokens_from_string(new_text)
try:
new_chunk_id = store.add_chunk(new_text, new_metadata)
deleted = store.delete_chunk(chunk_id)
if not deleted:
current_app.logger.warning(
f"Failed to delete old chunk {chunk_id}, but new chunk {new_chunk_id} was created"
)
return make_response(
jsonify(
{
"message": "Chunk updated successfully",
"chunk_id": new_chunk_id,
"original_chunk_id": chunk_id,
}
),
200,
)
except Exception as add_error:
current_app.logger.error(f"Failed to add updated chunk: {add_error}")
return make_response(
jsonify({"error": "Failed to update chunk - addition failed"}), 500
)
except Exception as e:
current_app.logger.error(f"Error updating chunk: {e}", exc_info=True)
return make_response(jsonify({"success": False}), 500)
+254
View File
@@ -0,0 +1,254 @@
"""Retrieval test — preview the chunks a query actually retrieves from a source.
The chunk browser's ``search`` (``/api/get_chunks``) is a substring filter over
stored text; it says nothing about what a RAG query would retrieve. This
endpoint runs the *production* retrieval path against a single source so a user
can see the real ranked chunks for a query, and try retrieval settings without
saving them.
It deliberately builds a ``Dispatcher`` — the same object the answer pipeline
uses — so retriever selection, per-source ``chunks`` / ``score_threshold``, the
prescreen stage and the token budget all behave exactly as they do at answer
time, rather than drifting from them.
"""
import logging
import math
import time
from flask import jsonify, make_response, request
from flask_restx import fields, Namespace, Resource
from pydantic import ValidationError
from docsgpt.api import api
from docsgpt.api.user.sources.routes import _resolve_readable_source
from docsgpt.core.model_utils import get_default_model_id
from docsgpt.retriever.dispatcher import Dispatcher
from docsgpt.retriever.retriever_creator import RetrieverCreator
from docsgpt.storage.db.session import db_readonly
from docsgpt.storage.db.source_config import RetrievalConfig, SourceConfig
from docsgpt.utils import num_tokens_from_string
logger = logging.getLogger(__name__)
sources_search_ns = Namespace(
"sources", description="Source retrieval testing", path="/api"
)
# A retrieval query is a search query, not a document.
MAX_QUERY_LENGTH = 2000
# The answer pipeline's default; keeps the preview's budgeting identical to it.
DOC_TOKEN_LIMIT = 50000
# Cost-attribution tag for any LLM call the preview makes (prescreen only —
# with no chat history the rephrase side-call is skipped entirely).
USAGE_SOURCE = "retrieval_test"
# Ceiling on the prescreen LLM calls a single test may trigger.
MAX_PRESCREEN_BATCHES = 20
@sources_search_ns.route("/sources/<string:source_id>/search")
class SourceSearch(Resource):
search_model = api.model(
"SourceSearchModel",
{
"query": fields.String(
required=True, description="The query to retrieve for"
),
"retrieval": fields.Raw(
required=False,
description="Ad-hoc retrieval config to test with. Omit to use "
"the source's saved config. Never persisted.",
),
},
)
@api.expect(search_model)
@api.doc(
description="Run the real retrieval pipeline against one source and "
"return the ranked chunks it produces, optionally under an ad-hoc "
"retrieval config. Read-only: nothing is saved."
)
def post(self, source_id):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
body = request.get_json(silent=True)
if not isinstance(body, dict):
return make_response(
jsonify({"success": False, "message": "Invalid request body"}), 400
)
query = (body.get("query") or "").strip()
if not query:
return make_response(
jsonify({"success": False, "message": "Query is required"}), 400
)
if len(query) > MAX_QUERY_LENGTH:
return make_response(
jsonify(
{
"success": False,
"message": f"Query must be at most {MAX_QUERY_LENGTH} characters",
}
),
400,
)
try:
# Read access = owner or any team grant (viewer included), matching
# the other source read endpoints (wiki pages, graph).
with db_readonly() as conn:
doc = _resolve_readable_source(conn, source_id, user)
except Exception as e:
# An unresolvable id yields None (→ 404); reaching here means the
# lookup itself failed, which is ours, not the caller's.
logger.error(f"Error resolving source: {e}", exc_info=True)
return make_response(
jsonify({"success": False, "message": "Could not resolve source"}), 500
)
if not doc:
return make_response(
jsonify(
{"success": False, "message": "Source not found or access denied"}
),
404,
)
resolved_id = str(doc["id"])
# A supplied config is validated exactly as strictly as a saved one (D7
# strict-on-write), with the same static message so validation internals
# aren't echoed back. Absent → the source's saved config.
saved = SourceConfig.parse(doc.get("config")).retrieval
if body.get("retrieval") is not None:
try:
retrieval = RetrievalConfig.model_validate(body["retrieval"])
except ValidationError:
return make_response(
jsonify(
{
"success": False,
"message": "Invalid retrieval config: one or more "
"fields failed validation.",
}
),
400,
)
else:
retrieval = saved
# "Ad-hoc" means the caller is testing something the source is NOT
# already configured to do. The client always sends the config it has on
# screen — including an untouched one — so compare by value rather than
# trusting the body's presence, or a source whose saved config is
# expensive could never be tested at all (see the ceiling below).
ad_hoc = retrieval != saved
# RetrievalConfig.retriever is a free string, so an unknown key would
# only blow up inside RetrieverCreator — a caller's typo must be a 400,
# not a 500.
if retrieval.retriever not in RetrieverCreator.retrievers:
return make_response(
jsonify(
{
"success": False,
"message": f"Unknown retriever '{retrieval.retriever}'.",
}
),
400,
)
# Prescreen screens candidate_k candidates in batches of batch_size, one
# synchronous LLM call each, so candidate_k=500 / batch_size=1 is 500
# provider calls in one request; RetrievalConfig bounds each field but
# not their ratio. Only an ad-hoc config is capped: a config already
# saved on the source runs at answer time anyway, so refusing to test it
# would defeat the endpoint.
if ad_hoc:
ps = retrieval.prescreen_config()
if ps is not None:
batches = math.ceil(ps.candidate_k / ps.batch_size)
if batches > MAX_PRESCREEN_BATCHES:
return make_response(
jsonify(
{
"success": False,
"message": (
f"This prescreen config needs {batches} LLM "
f"calls to test (limit "
f"{MAX_PRESCREEN_BATCHES}). Raise batch_size "
"or lower candidate_k."
),
}
),
400,
)
# A test has no chat behind it, so no model was requested. Resolve the
# same instance default the answer path falls back to: with a bogus id
# the prescreen stage would call the provider, fail, and silently keep
# every candidate. On an instance with no models configured this is
# None — leave the retriever's own default in place rather than
# forwarding it.
dispatcher_kwargs = {}
default_model_id = get_default_model_id()
if default_model_id:
dispatcher_kwargs["model_id"] = default_model_id
try:
started = time.monotonic()
# Dispatcher directly, NOT build_dispatcher: the
# PER_SOURCE_RETRIEVAL_ENABLED kill-switch would fall back to a
# stock classic retriever and ignore the config under test.
retriever = Dispatcher(
source={"active_docs": [resolved_id], "question": query},
chat_history=[], # no history ⇒ no rephrase side-call
chunks=retrieval.chunks,
doc_token_limit=DOC_TOKEN_LIMIT,
decoded_token=decoded_token,
sources=[{"id": resolved_id, "retrieval": retrieval}],
include_scores=True,
usage_source=USAGE_SOURCE,
**dispatcher_kwargs,
)
docs = retriever.search(query) or []
latency_ms = int((time.monotonic() - started) * 1000)
except Exception as e:
logger.error(f"Retrieval test failed: {e}", exc_info=True)
return make_response(
jsonify({"success": False, "message": "Retrieval failed"}), 500
)
chunks = [
{
"rank": idx,
"text": d.get("text", ""),
"title": d.get("title"),
"filename": d.get("filename"),
"source": d.get("source"),
"tokens": num_tokens_from_string(d.get("text", "")),
# None for retrievers/stores that produce no comparable score
# (graphrag's PPR ranking, stores without a score seam).
"score": d.get("score"),
"score_kind": d.get("score_kind"),
}
for idx, d in enumerate(docs, start=1)
]
return make_response(
jsonify(
{
"success": True,
"query": query,
"retriever": retrieval.retriever,
"retrieval": retrieval.model_dump(),
"total": len(chunks),
"latency_ms": latency_ms,
"chunks": chunks,
}
),
200,
)
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff