Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions backend/cortex_backend/api/routers/chats.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ def rename_chat(
_: SessionPrincipal = Depends(require_session),
) -> ChatResponse:
try:
if deps.chats.get_chat_overview(thread_id) is None:
if not deps.chats.chat_exists(thread_id):
raise HTTPException(status_code=404, detail="Chat not found.")
deps.chats.rename_chat(thread_id, payload.title.strip())
chat = deps.chats.get_chat(thread_id)
Expand Down Expand Up @@ -268,10 +268,14 @@ def fork_chat(
_: SessionPrincipal = Depends(require_session),
) -> ChatResponse:
try:
source = deps.chats.get_chat(thread_id)
if source is None:
# Only the ids are needed to check the message is in this chat.
# The fork itself copies every field, but reading and decoding the
# whole transcript here as well was a second full read for one
# membership test.
source_ids = deps.chats.get_messages(thread_id, columns=("id",))
if source_ids is None:
raise HTTPException(status_code=404, detail="Chat not found.")
message_position(source, payload.message_id)
message_position({"messages": source_ids}, payload.message_id)
new_thread_id = uuid4().hex
deps.chats.fork_chat(thread_id, payload.message_id, new_thread_id)
forked = deps.chats.get_chat(new_thread_id)
Expand Down
177 changes: 149 additions & 28 deletions backend/cortex_backend/api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from uuid import uuid4

from fastapi import HTTPException, Request, status
from pydantic import ValidationError

