* feat: postgres tests

* feat: mongo cutoff

* feat: mongo cutoff

* feat: adjust docs and compose files

* fix: mini code mongo removals

* fix: tests and k8s mongo stuff

* feat: test fixes

* fix: ruff

* fix: vale

* Potential fix for pull request finding 'CodeQL / Clear-text logging of sensitive information'

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* fix: mini suggestions

* vale lint fix 2

* fix: codeql columns thing

* fix: test mongo

* fix: tests coverage

* feat: better tests 4

* feat: more tests

* feat: decent coverage

* fix: ruff fixes

* fix: remove mongo mock

* feat: enhance workflow engine and API routes; add document retrieval and source handling

* feat: e2e tests

* fix: mcp, mongo and more

* fix: mini codeql warning

* fix: agent chunk view

* fix: mini issues

* fix: more pg fixes

* feat: postgres prep on start

* feat: qa tests

* fix: mini improvements

* fix: tests

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: Siddhant Rai <siddhant.rai.5686@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-04-18 13:13:57 +01:00
1 parent 7f6360b4ff
commit 81b6ee5daa
312 files changed
+763725 -25159

No files matched your search

+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# One-time bootstrap for the DocsGPT e2e Postgres template DB.
#
# Creates two databases on the native DBngin Postgres instance:
# * docsgpt_e2e_template — schema-only (alembic head), marked as a PG template
# * docsgpt_e2e — the live DB the first `up.sh` run connects to
#
# Idempotent. Safe to re-run after schema changes to refresh the template.
set -euo pipefail
PG_BIN="/Users/Shared/DBngin/postgresql/16.2/bin"
PSQL="${PG_BIN}/psql"
PG_ISREADY="${PG_BIN}/pg_isready"
PG_HOST="127.0.0.1"
PG_PORT="5432"
PG_SUPERUSER="postgres"
TEMPLATE_DB="docsgpt_e2e_template"
E2E_DB="docsgpt_e2e"
OWNER_ROLE="docsgpt"
OWNER_PASSWORD="docsgpt"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
log() {
printf '[bake_template] %s\n' "$*" >&2
}
if [[ ! -x "${PSQL}" ]]; then
log "psql not found at ${PSQL} — is DBngin Postgres 16.2 installed?"
exit 1
fi
log "Checking Postgres is up at ${PG_HOST}:${PG_PORT}..."
if ! "${PG_ISREADY}" -h "${PG_HOST}" -p "${PG_PORT}" -q; then
log "Postgres is not accepting connections at ${PG_HOST}:${PG_PORT}."
log "Start DBngin's Postgres 16.2 instance and try again."
exit 1
fi
log "Dropping and recreating ${TEMPLATE_DB} as superuser ${PG_SUPERUSER}..."
"${PSQL}" -h "${PG_HOST}" -p "${PG_PORT}" -U "${PG_SUPERUSER}" -d postgres \
-v ON_ERROR_STOP=1 -X -q <<SQL
-- Clear the template flag first so DROP DATABASE is allowed.
UPDATE pg_database SET datistemplate = FALSE WHERE datname = '${TEMPLATE_DB}';
UPDATE pg_database SET datallowconn = TRUE WHERE datname = '${TEMPLATE_DB}';
-- Evict any lingering backends on the template DB.
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = '${TEMPLATE_DB}'
AND pid <> pg_backend_pid();
DROP DATABASE IF EXISTS ${TEMPLATE_DB};
CREATE DATABASE ${TEMPLATE_DB} OWNER ${OWNER_ROLE};
SQL
log "Applying Alembic schema to ${TEMPLATE_DB}..."
if [[ -x "${REPO_ROOT}/.venv/bin/python" ]]; then
PYTHON="${REPO_ROOT}/.venv/bin/python"
else
PYTHON="python3"
fi
(
cd "${REPO_ROOT}"
POSTGRES_URI="postgresql://${OWNER_ROLE}:${OWNER_PASSWORD}@${PG_HOST}:${PG_PORT}/${TEMPLATE_DB}" \
"${PYTHON}" scripts/db/init_postgres.py
)
log "Marking ${TEMPLATE_DB} as a Postgres template (datistemplate=TRUE, datallowconn=FALSE)..."
"${PSQL}" -h "${PG_HOST}" -p "${PG_PORT}" -U "${PG_SUPERUSER}" -d postgres \
-v ON_ERROR_STOP=1 -X -q <<SQL
UPDATE pg_database SET datistemplate = TRUE WHERE datname = '${TEMPLATE_DB}';
UPDATE pg_database SET datallowconn = FALSE WHERE datname = '${TEMPLATE_DB}';
SQL
log "Cloning ${E2E_DB} from ${TEMPLATE_DB}..."
"${PSQL}" -h "${PG_HOST}" -p "${PG_PORT}" -U "${PG_SUPERUSER}" -d postgres \
-v ON_ERROR_STOP=1 -X -q <<SQL
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = '${E2E_DB}'
AND pid <> pg_backend_pid();
DROP DATABASE IF EXISTS ${E2E_DB};
CREATE DATABASE ${E2E_DB} TEMPLATE ${TEMPLATE_DB} OWNER ${OWNER_ROLE};
SQL
log "Done. Template ${TEMPLATE_DB} is baked; ${E2E_DB} is ready for the first e2e run."
exit 0
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# scripts/e2e/down.sh
#
# Tear down the DocsGPT end-to-end test stack started by up.sh.
# Reads pidfiles from /tmp/docsgpt-e2e/*.pid, sends SIGTERM, waits up to 3s,
# escalates to SIGKILL if still alive, removes each pidfile.
#
# Constraints:
# - Idempotent: exits 0 even when no pidfiles exist.
# - NEVER uses pkill/killall (CLAUDE.md: don't risk killing native
# Postgres/Redis/Mongo processes).
# - NEVER touches shared DB/Redis services.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
PIDDIR="/tmp/docsgpt-e2e"
log() {
echo "[down.sh] $*" >&2
}
# Stop a single service given its pidfile. Best-effort; never fatal.
stop_one() {
local pidfile="$1"
local svc
svc="$(basename "$pidfile" .pid)"
if [[ ! -s "$pidfile" ]]; then
log "$svc: pidfile empty or missing — removing"
rm -f "$pidfile"
return 0
fi
local pid
pid="$(cat "$pidfile" 2>/dev/null || true)"
# Guard against garbage in the pidfile (non-numeric or empty).
if ! [[ "$pid" =~ ^[0-9]+$ ]]; then
log "$svc: pidfile contents not numeric ('$pid') — removing"
rm -f "$pidfile"
return 0
fi
if ! kill -0 "$pid" 2>/dev/null; then
log "$svc: pid $pid not running — removing pidfile"
rm -f "$pidfile"
return 0
fi
log "$svc: sending SIGTERM to pid $pid"
kill "$pid" 2>/dev/null || true
# Poll up to 3 seconds for graceful exit.
local waited=0
while (( waited < 3 )); do
if ! kill -0 "$pid" 2>/dev/null; then
break
fi
sleep 1
waited=$(( waited + 1 ))
done
if kill -0 "$pid" 2>/dev/null; then
log "$svc: pid $pid still alive after 3s — SIGKILL"
kill -9 "$pid" 2>/dev/null || true
else
log "$svc: pid $pid exited gracefully"
fi
rm -f "$pidfile"
}
if [[ ! -d "$PIDDIR" ]]; then
log "no pid directory at $PIDDIR — nothing to stop"
exit 0
fi
shopt -s nullglob
pidfiles=( "$PIDDIR"/*.pid )
shopt -u nullglob
if (( ${#pidfiles[@]} == 0 )); then
log "no pidfiles in $PIDDIR — nothing to stop"
exit 0
fi
for pidfile in "${pidfiles[@]}"; do
stop_one "$pidfile"
done
log "teardown complete"
exit 0
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
# scripts/e2e/env.sh
#
# Environment variables for the DocsGPT end-to-end test stack.
# This file is intentionally passive: it exports variables and nothing else.
# It is `source`d by scripts/e2e/up.sh (and potentially by developers who want
# to run Flask/Celery manually against the e2e stack).
#
# Mirrors `Appendix A — .env.e2e reference` in e2e-plan.md. If you add/remove
# a variable here, update the plan doc as well.
#
# DO NOT run commands (mkdir, touch, etc.) from this file — keep it pure.
# -----------------------------------------------------------------------------
# Postgres
# -----------------------------------------------------------------------------
export POSTGRES_URI="postgresql://docsgpt:docsgpt@127.0.0.1:5432/docsgpt_e2e"
# -----------------------------------------------------------------------------
# Redis (dev uses DBs 0/1/2; e2e uses 11/12/13 to stay isolated)
# -----------------------------------------------------------------------------
export CELERY_BROKER_URL="redis://127.0.0.1:6379/11"
export CELERY_RESULT_BACKEND="redis://127.0.0.1:6379/12"
export CACHE_REDIS_URL="redis://127.0.0.1:6379/13"
# -----------------------------------------------------------------------------
# Mongo — unused in the e2e stack (app fully cut over on this branch)
# -----------------------------------------------------------------------------
export MONGO_URI=""
# -----------------------------------------------------------------------------
# Vector + storage
# -----------------------------------------------------------------------------
export VECTOR_STORE="faiss"
export EMBEDDINGS_NAME="huggingface_sentence-transformers/all-mpnet-base-v2"
export STORAGE_TYPE="local"
export URL_STRATEGY="backend"
export UPLOAD_FOLDER=".e2e-tmp/inputs"
# -----------------------------------------------------------------------------
# Flask
# -----------------------------------------------------------------------------
export API_URL="http://127.0.0.1:7099"
export FLASK_DEBUG_MODE="false"
# -----------------------------------------------------------------------------
# Auth (specs can override AUTH_TYPE per-launch via process env)
# -----------------------------------------------------------------------------
export AUTH_TYPE="session_jwt"
export JWT_SECRET_KEY="e2e-fixed-secret-never-use-in-prod"
export ENCRYPTION_SECRET_KEY="e2e-fixed-encryption-key-never-use-in-prod"
# -----------------------------------------------------------------------------
# LLM → mock stub on 127.0.0.1:7899
# -----------------------------------------------------------------------------
export LLM_PROVIDER="openai"
export LLM_NAME="gpt-4o-mini"
export API_KEY="e2e-fake-key"
export OPENAI_API_KEY="e2e-fake-key"
export OPENAI_BASE_URL="http://127.0.0.1:7899/v1"
export EMBEDDINGS_BASE_URL="http://127.0.0.1:7899/v1"
export EMBEDDINGS_KEY="e2e-fake-key"
# -----------------------------------------------------------------------------
# Determinism — disable features that introduce non-determinism in tests
# -----------------------------------------------------------------------------
export ENABLE_CONVERSATION_COMPRESSION="false"
export ENABLE_TOOL_PREFETCH="false"
export PARSE_PDF_AS_IMAGE="false"
export DOCLING_OCR_ENABLED="false"
+452
View File
@@ -0,0 +1,452 @@
"""OpenAI-compatible stub server for the DocsGPT e2e test suite.
Speaks the minimum subset of the OpenAI HTTP API that DocsGPT's ``openai``
Python client needs:
* ``POST /v1/chat/completions`` (streaming + non-streaming, tool calls via fixture)
* ``POST /v1/embeddings`` (deterministic hash-seeded vectors)
* ``GET /healthz`` (liveness probe for ``scripts/e2e/up.sh``)
The server is **deterministic**: the same request always returns the same
response. Requests are fingerprinted by SHA-256 of a canonical JSON encoding
of ``(model, messages, tool_choice)``. If a fixture file matching that hash
exists under ``mock_llm_fixtures/<hash>.json`` it wins; otherwise a generic
"I don't know" fallback is returned and the hash + request is logged to stderr
so a developer can promote it into a fixture later.
Run standalone (does NOT import anything from ``application/``). Python 3.11+.
Flask is the only non-stdlib dependency and is already in
``application/requirements.txt``.
Usage::
python scripts/e2e/mock_llm.py
Defaults to ``127.0.0.1:7899`` to match the ``OPENAI_BASE_URL`` referenced in
``e2e-plan.md`` Appendix A.
"""
from __future__ import annotations
import hashlib
import json
import os
import random
import sys
import time
from pathlib import Path
from typing import Any
from flask import Flask, Response, jsonify, request, stream_with_context
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
HOST = os.environ.get("MOCK_LLM_HOST", "127.0.0.1")
PORT = int(os.environ.get("MOCK_LLM_PORT", "7899"))
FIXTURES_DIR = Path(__file__).parent / "mock_llm_fixtures"
EMBEDDING_DIM = 768
GENERIC_FALLBACK_TEXT = (
"I don't have enough information to answer that from the provided sources."
)
STREAM_CHUNK_COUNT = 5
app = Flask(__name__)
# ---------------------------------------------------------------------------
# CORS — permissive; stub trusts its port
# ---------------------------------------------------------------------------
@app.after_request
def _add_cors_headers(response: Response) -> Response:
response.headers["Access-Control-Allow-Origin"] = "*"
response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
response.headers["Access-Control-Allow-Headers"] = "*"
return response
@app.route("/v1/chat/completions", methods=["OPTIONS"])
@app.route("/v1/embeddings", methods=["OPTIONS"])
def _cors_preflight() -> Response:
return Response(status=204)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _canonical_messages(messages: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
"""Return a minimal, stable representation of the messages array.
We keep only fields that are semantically meaningful for fingerprinting a
request. Extra keys from the OpenAI client (e.g. ``name``, ``tool_call_id``)
are preserved because they *do* change the intended response.
"""
if not messages:
return []
out: list[dict[str, Any]] = []
for msg in messages:
if not isinstance(msg, dict):
continue
# Content may be a string OR a list of content-part dicts (vision / tool).
# Serialize both forms deterministically.
entry: dict[str, Any] = {
"role": msg.get("role"),
"content": msg.get("content"),
}
for key in ("name", "tool_call_id", "tool_calls"):
if key in msg:
entry[key] = msg[key]
out.append(entry)
return out
def _compute_request_digest(payload: dict[str, Any]) -> str:
"""SHA-256 fingerprint of ``(model, messages, tool_choice)``.
Kept narrow on purpose — temperature / top_p / seed / max_tokens should
NOT influence which canned answer we return; those are knobs the app may
flap on across runs.
"""
canonical = {
"model": payload.get("model"),
"messages": _canonical_messages(payload.get("messages")),
"tool_choice": payload.get("tool_choice"),
}
blob = json.dumps(canonical, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
def _load_fixture(digest: str) -> dict[str, Any] | None:
"""Return the parsed fixture dict for ``digest``, or ``None`` if missing/bad."""
path = FIXTURES_DIR / f"{digest}.json"
if not path.is_file():
return None
try:
with path.open("r", encoding="utf-8") as fh:
data = json.load(fh)
except (OSError, json.JSONDecodeError) as exc:
sys.stderr.write(f"[mock-llm] failed to load fixture {path}: {exc}\n")
sys.stderr.flush()
return None
return data
def _estimate_tokens(text: str) -> int:
"""Rough 4-chars-per-token estimate (OpenAI's own ballpark)."""
if not text:
return 0
return max(1, len(text) // 4)
def _messages_text(messages: list[dict[str, Any]] | None) -> str:
"""Concatenate message contents for prompt-token estimation."""
if not messages:
return ""
parts: list[str] = []
for msg in messages:
if not isinstance(msg, dict):
continue
content = msg.get("content")
if isinstance(content, str):
parts.append(content)
elif isinstance(content, list):
for part in content:
if isinstance(part, dict):
text = part.get("text")
if isinstance(text, str):
parts.append(text)
return "\n".join(parts)
def _split_into_chunks(text: str, count: int) -> list[str]:
"""Split ``text`` into roughly ``count`` pieces by character length.
Guarantees at least one chunk even for the empty string (so streaming
clients still see a delta before ``[DONE]``).
"""
if count <= 0:
return [text]
if not text:
return [""]
n = len(text)
size = max(1, (n + count - 1) // count)
chunks = [text[i : i + size] for i in range(0, n, size)]
if not chunks:
chunks = [""]
return chunks
# ---------------------------------------------------------------------------
# Chat completions
# ---------------------------------------------------------------------------
def _resolve_chat_response(
payload: dict[str, Any], digest: str
) -> tuple[str, list[dict[str, Any]] | None, str, dict[str, int]]:
"""Return ``(content, tool_calls, finish_reason, usage)`` for ``payload``.
Looks up a fixture by digest first; falls back to the generic response if
no fixture is present, and logs the miss so the dev can convert it.
"""
fixture = _load_fixture(digest)
if fixture is None:
sys.stderr.write(f"[mock-llm] unknown fixture hash {digest}\n")
try:
sys.stderr.write(
"[mock-llm] request: "
+ json.dumps(payload, sort_keys=True, ensure_ascii=False)
+ "\n"
)
except (TypeError, ValueError):
sys.stderr.write("[mock-llm] request: <unserializable>\n")
sys.stderr.flush()
content = GENERIC_FALLBACK_TEXT
tool_calls: list[dict[str, Any]] | None = None
finish_reason = "stop"
prompt_tokens = _estimate_tokens(_messages_text(payload.get("messages")))
completion_tokens = _estimate_tokens(content)
usage = {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": prompt_tokens + completion_tokens,
}
return content, tool_calls, finish_reason, usage
response = fixture.get("response") or {}
content = response.get("content") or ""
tool_calls = response.get("tool_calls")
finish_reason = response.get("finish_reason") or "stop"
fixture_usage = response.get("usage") or {}
prompt_tokens = int(
fixture_usage.get(
"prompt_tokens",
_estimate_tokens(_messages_text(payload.get("messages"))),
)
)
completion_tokens = int(
fixture_usage.get("completion_tokens", _estimate_tokens(content))
)
usage = {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": prompt_tokens + completion_tokens,
}
return content, tool_calls, finish_reason, usage
def _chat_completion_envelope(
*,
digest: str,
model: str,
content: str,
tool_calls: list[dict[str, Any]] | None,
finish_reason: str,
usage: dict[str, int],
) -> dict[str, Any]:
message: dict[str, Any] = {"role": "assistant", "content": content}
if tool_calls:
message["tool_calls"] = tool_calls
return {
"id": f"chatcmpl-e2e-{digest[:12]}",
"object": "chat.completion",
"created": int(time.time()),
"model": model,
"choices": [
{
"index": 0,
"message": message,
"finish_reason": finish_reason,
}
],
"usage": usage,
}
def _sse(payload: dict[str, Any]) -> str:
return "data: " + json.dumps(payload, ensure_ascii=False) + "\n\n"
def _stream_chat_response(
*,
digest: str,
model: str,
content: str,
tool_calls: list[dict[str, Any]] | None,
finish_reason: str,
):
"""Generator yielding SSE frames that match the OpenAI streaming protocol."""
created = int(time.time())
completion_id = f"chatcmpl-e2e-{digest[:12]}"
def _base_chunk(delta: dict[str, Any], final: bool = False) -> dict[str, Any]:
return {
"id": completion_id,
"object": "chat.completion.chunk",
"created": created,
"model": model,
"choices": [
{
"index": 0,
"delta": delta,
"finish_reason": finish_reason if final else None,
}
],
}
# Opening role delta — matches OpenAI's real behavior.
yield _sse(_base_chunk({"role": "assistant", "content": ""}))
if tool_calls:
# Emit tool calls in one delta; content streaming is skipped when
# tool_calls are present, matching what RAG code paths expect.
yield _sse(_base_chunk({"tool_calls": tool_calls}))
yield _sse(_base_chunk({}, final=True))
else:
chunks = _split_into_chunks(content, STREAM_CHUNK_COUNT)
last_index = len(chunks) - 1
for i, piece in enumerate(chunks):
yield _sse(_base_chunk({"content": piece}, final=(i == last_index)))
yield "data: [DONE]\n\n"
@app.post("/v1/chat/completions")
def chat_completions() -> Response:
payload = request.get_json(silent=True) or {}
model = payload.get("model") or "gpt-4o-mini"
stream = bool(payload.get("stream"))
digest = _compute_request_digest(payload)
content, tool_calls, finish_reason, usage = _resolve_chat_response(payload, digest)
if stream:
generator = _stream_chat_response(
digest=digest,
model=model,
content=content,
tool_calls=tool_calls,
finish_reason=finish_reason,
)
response = Response(
stream_with_context(generator),
mimetype="text/event-stream",
)
response.headers["Cache-Control"] = "no-cache"
response.headers["X-Accel-Buffering"] = "no"
# Flask strips Content-Length on streamed responses; make sure we don't
# accidentally set one. Nothing to do here — just documenting.
return response
envelope = _chat_completion_envelope(
digest=digest,
model=model,
content=content,
tool_calls=tool_calls,
finish_reason=finish_reason,
usage=usage,
)
return jsonify(envelope)
# ---------------------------------------------------------------------------
# Embeddings
# ---------------------------------------------------------------------------
def _deterministic_embedding(text: str) -> list[float]:
"""Hash-seeded 768-dim float vector in [-1, 1).
Never all-zero: seeded RNG on a non-trivial hash of ``text`` plus a small
non-zero offset so degenerate vector-store checks pass even if
``text`` itself is empty.
"""
seed = int(hashlib.sha256(text.encode("utf-8")).hexdigest()[:16], 16) & 0xFFFFFFFF
rng = random.Random(seed)
vec = [rng.uniform(-1.0, 1.0) for _ in range(EMBEDDING_DIM)]
# Guarantee non-degeneracy: nudge the first component away from 0 if the
# seeded draw happens to produce a very small value.
if abs(vec[0]) < 1e-6:
vec[0] = 0.1
return vec
@app.post("/v1/embeddings")
@app.post("/v1/v1/embeddings")
def embeddings() -> Response:
payload = request.get_json(silent=True) or {}
model = payload.get("model") or "text-embedding-3-small"
raw_input = payload.get("input", "")
if isinstance(raw_input, str):
inputs: list[str] = [raw_input]
elif isinstance(raw_input, list):
inputs = [str(item) if not isinstance(item, str) else item for item in raw_input]
else:
inputs = [str(raw_input)]
data = [
{
"object": "embedding",
"index": i,
"embedding": _deterministic_embedding(text),
}
for i, text in enumerate(inputs)
]
total_tokens = sum(_estimate_tokens(text) for text in inputs)
return jsonify(
{
"object": "list",
"data": data,
"model": model,
"usage": {
"prompt_tokens": total_tokens,
"total_tokens": total_tokens,
},
}
)
# ---------------------------------------------------------------------------
# Health
# ---------------------------------------------------------------------------
@app.get("/healthz")
def healthz() -> Response:
return jsonify({"ok": True})
# ---------------------------------------------------------------------------
# Entrypoint
# ---------------------------------------------------------------------------
def main() -> None:
FIXTURES_DIR.mkdir(parents=True, exist_ok=True)
sys.stderr.write(
f"[mock-llm] listening on http://{HOST}:{PORT} "
f"(fixtures: {FIXTURES_DIR})\n"
)
sys.stderr.flush()
# threaded=True so that concurrent streaming + embeddings requests from
# the Flask backend + Celery worker don't serialize behind each other.
app.run(host=HOST, port=PORT, debug=False, use_reloader=False, threaded=True)
if __name__ == "__main__":
main()
Whitespace-only changes.
+167
View File
@@ -0,0 +1,167 @@
# Mock LLM fixtures
This directory holds **canned OpenAI Chat Completions responses** keyed by a
SHA-256 fingerprint of the request. The stub server at
`scripts/e2e/mock_llm.py` looks up `<hash>.json` here for every
`POST /v1/chat/completions` request; if no fixture matches, the server returns
a generic deterministic fallback and logs the missing hash to stderr so you
can promote it into a fixture later.
Embeddings do not use fixtures — they are generated on the fly from a
hash-seeded RNG. Only chat completions are fixtured.
## Filename format
```
<sha256-hex>.json
```
The hash is a lowercase hex SHA-256 digest (64 characters, no prefix, no
extension beyond `.json`). Example:
```
3f5a7b9c...d12ef0.json
```
## How the hash is computed
The fingerprint covers only the fields that should control which canned answer
is returned:
```python
canonical = {
"model": payload.get("model"),
"messages": [minimal({role, content, name?, tool_call_id?, tool_calls?}) ...],
"tool_choice": payload.get("tool_choice"),
}
blob = json.dumps(canonical, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
digest = hashlib.sha256(blob.encode("utf-8")).hexdigest()
```
Notes:
- `temperature`, `top_p`, `seed`, `max_tokens`, `stream`, and any other
sampling/transport knobs **do not influence the hash**. Streaming and
non-streaming variants of the same request resolve to the same fixture.
- Message `content` is kept verbatim — if the app passes a list of content
parts (vision / multi-modal), the list is hashed as-is.
- `tool_calls` on assistant messages and `tool_call_id` / `name` on tool
messages are included because they change what the model is *replying to*.
The canonical source of the hashing logic is `_compute_request_digest` in
`scripts/e2e/mock_llm.py`. If you change that function, regenerate all
fixtures.
## Computing a fixture hash from the command line
The easiest path: run the e2e suite once with your new flow, grep stderr for
`[mock-llm] unknown fixture hash <hash>` along with the request dump on the
following line, and save the canned answer under `<hash>.json`. The up.sh log
tail preserves both lines.
If you need to compute a hash by hand from a request payload:
```python
# scripts/e2e/compute_hash.py (not committed; run ad hoc)
import hashlib, json, sys
payload = json.load(sys.stdin)
canonical = {
"model": payload.get("model"),
"messages": [
{k: v for k, v in msg.items() if k in {"role", "content", "name", "tool_call_id", "tool_calls"}}
for msg in payload.get("messages", [])
],
"tool_choice": payload.get("tool_choice"),
}
blob = json.dumps(canonical, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
print(hashlib.sha256(blob.encode("utf-8")).hexdigest())
```
```bash
cat request.json | python scripts/e2e/compute_hash.py
```
## Fixture JSON schema
```json
{
"request_digest": "<hash>",
"description": "Human description of when this is used",
"response": {
"content": "The canned assistant text, may include markdown.",
"tool_calls": null,
"finish_reason": "stop",
"usage": {"prompt_tokens": 12, "completion_tokens": 34}
}
}
```
### Field reference
| Field | Type | Required | Notes |
|---|---|---|---|
| `request_digest` | string | no (documentation only) | Must match the filename. The loader does not re-verify this, it's here for human review. |
| `description` | string | no | Short note on what flow this covers — makes grepping fixtures easier. |
| `response.content` | string | yes (if no `tool_calls`) | The assistant's reply body. Plain text or markdown. Empty string is legal when `tool_calls` is set. |
| `response.tool_calls` | array \| null | no | OpenAI tool-call shape: `[{"id": "call_x", "type": "function", "function": {"name": "...", "arguments": "{...}"}}]`. Arguments must be a JSON **string**, not an object. |
| `response.finish_reason` | string | no (defaults to `"stop"`) | Use `"tool_calls"` when returning tool calls, `"length"` to simulate truncation. |
| `response.usage.prompt_tokens` | number | no | Used verbatim in the non-streaming envelope. Default: estimated from request messages. |
| `response.usage.completion_tokens` | number | no | Default: estimated from `content`. |
`response.usage.total_tokens` is always recomputed as the sum — do not set it.
### Streaming behavior
The stub handles streaming vs non-streaming transparently for both content
and tool-call fixtures:
- **Content fixtures** are split into ~5 SSE deltas by character length. Only
the last delta carries `finish_reason`.
- **Tool-call fixtures** are emitted as a single delta containing the full
`tool_calls` array, followed by a final empty delta carrying `finish_reason`.
No fixture change is needed to toggle between streaming and non-streaming;
the app's `stream=true` flag alone controls it.
## Tool-call example
```json
{
"request_digest": "abc123...",
"description": "Agent calls the weather tool for 'weather in London?'",
"response": {
"content": "",
"tool_calls": [
{
"id": "call_e2e_weather_1",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"city\":\"London\"}"
}
}
],
"finish_reason": "tool_calls"
}
}
```
## Workflow for adding a fixture
1. Run the failing e2e spec. Watch `scripts/e2e/up.sh`'s log tail (or
`/tmp/docsgpt-e2e/mock_llm.log` depending on how orchestration pipes it).
2. Find the `[mock-llm] unknown fixture hash <hash>` warning and the request
dump on the following line.
3. Create `mock_llm_fixtures/<hash>.json` with the schema above.
4. Re-run the spec — the warning should disappear and the spec should pass.
5. Commit the fixture. Fixtures are checked into the repo so every developer
and every CI run gets the same canned answers.
## Determinism guarantees
- Same request → same hash → same fixture → same response, always.
- No `time.time()`, no random seeds, no environment dependence in the hash.
- Embeddings are hash-seeded but never all-zero (the stub nudges the first
component away from 0 if the seeded draw is near zero), so the vector store
ingest path never rejects them.
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Reset the DocsGPT e2e DB by cloning it from the baked template.
#
# Called by scripts/e2e/up.sh at the start of each e2e run.
# Fast path: a single psql session does terminate + drop + clone.
set -euo pipefail
PG_BIN="/Users/Shared/DBngin/postgresql/16.2/bin"
PSQL="${PG_BIN}/psql"
PG_ISREADY="${PG_BIN}/pg_isready"
PG_HOST="127.0.0.1"
PG_PORT="5432"
PG_SUPERUSER="postgres"
TEMPLATE_DB="docsgpt_e2e_template"
E2E_DB="docsgpt_e2e"
OWNER_ROLE="docsgpt"
log() {
printf '[reset_db] %s\n' "$*" >&2
}
if [[ ! -x "${PSQL}" ]]; then
log "psql not found at ${PSQL} — is DBngin Postgres 16.2 installed?"
exit 1
fi
if ! "${PG_ISREADY}" -h "${PG_HOST}" -p "${PG_PORT}" -q; then
log "Postgres is not accepting connections at ${PG_HOST}:${PG_PORT}."
exit 1
fi
# Verify the template exists before attempting to clone from it.
template_exists="$(
"${PSQL}" -h "${PG_HOST}" -p "${PG_PORT}" -U "${PG_SUPERUSER}" -d postgres \
-tAX -c "SELECT 1 FROM pg_database WHERE datname = '${TEMPLATE_DB}';"
)"
if [[ "${template_exists}" != "1" ]]; then
log "Template DB '${TEMPLATE_DB}' does not exist."
log "Run scripts/e2e/bake_template.sh once before the first e2e run."
exit 1
fi
"${PSQL}" -h "${PG_HOST}" -p "${PG_PORT}" -U "${PG_SUPERUSER}" -d postgres \
-v ON_ERROR_STOP=1 -X -q <<SQL
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = '${E2E_DB}'
AND pid <> pg_backend_pid();
DROP DATABASE IF EXISTS ${E2E_DB};
CREATE DATABASE ${E2E_DB} TEMPLATE ${TEMPLATE_DB} OWNER ${OWNER_ROLE};
SQL
exit 0
+12
View File
@@ -0,0 +1,12 @@
DO $$
DECLARE r record;
BEGIN
FOR r IN
SELECT tablename
FROM pg_tables
WHERE schemaname='public'
AND tablename <> 'alembic_version'
LOOP
EXECUTE format('TRUNCATE TABLE %I RESTART IDENTITY CASCADE', r.tablename);
END LOOP;
END $$;
+356
View File
@@ -0,0 +1,356 @@
#!/usr/bin/env bash
# scripts/e2e/up.sh
#
# Boot the DocsGPT end-to-end test stack on this machine, natively.
# See e2e-plan.md (Phase 0 / P0-A) for the contract.
#
# Happy path:
# 1. Preflight shared services (Postgres, Redis). Fail loud if down.
# 2. Reset state: Postgres template clone, Redis FLUSHDB 11/12/13, wipe .e2e-tmp.
# 3. Export env.
# 4. Start mock LLM (7899) → Flask (7099) → Celery → Vite (5179), each in
# background, each with its own pidfile + log + readiness probe.
# 5. Exit 0, leaving services running. Playwright (or the user) invokes
# down.sh separately when done.
#
# On error before handoff: tear everything down, non-zero exit.
# We explicitly DO NOT tear down on the happy-path exit — that would defeat
# the purpose of "up".
set -euo pipefail
# -----------------------------------------------------------------------------
# Paths
# -----------------------------------------------------------------------------
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
PG_BIN="/Users/Shared/DBngin/postgresql/16.2/bin"
DBNGIN_REDIS_BIN="/Users/Shared/DBngin/redis/7.0.0/bin"
# Resolve redis-cli — PATH first, then DBngin's bundled copy.
if command -v redis-cli >/dev/null 2>&1; then
REDIS_CLI="$(command -v redis-cli)"
elif [[ -x "$DBNGIN_REDIS_BIN/redis-cli" ]]; then
REDIS_CLI="$DBNGIN_REDIS_BIN/redis-cli"
else
REDIS_CLI=""
fi
PIDDIR="/tmp/docsgpt-e2e"
E2E_TMP="$REPO_ROOT/.e2e-tmp"
LOGDIR="$E2E_TMP/logs"
BOOT_LOG="$LOGDIR/up.log"
SVC_LOGDIR="$PIDDIR" # per-service logs live with the pidfiles per the brief
MOCK_LLM_PORT=7899
FLASK_PORT=7099
VITE_PORT=5179
# -----------------------------------------------------------------------------
# Bookkeeping — track which services we successfully started so we can tear
# them down if something later fails.
# -----------------------------------------------------------------------------
HANDOFF_OK=0
STARTED_SERVICES=()
log() {
local msg="[up.sh] $*"
# Goes to stderr so stdout stays clean; also mirrored to the boot log.
echo "$msg" >&2
if [[ -n "${BOOT_LOG:-}" ]] && [[ -d "$(dirname "$BOOT_LOG")" ]]; then
echo "$msg" >> "$BOOT_LOG"
fi
}
die() {
log "ERROR: $*"
exit 1
}
# Trap: if we exit before handoff (failure or Ctrl-C), clean up. The happy
# path sets HANDOFF_OK=1 just before `exit 0`, so the trap becomes a no-op.
cleanup_on_failure() {
local rc=$?
if [[ "$HANDOFF_OK" -eq 1 ]]; then
return 0
fi
log "aborting — tearing down any services that started (rc=$rc)"
if [[ -x "$SCRIPT_DIR/down.sh" ]]; then
"$SCRIPT_DIR/down.sh" || true
fi
}
trap cleanup_on_failure EXIT INT TERM
# -----------------------------------------------------------------------------
# Helpers
# -----------------------------------------------------------------------------
# Wait for a shell predicate to succeed. Args: <label> <timeout-seconds> <cmd...>
wait_for() {
local label="$1"
local timeout="$2"
shift 2
local elapsed=0
while (( elapsed < timeout )); do
if "$@" >/dev/null 2>&1; then
log " -> $label ready after ${elapsed}s"
return 0
fi
sleep 1
elapsed=$(( elapsed + 1 ))
done
return 1
}
# Wait for a substring to appear in a log file.
wait_for_log() {
local label="$1"
local timeout="$2"
local logfile="$3"
local needle="$4"
local elapsed=0
while (( elapsed < timeout )); do
if [[ -f "$logfile" ]] && grep -qF -- "$needle" "$logfile"; then
log " -> $label saw '$needle' after ${elapsed}s"
return 0
fi
sleep 1
elapsed=$(( elapsed + 1 ))
done
return 1
}
# Dump last 50 lines of a log file to stderr (for boot-failure diagnostics).
dump_tail() {
local label="$1"
local logfile="$2"
echo "---- last 50 lines of $label ($logfile) ----" >&2
if [[ -f "$logfile" ]]; then
tail -n 50 "$logfile" >&2 || true
else
echo "(log file does not exist)" >&2
fi
echo "---- end $label ----" >&2
}
# Boot-failure handler: dump the log, then let the trap tear everything down.
boot_fail() {
local svc="$1"
local logfile="$2"
local reason="$3"
log "boot failure: $svc — $reason"
dump_tail "$svc" "$logfile"
exit 1
}
# -----------------------------------------------------------------------------
# 1. Preflight
# -----------------------------------------------------------------------------
log "preflight: checking shared native services"
if [[ ! -x "$PG_BIN/pg_isready" ]]; then
die "pg_isready not found at $PG_BIN/pg_isready — is DBngin Postgres 16.2 installed?"
fi
if ! "$PG_BIN/pg_isready" -h 127.0.0.1 -p 5432 -U docsgpt -d postgres >/dev/null 2>&1; then
die "Postgres not reachable at 127.0.0.1:5432 as user 'docsgpt'. Start DBngin Postgres 16.2. (CLAUDE.md: do not kill/start this process from scripts.)"
fi
log " -> postgres OK"
if [[ -z "$REDIS_CLI" ]]; then
die "redis-cli not found on PATH nor at $DBNGIN_REDIS_BIN/redis-cli — install redis or adjust DBNGIN_REDIS_BIN"
fi
if ! "$REDIS_CLI" -h 127.0.0.1 -p 6379 PING 2>/dev/null | grep -q '^PONG$'; then
die "Redis not reachable at 127.0.0.1:6379. Start the native redis-server. (CLAUDE.md: do not kill/start this process from scripts.)"
fi
log " -> redis OK"
# -----------------------------------------------------------------------------
# 2. Reset state
# -----------------------------------------------------------------------------
log "resetting state"
# Wipe & recreate .e2e-tmp first so BOOT_LOG has a home.
rm -rf "$E2E_TMP"
mkdir -p "$E2E_TMP/inputs" "$E2E_TMP/indexes" "$LOGDIR"
: > "$BOOT_LOG"
log " -> .e2e-tmp wiped; logs at $LOGDIR"
mkdir -p "$PIDDIR"
# Leave existing per-service logs alone until we overwrite them at launch time;
# that way a prior failure log isn't immediately erased if someone re-runs up.
# Postgres reset — delegated to reset_db.sh (owned by track P0-B).
RESET_DB_SCRIPT="$SCRIPT_DIR/reset_db.sh"
if [[ ! -x "$RESET_DB_SCRIPT" ]]; then
die "reset_db.sh missing or not executable at $RESET_DB_SCRIPT — has track P0-B landed?"
fi
log " -> invoking reset_db.sh"
if ! "$RESET_DB_SCRIPT" >> "$BOOT_LOG" 2>&1; then
die "reset_db.sh failed — see $BOOT_LOG"
fi
# Redis reset — three dedicated DB indices.
for db in 11 12 13; do
if ! "$REDIS_CLI" -h 127.0.0.1 -p 6379 -n "$db" FLUSHDB >/dev/null 2>&1; then
die "redis-cli FLUSHDB failed on db $db"
fi
done
log " -> redis dbs 11/12/13 flushed"
# -----------------------------------------------------------------------------
# 3. Load env
# -----------------------------------------------------------------------------
log "sourcing env.sh"
# shellcheck source=./env.sh
source "$SCRIPT_DIR/env.sh"
# -----------------------------------------------------------------------------
# 4. Start services
# -----------------------------------------------------------------------------
# Pick Flask / python binaries from the repo venv when present.
if [[ -x "$REPO_ROOT/.venv/bin/flask" ]]; then
FLASK_BIN="$REPO_ROOT/.venv/bin/flask"
else
FLASK_BIN="$(command -v flask || true)"
fi
if [[ -z "$FLASK_BIN" ]]; then
die "flask binary not found (.venv/bin/flask missing and no 'flask' on PATH)"
fi
if [[ -x "$REPO_ROOT/.venv/bin/python" ]]; then
PY_BIN="$REPO_ROOT/.venv/bin/python"
else
PY_BIN="$(command -v python3 || command -v python || true)"
fi
if [[ -z "$PY_BIN" ]]; then
die "python binary not found (.venv/bin/python missing and no 'python3' on PATH)"
fi
log "using flask=$FLASK_BIN python=$PY_BIN"
# ---- 4a. Mock LLM ------------------------------------------------------------
MOCK_LLM_LOG="$SVC_LOGDIR/mock-llm.log"
MOCK_LLM_PID="$PIDDIR/mock-llm.pid"
log "starting mock LLM on 127.0.0.1:$MOCK_LLM_PORT"
(
cd "$REPO_ROOT"
# Port can be read from env by the script; we also export it for clarity.
MOCK_LLM_PORT="$MOCK_LLM_PORT" PYTHONUNBUFFERED=1 nohup "$PY_BIN" scripts/e2e/mock_llm.py \
>"$MOCK_LLM_LOG" 2>&1 &
echo $! > "$MOCK_LLM_PID"
)
STARTED_SERVICES+=("mock-llm")
if ! wait_for "mock-llm /healthz" 10 \
curl -sf "http://127.0.0.1:${MOCK_LLM_PORT}/healthz"; then
boot_fail "mock-llm" "$MOCK_LLM_LOG" "healthz did not respond within 10s"
fi
# ---- 4b. Flask ---------------------------------------------------------------
FLASK_LOG="$SVC_LOGDIR/flask.log"
FLASK_PID="$PIDDIR/flask.pid"
log "starting Flask on 127.0.0.1:$FLASK_PORT"
(
cd "$E2E_TMP"
PYTHONUNBUFFERED=1 nohup "$FLASK_BIN" --app ../application/app.py run \
--host 127.0.0.1 --port "$FLASK_PORT" \
>"$FLASK_LOG" 2>&1 &
echo $! > "$FLASK_PID"
)
STARTED_SERVICES+=("flask")
if ! wait_for "flask /api/config" 30 \
curl -sf "http://127.0.0.1:${FLASK_PORT}/api/config"; then
boot_fail "flask" "$FLASK_LOG" "/api/config did not respond within 30s"
fi
# ---- 4c. Celery --------------------------------------------------------------
CELERY_LOG="$SVC_LOGDIR/celery.log"
CELERY_PID="$PIDDIR/celery.pid"
log "starting Celery worker (solo pool)"
(
cd "$E2E_TMP"
PYTHONPATH="$REPO_ROOT${PYTHONPATH:+:$PYTHONPATH}" \
PYTHONUNBUFFERED=1 \
nohup "$PY_BIN" -m celery -A application.app.celery worker \
-l INFO --pool=solo \
--without-gossip --without-mingle --without-heartbeat \
>"$CELERY_LOG" 2>&1 &
echo $! > "$CELERY_PID"
)
STARTED_SERVICES+=("celery")
# Celery's "ready" banner contains both "celery@<host>" and "ready.". Wait for
# both in sequence so we know the worker actually finished bootstrapping.
if ! wait_for_log "celery 'celery@'" 30 "$CELERY_LOG" "celery@"; then
boot_fail "celery" "$CELERY_LOG" "never emitted 'celery@' banner within 30s"
fi
# Ready check via `celery inspect ping`. We can't grep the log for 'ready'
# because application/core/logging_config.py calls dictConfig with the default
# disable_existing_loggers=True, which silences celery.worker's ready banner.
# `inspect ping` queries the worker over the broker — it's the canonical
# responsiveness check and doesn't depend on log output.
CELERY_INSPECT_TIMEOUT=45
elapsed=0
ping_ok=0
while (( elapsed < CELERY_INSPECT_TIMEOUT )); do
if ( cd "$E2E_TMP" && \
PYTHONPATH="$REPO_ROOT${PYTHONPATH:+:$PYTHONPATH}" \
PYTHONUNBUFFERED=1 \
"$PY_BIN" -m celery -A application.app.celery inspect ping \
--timeout 2 >/dev/null 2>&1 ); then
ping_ok=1
log " -> celery inspect ping OK after ${elapsed}s"
break
fi
sleep 1
elapsed=$(( elapsed + 1 ))
done
if (( ping_ok == 0 )); then
boot_fail "celery" "$CELERY_LOG" "worker did not respond to 'inspect ping' within ${CELERY_INSPECT_TIMEOUT}s"
fi
# ---- 4d. Vite dev server -----------------------------------------------------
VITE_LOG="$SVC_LOGDIR/vite.log"
VITE_PID="$PIDDIR/vite.pid"
log "starting Vite dev server on 127.0.0.1:$VITE_PORT"
(
cd "$REPO_ROOT/frontend"
VITE_API_HOST="http://127.0.0.1:${FLASK_PORT}" nohup npm run dev -- \
--host 127.0.0.1 --port "$VITE_PORT" --strictPort \
>"$VITE_LOG" 2>&1 &
echo $! > "$VITE_PID"
)
STARTED_SERVICES+=("vite")
# Prefer nc; fall back to lsof. Either succeeding means the port is LISTEN.
vite_ready() {
if command -v nc >/dev/null 2>&1; then
nc -z 127.0.0.1 "$VITE_PORT" >/dev/null 2>&1 && return 0
fi
if command -v lsof >/dev/null 2>&1; then
[[ -n "$(lsof -nP -iTCP:"$VITE_PORT" -sTCP:LISTEN -t 2>/dev/null)" ]] && return 0
fi
return 1
}
if ! wait_for "vite LISTEN on $VITE_PORT" 20 vite_ready; then
boot_fail "vite" "$VITE_LOG" "port $VITE_PORT never entered LISTEN within 20s"
fi
# -----------------------------------------------------------------------------
# 5. Handoff
# -----------------------------------------------------------------------------
log "all services up:"
log " mock-llm pid=$(cat "$MOCK_LLM_PID") log=$MOCK_LLM_LOG"
log " flask pid=$(cat "$FLASK_PID") log=$FLASK_LOG"
log " celery pid=$(cat "$CELERY_PID") log=$CELERY_LOG"
log " vite pid=$(cat "$VITE_PID") log=$VITE_LOG"
log "handoff complete — exiting 0, services remain running. Run scripts/e2e/down.sh to stop."
HANDOFF_OK=1
exit 0