Files
Alex 574f96341e 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.
2026-09-07 10:20:43 +01:00

121 lines
4.8 KiB
Python

import base64
import binascii
import uuid
from typing import Any, Dict, Generator, Optional, Union
from docsgpt.llm.handlers.base import LLMHandler, LLMResponse, ToolCall
def _encode_thought_signature(sig: Optional[Union[bytes, str]]) -> Optional[str]:
# Gemini's Python SDK returns thought_signature as raw bytes, but the
# field is typed Optional[str] downstream and gets json.dumps'd into
# SSE events. Encode once at ingress so callers only ever see a str.
if isinstance(sig, bytes):
return base64.b64encode(sig).decode("ascii")
return sig
def _decode_thought_signature(
sig: Optional[Union[bytes, str]],
) -> Optional[Union[bytes, str]]:
# Reverse of _encode_thought_signature — Gemini's SDK expects bytes
# back when we replay a tool call. ``validate=True`` keeps ASCII
# strings that happen to be loosely decodable from being silently
# turned into bytes; non-base64 inputs pass through unchanged.
if isinstance(sig, str):
try:
return base64.b64decode(sig.encode("ascii"), validate=True)
except (binascii.Error, ValueError):
return sig
return sig
class GoogleLLMHandler(LLMHandler):
"""Handler for Google's GenAI API."""
def parse_response(self, response: Any) -> LLMResponse:
"""Parse Google response into standardized format."""
if isinstance(response, str):
return LLMResponse(
content=response,
tool_calls=[],
finish_reason="stop",
raw_response=response,
)
if hasattr(response, "candidates"):
parts = response.candidates[0].content.parts if response.candidates else []
tool_calls = []
for idx, part in enumerate(parts):
if hasattr(part, "function_call") and part.function_call is not None:
has_sig = hasattr(part, "thought_signature") and part.thought_signature is not None
thought_sig = _encode_thought_signature(part.thought_signature) if has_sig else None
tool_calls.append(
ToolCall(
id=str(uuid.uuid4()),
name=part.function_call.name,
arguments=part.function_call.args,
index=idx,
thought_signature=thought_sig,
)
)
content = " ".join(
part.text
for part in parts
if hasattr(part, "text") and part.text is not None
)
return LLMResponse(
content=content,
tool_calls=tool_calls,
finish_reason="tool_calls" if tool_calls else "stop",
raw_response=response,
)
else:
# This branch handles individual Part objects from streaming responses
tool_calls = []
if hasattr(response, "function_call") and response.function_call is not None:
has_sig = hasattr(response, "thought_signature") and response.thought_signature is not None
thought_sig = _encode_thought_signature(response.thought_signature) if has_sig else None
tool_calls.append(
ToolCall(
id=str(uuid.uuid4()),
name=response.function_call.name,
arguments=response.function_call.args,
thought_signature=thought_sig,
)
)
return LLMResponse(
content=response.text if hasattr(response, "text") else "",
tool_calls=tool_calls,
finish_reason="tool_calls" if tool_calls else "stop",
raw_response=response,
)
def create_tool_message(self, tool_call: ToolCall, result: Any) -> Dict:
"""Create a tool result message in the standard internal format."""
import json as _json
from docsgpt.storage.db.serialization import PGNativeJSONEncoder
# PostgresTool results commonly include PG-native types
# (datetime / UUID / Decimal / bytea) when SELECT touches
# timestamptz / numeric / uuid / bytea columns. The shared
# encoder handles all five — bytes get base64 (lossless) instead
# of the ``str(b'...')`` repr that ``default=str`` would emit.
content = (
_json.dumps(result, cls=PGNativeJSONEncoder)
if not isinstance(result, str)
else result
)
return {
"role": "tool",
"tool_call_id": tool_call.id,
"content": content,
}
def _iterate_stream(self, response: Any) -> Generator:
"""Iterate through Google streaming response."""
for chunk in response:
yield chunk