from cortex_backend.core.generation import ConnectionResult, GenerationAttachment, GenerationSnapshot
from cortex_backend.services.chat import (
Expand All @@ -36,12 +37,14 @@
from cortex_backend.services.chat_client import KEEP_ALIVE_OPTION, ollama_keep_alive
from cortex_backend.services.code_feedback import format_execution_observation
from cortex_backend.services.code_prompt import should_offer_code_execution
from cortex_backend.services.generation import HISTORY_MESSAGE_FIELDS
from cortex_backend.core.settings import (
CortexSettings,
GENERATION_OVERRIDE_FIELDS,
GenerationOptionsOverride,
)
from cortex_backend.repositories.chats import (
ChatNotFound,
ChatRepositoryError,
)
from cortex_backend.repositories.settings import (
Expand Down Expand Up @@ -321,10 +324,10 @@ async def _start_generation_job(
validate the target, and ``transcript_revision``, the revision that read
returned (a regeneration changes a message without changing how many there
are, so the revision cannot be derived from the transcript), so the chat is
not read again. A new turn passes neither: admission needs only the chat's
overview, and preparation reads the transcript once, because the model
needs it as history. Either way the generation service is handed its
history and never loads it itself.
not read again. A new turn passes neither: admission reads the chat's
overview and then the transcript once, only the fields history is built
from. Either way the generation service is handed its history and never
loads it itself.
"""
regenerating = target_message_id is not None
if regenerating != (transcript is not None) or regenerating != (transcript_revision is not None):
Expand Down Expand Up @@ -356,6 +359,8 @@ async def _start_generation_job(
try:
target_position = -1
target_role = ""
# A new turn's transcript, read below; None while the chat does not exist.
earlier_messages: list[dict[str, Any]] | None = None
if (
transcript is not None
and target_message_id is not None
Expand All @@ -378,6 +383,23 @@ async def _start_generation_job(
# always matches) is about to become a new chat's primary key.
_reject_invalid_new_chat_thread_id(thread_id)
admission_revision = int(overview["revision"]) if overview is not None else 0
if overview is not None:
# The one transcript read of a new turn, and only the fields
# history is built from. It is taken at admission rather than
# in prepare(): a follow-up needs the documents of the message
# before it resolved like any attachment, before the job
# starts. prepare() appends the user turn guarded by the
# revision above, so a write that lands after this read fails
# that append instead of being answered around.
earlier_messages = await asyncio.to_thread(
deps.chats.get_messages, thread_id, columns=HISTORY_MESSAGE_FIELDS
)
if earlier_messages is None:
# Deleted between the two reads.
raise ChatDomainError(
"This chat changed. Reload it before generating again.",
code="stale_revision",
)
if (
payload.base_revision is not None
and admission_revision != payload.base_revision
Expand All @@ -389,18 +411,27 @@ async def _start_generation_job(

settings = await asyncio.to_thread(_load_settings, deps)
attachment_refs = list(payload.attachments)
if transcript is not None and not attachment_refs:
# A dangling user turn's own attachments are the ones to resend; an
# assistant reply being regenerated has none of its own, so fall
# back to the user turn before it instead.
# Everything the user and the model said before the turn being answered.
conversation_so_far: Sequence[Mapping[str, Any]] = earlier_messages or ()
if transcript is not None:
# A dangling user turn is itself the turn to answer; an assistant
# reply being regenerated answers the user turn before it.
source_position = (
target_position if target_role == "user" else target_position - 1
)
if source_position >= 0:
conversation_so_far = transcript[: max(source_position, 0)]
if not attachment_refs and source_position >= 0:
# That turn's own attachments are the ones to resend.
attachment_refs = [
ChatAttachment.model_validate(item)
for item in (transcript[source_position].get("attachments") or [])
]
# A turn that attaches nothing keeps the documents of the message before
# it: "summarise this", then "now list its headings". Never persisted on
# this turn, which the user did not attach them to.
earlier_documents = (
[] if attachment_refs else _previous_turn_documents(conversation_so_far)
)
generation_payload = payload.model_copy(
update={"thread_id": thread_id, "attachments": attachment_refs}
)
Expand Down Expand Up @@ -436,6 +467,10 @@ async def _start_generation_job(
attachment_refs,
settings=settings,
)
if earlier_documents:
resolved_attachments += await asyncio.to_thread(
_resolve_earlier_documents, request, deps, principal, earlier_documents
)
generation_snapshot = _generation_snapshot(
reservation.snapshot.job_id,
generation_payload,
Expand Down Expand Up @@ -484,28 +519,30 @@ def prepare() -> Mapping[str, Any]:
)
prepared_revision = admission_revision
return {"user_message_id": None}
# The one full read of a new turn: the model needs the transcript
# as history, so it is taken here rather than by the generation
# service afterwards. It is read before the user turn is written,
# and that write is guarded by the revision it was read at.
current_chat = deps.chats.get_chat(thread_id)
existing = list(current_chat.get("messages", ())) if current_chat else []
if (chat_revision(current_chat) if current_chat else 0) != admission_revision:
existing = list(earlier_messages or ())
try:
# Guarded by the revision the chat was admitted at: a write
# that landed since, including one between that check and the
# transcript read, fails this append instead of being answered
# around. So the history read at admission is the chat as
# admitted.
user_message_id = deps.chats.add_message(
thread_id,
"user",
payload.user_input,
attachments=[
attachment.model_dump(mode="json")
for attachment in attachment_refs
],
thread_title="New Chat" if earlier_messages is None else None,
expected_revision=admission_revision,
)
except ChatNotFound as exc:
# An empty chat (revision 0) deleted since it was admitted.
raise ChatDomainError(
"This chat changed. Reload it before generating again.",
code="stale_revision",
)
user_message_id = deps.chats.add_message(
thread_id,
"user",
payload.user_input,
attachments=[
attachment.model_dump(mode="json")
for attachment in attachment_refs
],
thread_title="New Chat" if current_chat is None else None,
expected_revision=admission_revision,
)
) from exc
# Only the revision is needed here, so read the overview
# rather than every message of what may be a long thread.
overview = deps.chats.get_chat_overview(thread_id)
Expand Down Expand Up @@ -1771,6 +1808,90 @@ def _resolve_generation_attachments(
return tuple(resolved)


def _previous_turn_documents(
messages: Sequence[Mapping[str, Any]],
) -> list[ChatAttachment]:
"""The documents attached to the user's most recent message, as stored.

Only that one message: a document comes back for the follow-up right after
it, not for every later turn, so its text does not ride along -- and cost
a re-read of the whole prompt -- long after the conversation moved on.
Images stay with the turn they were sent on. Metadata that no longer
validates is skipped, and the set is held to what one message may carry.
"""
previous = next(
(message for message in reversed(messages) if message.get("role") == "user"),
None,
)
stored = previous.get("attachments") if previous is not None else None
if not isinstance(stored, list):
return []
documents: list[ChatAttachment] = []
total = 0
for item in stored:
if not isinstance(item, Mapping) or item.get("kind") != "document":
continue
try:
reference = ChatAttachment.model_validate(item)
except ValidationError:
continue
if (
len(documents) == MAX_CHAT_ATTACHMENTS
or total + reference.size > MAX_CHAT_ATTACHMENT_TOTAL_BYTES
):
break
documents.append(reference)
total += reference.size
return documents


def _resolve_earlier_documents(
request: Request,
deps: BackendDependenciesProtocol,
principal: SessionPrincipal,
references: Sequence[ChatAttachment],
) -> tuple[GenerationAttachment, ...]:
"""Resolve the previous message's documents, leaving out any that are gone.

Unlike the user's own attachments, which refuse the turn when one cannot
be resolved, these were not asked for: a document deleted or expired since
simply does not come back, and the history still names it on the turn it
was attached to. Ownership and integrity are checked exactly as for a new
attachment. Only a count is logged, never a name or any text.
"""
try:
service = _chat_attachment_service(request, deps)
except HTTPException:
return ()
owner = _attachment_owner(request, principal)
resolved: list[GenerationAttachment] = []
unavailable = 0
for reference in references:
try:
item = service.resolve(owner=owner, descriptor=reference.model_dump(mode="json"))
except ChatAttachmentError:
unavailable += 1
continue
if item.descriptor.kind != "document" or item.text_content is None:
continue
resolved.append(
GenerationAttachment(
attachment_id=item.descriptor.attachment_id,
filename=item.descriptor.filename,
mime_type=item.descriptor.mime_type,
kind="document",
text_content=item.text_content,
from_earlier_message=True,
)
)
if unavailable:
logger.info(
"Left %d document(s) from the previous message out of a follow-up: no longer available.",
unavailable,
)
return tuple(resolved)


def _execution_repository(request: Request):
return _execution_runtime(request).repository

Expand Down
4 changes: 4 additions & 0 deletions backend/cortex_backend/core/generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,10 @@ class GenerationAttachment:
kind: Literal["image", "document"]
text_content: str | None = None
image_base64: str | None = None
# A document the user attached to their previous message, resent so a
# follow-up ("now list its headings") still has the text it is about. The
# prompt sets these apart from what the user attached this time.
from_earlier_message: bool = False


@dataclass(frozen=True, slots=True)
Expand Down
4 changes: 3 additions & 1 deletion backend/cortex_backend/core/paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
import sys
from pathlib import PureWindowsPath

from .win_paths import system32


ORGANIZATION_NAME = "ChatLLM"
APPLICATION_NAME = "ChatLLM-Assistant"
Expand Down Expand Up @@ -97,7 +99,7 @@ def secure_private_path(path: str | os.PathLike[str], *, directory: bool) -> Pat
rights = "(OI)(CI)F" if directory else "F"
result = subprocess.run(
[
"icacls",
system32("icacls.exe"),
str(target),
"/inheritance:r",
"/grant:r",
Expand Down
76 changes: 76 additions & 0 deletions backend/cortex_backend/core/win_paths.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""Absolute paths for the Windows tools Cortex runs.

Cortex starts a few of Windows' own executables: PowerShell (to check the
bundled WebView2 installer's signature before running it), ``icacls`` (to make
its data folders private) and ``taskkill`` (to end a development server's
process tree). Started by bare name they are whatever comes first on ``PATH``,
which the user, or anything they install, can change. No privilege boundary is
crossed either way, but the signature check is the gate before an installer
runs and should not depend on that, so these are resolved under the Windows
system directory instead.
"""

from __future__ import annotations

import ctypes
import os
from pathlib import Path
import sys


# Tools that live in a folder of their own under System32.
_SUBDIRECTORIES = {
"powershell.exe": ("WindowsPowerShell", "v1.0"),
}
_MAX_PATH = 260


def _system_directory_from_api() -> Path | None:
"""``GetSystemDirectoryW``: the real System32, whatever the environment says."""
try:
buffer = ctypes.create_unicode_buffer(_MAX_PATH)
length = ctypes.windll.kernel32.GetSystemDirectoryW(buffer, _MAX_PATH)
except (AttributeError, OSError):
return None
if not 0 < length < _MAX_PATH:
return None
return Path(buffer.value)


def system_directory() -> Path | None:
"""The Windows system directory (normally ``C:\\Windows\\System32``).

Asked of Windows first, because ``%SystemRoot%`` is an environment variable
like ``PATH``; the variable (or ``%windir%``) is only the fallback. ``None``
off Windows, or when neither answers with an absolute path.
"""
if sys.platform != "win32":
return None
directory = _system_directory_from_api()
if directory is not None and directory.is_absolute():
return directory
for variable in ("SystemRoot", "windir"):
root = os.environ.get(variable, "").strip()
if root and Path(root).is_absolute():
return Path(root) / "System32"
return None


def system32(name: str) -> str:
"""The absolute path of the Windows tool ``name`` (for example ``"icacls.exe"``).

PowerShell is found in its ``WindowsPowerShell\\v1.0`` folder. When the
absolute path does not exist (off Windows, or an unusual installation) the
bare ``name`` comes back, so the tool is still found the way it always was
rather than not at all.
"""
directory = system_directory()
if directory is None:
return name
candidate = directory.joinpath(*_SUBDIRECTORIES.get(name.lower(), ()), name)
try:
if candidate.is_file():
return str(candidate)
except OSError:
pass
return name
Loading
Loading