docs: 更新协议文档,删除废弃计划文档
- 更新 http-error-codes, user-points-chat-data-protocol - 更新 divination-run-protocol, profile-protocol - 删除废弃的后端和前端设计计划文档
This commit is contained in:
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from decimal import Decimal
|
||||
from typing import Protocol
|
||||
from typing import Any, Protocol
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from sqlalchemy import Select, func, select
|
||||
@@ -45,8 +45,10 @@ class AgentRepository:
|
||||
detail="Invalid session_id",
|
||||
) from exc
|
||||
|
||||
stmt = select(AgentChatSession.user_id).where(
|
||||
AgentChatSession.id == session_uuid
|
||||
stmt = (
|
||||
select(AgentChatSession.user_id)
|
||||
.where(AgentChatSession.id == session_uuid)
|
||||
.where(AgentChatSession.deleted_at.is_(None))
|
||||
)
|
||||
owner_id = (await self._session.execute(stmt)).scalar_one_or_none()
|
||||
if owner_id is None:
|
||||
@@ -103,10 +105,18 @@ class AgentRepository:
|
||||
code="AGENT_SESSION_ID_INVALID",
|
||||
detail="Invalid session_id",
|
||||
) from exc
|
||||
session = await self._session.get(AgentChatSession, session_uuid)
|
||||
if session is not None:
|
||||
await self._session.delete(session)
|
||||
await self._session.flush()
|
||||
stmt = (
|
||||
select(AgentChatSession)
|
||||
.where(AgentChatSession.id == session_uuid)
|
||||
.with_for_update()
|
||||
)
|
||||
session = (await self._session.execute(stmt)).scalar_one_or_none()
|
||||
if session is None:
|
||||
return
|
||||
if session.deleted_at is not None:
|
||||
return
|
||||
session.deleted_at = datetime.now(timezone.utc)
|
||||
await self._session.flush()
|
||||
|
||||
async def persist_user_message(
|
||||
self,
|
||||
@@ -263,6 +273,37 @@ class AgentRepository:
|
||||
"messages": snapshot_messages,
|
||||
}
|
||||
|
||||
async def get_session_messages(
|
||||
self,
|
||||
*,
|
||||
session_id: str,
|
||||
visibility_mask: int | None = None,
|
||||
) -> list[dict[str, object]]:
|
||||
try:
|
||||
session_uuid = UUID(session_id)
|
||||
except ValueError as exc:
|
||||
raise ApiProblemError(
|
||||
status_code=422,
|
||||
code="AGENT_SESSION_ID_INVALID",
|
||||
detail="Invalid session_id",
|
||||
) from exc
|
||||
|
||||
message_stmt = (
|
||||
select(AgentChatMessage)
|
||||
.where(AgentChatMessage.session_id == session_uuid)
|
||||
.where(AgentChatMessage.deleted_at.is_(None))
|
||||
.order_by(AgentChatMessage.seq.asc())
|
||||
)
|
||||
message_stmt = self._apply_visibility_filter(
|
||||
stmt=message_stmt,
|
||||
visibility_mask=visibility_mask,
|
||||
)
|
||||
messages = (await self._session.execute(message_stmt)).scalars().all()
|
||||
snapshot_messages: list[dict[str, object]] = []
|
||||
for message in messages:
|
||||
snapshot_messages.append(await self._to_snapshot_message(message))
|
||||
return snapshot_messages
|
||||
|
||||
async def get_recent_messages_by_user_window(
|
||||
self,
|
||||
*,
|
||||
@@ -371,16 +412,32 @@ class AgentRepository:
|
||||
.where(AgentChatMessage.deleted_at.is_(None))
|
||||
.where(AgentChatMessage.role == AgentChatMessageRole.ASSISTANT)
|
||||
.order_by(AgentChatMessage.created_at.desc())
|
||||
.limit(1)
|
||||
.limit(20)
|
||||
)
|
||||
message_stmt = self._apply_visibility_filter(
|
||||
stmt=message_stmt,
|
||||
visibility_mask=visibility_mask,
|
||||
)
|
||||
message = (await self._session.execute(message_stmt)).scalar_one_or_none()
|
||||
if message is None:
|
||||
candidate_messages = (
|
||||
(await self._session.execute(message_stmt)).scalars().all()
|
||||
)
|
||||
if not candidate_messages:
|
||||
continue
|
||||
snapshots.append(await self._to_snapshot_message(message))
|
||||
selected_snapshot: dict[str, object] | None = None
|
||||
for message in candidate_messages:
|
||||
snapshot = await self._to_snapshot_message(message)
|
||||
metadata = snapshot.get("metadata")
|
||||
if not isinstance(metadata, dict):
|
||||
continue
|
||||
agent_output = metadata.get("agent_output")
|
||||
if not isinstance(agent_output, dict):
|
||||
continue
|
||||
derived = agent_output.get("divination_derived")
|
||||
if isinstance(derived, dict) and derived:
|
||||
selected_snapshot = snapshot
|
||||
break
|
||||
if selected_snapshot is not None:
|
||||
snapshots.append(selected_snapshot)
|
||||
|
||||
snapshots.sort(
|
||||
key=lambda item: str(item.get("timestamp") or ""),
|
||||
@@ -416,6 +473,7 @@ class AgentRepository:
|
||||
payload_model = AgentChatMessageSchema.model_validate(
|
||||
{
|
||||
"id": str(message.id),
|
||||
"session_id": str(message.session_id),
|
||||
"seq": int(message.seq),
|
||||
"role": role,
|
||||
"content": message.content,
|
||||
@@ -434,9 +492,9 @@ class AgentRepository:
|
||||
def _apply_visibility_filter(
|
||||
self,
|
||||
*,
|
||||
stmt: Select,
|
||||
stmt: Select[Any],
|
||||
visibility_mask: int | None,
|
||||
) -> Select:
|
||||
) -> Select[Any]:
|
||||
if visibility_mask is None:
|
||||
return stmt
|
||||
required_mask = max(int(visibility_mask), 0)
|
||||
|
||||
@@ -5,7 +5,6 @@ import os
|
||||
import re
|
||||
import tempfile
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import date
|
||||
from typing import Annotated
|
||||
|
||||
from ag_ui.core import RunAgentInput
|
||||
@@ -25,6 +24,7 @@ from fastapi import (
|
||||
Form,
|
||||
Header,
|
||||
Query,
|
||||
Response,
|
||||
Request,
|
||||
UploadFile,
|
||||
status,
|
||||
@@ -297,15 +297,23 @@ async def get_user_history_snapshot(
|
||||
service: Annotated[AgentService, Depends(get_agent_service)],
|
||||
current_user: Annotated[CurrentUser, Depends(get_current_user)],
|
||||
thread_id: str | None = Query(default=None, alias="threadId"),
|
||||
before: date | None = Query(default=None),
|
||||
) -> HistorySnapshotResponse:
|
||||
return await service.get_user_history_snapshot(
|
||||
current_user=current_user,
|
||||
thread_id=thread_id,
|
||||
before=before,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/sessions/{thread_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_session(
|
||||
thread_id: str,
|
||||
service: Annotated[AgentService, Depends(get_agent_service)],
|
||||
current_user: Annotated[CurrentUser, Depends(get_current_user)],
|
||||
) -> Response:
|
||||
await service.delete_session(thread_id=thread_id, current_user=current_user)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/attachments",
|
||||
response_model=AttachmentUploadResponse,
|
||||
|
||||
@@ -7,6 +7,7 @@ from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from schemas.agent.runtime_models import ErrorInfo
|
||||
from schemas.domain.divination import DerivedDivinationData
|
||||
|
||||
|
||||
@@ -21,6 +22,8 @@ class AgentRepositoryLike(Protocol):
|
||||
|
||||
async def rollback(self) -> None: ...
|
||||
|
||||
async def delete_session(self, *, session_id: str) -> None: ...
|
||||
|
||||
async def get_history_day(
|
||||
self,
|
||||
*,
|
||||
@@ -29,6 +32,13 @@ class AgentRepositoryLike(Protocol):
|
||||
visibility_mask: int | None = None,
|
||||
) -> dict[str, object] | None: ...
|
||||
|
||||
async def get_session_messages(
|
||||
self,
|
||||
*,
|
||||
session_id: str,
|
||||
visibility_mask: int | None = None,
|
||||
) -> list[dict[str, object]]: ...
|
||||
|
||||
async def get_latest_session_id_for_user(self, *, user_id: str) -> str | None: ...
|
||||
|
||||
async def get_latest_assistant_messages_by_user_sessions(
|
||||
@@ -186,6 +196,7 @@ class HistoryMessage(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
|
||||
|
||||
id: str = Field(description="Message UUID")
|
||||
thread_id: str = Field(alias="threadId", description="Owning session UUID")
|
||||
seq: int = Field(description="Message sequence number")
|
||||
role: Literal["user", "assistant"] = Field(
|
||||
description="Message role: user | assistant"
|
||||
@@ -213,6 +224,7 @@ class HistoryAgentOutput(BaseModel):
|
||||
advice: list[str] = Field(default_factory=list)
|
||||
keywords: list[str] = Field(default_factory=list)
|
||||
answer: str | None = None
|
||||
error: ErrorInfo | None = None
|
||||
divination_derived: DerivedDivinationData | None = None
|
||||
|
||||
|
||||
@@ -221,7 +233,10 @@ class HistorySnapshotResponse(BaseModel):
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
|
||||
|
||||
scope: str = Field(default="history_day")
|
||||
scope: str = Field(
|
||||
default="history_session_full",
|
||||
description="history_session_full | history_sessions_latest_assistant",
|
||||
)
|
||||
thread_id: str | None = Field(default=None, alias="threadId")
|
||||
day: str | None = None
|
||||
has_more: bool = Field(default=False, alias="hasMore")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timezone
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
|
||||
from urllib.parse import urlparse
|
||||
@@ -46,7 +46,7 @@ from v1.agent.utils import (
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
MAX_RUNS_PER_SESSION = 4
|
||||
MAX_RUNS_PER_SESSION = 2
|
||||
|
||||
|
||||
def ensure_session_owner(*, owner_id: str, current_user: CurrentUser) -> None:
|
||||
@@ -94,6 +94,15 @@ class AgentService:
|
||||
forwarded_props = getattr(run_input, "forwarded_props", None)
|
||||
try:
|
||||
runtime_mode = parse_forwarded_props_runtime_mode(forwarded_props)
|
||||
except ValueError as exc:
|
||||
raise ApiProblemError(
|
||||
status_code=422,
|
||||
detail=problem_payload(
|
||||
code="AGENT_RUNTIME_MODE_INVALID",
|
||||
detail="Invalid forwardedProps.runtime_mode",
|
||||
),
|
||||
) from exc
|
||||
try:
|
||||
divination_payload = parse_forwarded_props_divination_payload(
|
||||
forwarded_props
|
||||
)
|
||||
@@ -123,6 +132,14 @@ class AgentService:
|
||||
except ApiProblemError as exc:
|
||||
if exc.status_code != 404:
|
||||
raise
|
||||
if runtime_mode == RuntimeMode.FOLLOW_UP:
|
||||
raise ApiProblemError(
|
||||
status_code=404,
|
||||
detail=problem_payload(
|
||||
code="AGENT_SESSION_NOT_FOUND",
|
||||
detail="Session not found",
|
||||
),
|
||||
) from exc
|
||||
created = await self._create_session_if_missing(
|
||||
thread_id=thread_id,
|
||||
current_user=current_user,
|
||||
@@ -204,6 +221,22 @@ class AgentService:
|
||||
accepted=True,
|
||||
)
|
||||
|
||||
async def delete_session(
|
||||
self,
|
||||
*,
|
||||
thread_id: str,
|
||||
current_user: CurrentUser,
|
||||
) -> None:
|
||||
try:
|
||||
owner = await self._repository.get_session_owner(session_id=thread_id)
|
||||
except ApiProblemError as exc:
|
||||
if exc.status_code == 404:
|
||||
return
|
||||
raise
|
||||
ensure_session_owner(owner_id=owner, current_user=current_user)
|
||||
await self._repository.delete_session(session_id=thread_id)
|
||||
await self._repository.commit()
|
||||
|
||||
async def _append_context_cache_user_message(
|
||||
self,
|
||||
*,
|
||||
@@ -226,30 +259,21 @@ class AgentService:
|
||||
if isinstance(metadata_payload, dict):
|
||||
message_payload["metadata"] = metadata_payload
|
||||
|
||||
try:
|
||||
context_cache = create_context_messages_cache()
|
||||
await context_cache.append_message(
|
||||
thread_id=thread_id,
|
||||
runtime_mode=runtime_mode.value,
|
||||
visibility_mask=visibility_mask,
|
||||
message=message_payload,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to append user message to context cache",
|
||||
thread_id=thread_id,
|
||||
runtime_mode=runtime_mode.value,
|
||||
error=str(exc),
|
||||
)
|
||||
context_cache = create_context_messages_cache()
|
||||
await context_cache.append_message(
|
||||
thread_id=thread_id,
|
||||
runtime_mode=runtime_mode.value,
|
||||
visibility_mask=visibility_mask,
|
||||
message=message_payload,
|
||||
)
|
||||
|
||||
async def _resolve_user_message_visibility_mask(
|
||||
self, *, runtime_mode: RuntimeMode
|
||||
) -> int:
|
||||
if runtime_mode == RuntimeMode.CHAT:
|
||||
return bit_mask(bit=int(SystemVisibilityBit.UI_HISTORY)) | bit_mask(
|
||||
bit=int(SystemVisibilityBit.CONTEXT_ASSEMBLY)
|
||||
)
|
||||
return 0
|
||||
_ = runtime_mode
|
||||
return bit_mask(bit=int(SystemVisibilityBit.UI_HISTORY)) | bit_mask(
|
||||
bit=int(SystemVisibilityBit.CONTEXT_ASSEMBLY)
|
||||
)
|
||||
|
||||
async def _prepare_user_message(
|
||||
self,
|
||||
@@ -571,7 +595,6 @@ class AgentService:
|
||||
self,
|
||||
*,
|
||||
thread_id: str,
|
||||
before: date | None,
|
||||
current_user: CurrentUser,
|
||||
) -> HistorySnapshotResponse:
|
||||
from schemas.domain.chat_message import AgentChatMessage
|
||||
@@ -580,57 +603,47 @@ class AgentService:
|
||||
|
||||
owner = await self._repository.get_session_owner(session_id=thread_id)
|
||||
ensure_session_owner(owner_id=owner, current_user=current_user)
|
||||
day_payload = await self._repository.get_history_day(
|
||||
raw_messages = await self._repository.get_session_messages(
|
||||
session_id=thread_id,
|
||||
before=before,
|
||||
visibility_mask=bit_mask(bit=int(SystemVisibilityBit.UI_HISTORY)),
|
||||
)
|
||||
|
||||
messages: list[HistoryMessage] = []
|
||||
if day_payload:
|
||||
raw_messages_obj = day_payload.get("messages")
|
||||
raw_messages = (
|
||||
raw_messages_obj if isinstance(raw_messages_obj, list) else []
|
||||
)
|
||||
for msg_dict in raw_messages:
|
||||
msg = AgentChatMessage.model_validate(msg_dict)
|
||||
if msg.role == "tool":
|
||||
continue
|
||||
for msg_dict in raw_messages:
|
||||
msg = AgentChatMessage.model_validate(msg_dict)
|
||||
if msg.role == "tool":
|
||||
continue
|
||||
|
||||
signed_urls: dict[str, str] = {}
|
||||
attachments = extract_user_message_attachments(msg.metadata)
|
||||
if self._attachment_storage and attachments:
|
||||
expected_prefix = (
|
||||
f"agent-inputs/{current_user.id}/{thread_id}/uploads/"
|
||||
signed_urls: dict[str, str] = {}
|
||||
attachments = extract_user_message_attachments(msg.metadata)
|
||||
if self._attachment_storage and attachments:
|
||||
expected_prefix = f"agent-inputs/{current_user.id}/{thread_id}/uploads/"
|
||||
for attachment in attachments:
|
||||
if not is_safe_attachment_path(
|
||||
attachment.path,
|
||||
expected_prefix=expected_prefix,
|
||||
):
|
||||
continue
|
||||
signed_url = await self._attachment_storage.create_signed_url(
|
||||
bucket=attachment.bucket,
|
||||
path=attachment.path,
|
||||
expires_in_seconds=self._SIGNED_URL_EXPIRES_IN_SECONDS,
|
||||
)
|
||||
for attachment in attachments:
|
||||
if not is_safe_attachment_path(
|
||||
attachment.path,
|
||||
expected_prefix=expected_prefix,
|
||||
):
|
||||
continue
|
||||
signed_url = await self._attachment_storage.create_signed_url(
|
||||
bucket=attachment.bucket,
|
||||
path=attachment.path,
|
||||
expires_in_seconds=self._SIGNED_URL_EXPIRES_IN_SECONDS,
|
||||
)
|
||||
key = f"{attachment.bucket}/{attachment.path}"
|
||||
signed_urls[key] = signed_url
|
||||
key = f"{attachment.bucket}/{attachment.path}"
|
||||
signed_urls[key] = signed_url
|
||||
|
||||
def _get_signed_url(payload: dict[str, str]) -> str:
|
||||
key = f"{payload['bucket']}/{payload['path']}"
|
||||
return signed_urls[key]
|
||||
def _get_signed_url(payload: dict[str, str]) -> str:
|
||||
key = f"{payload['bucket']}/{payload['path']}"
|
||||
return signed_urls[key]
|
||||
|
||||
converted = convert_message_to_history(msg, _get_signed_url)
|
||||
messages.append(HistoryMessage.model_validate(converted))
|
||||
converted = convert_message_to_history(msg, _get_signed_url)
|
||||
messages.append(HistoryMessage.model_validate(converted))
|
||||
|
||||
return HistorySnapshotResponse(
|
||||
scope="history_day",
|
||||
scope="history_session_full",
|
||||
threadId=thread_id,
|
||||
day=str(day_payload.get("day"))
|
||||
if day_payload and day_payload.get("day")
|
||||
else None,
|
||||
hasMore=bool(day_payload.get("hasMore")) if day_payload else False,
|
||||
day=None,
|
||||
hasMore=False,
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
@@ -639,7 +652,6 @@ class AgentService:
|
||||
*,
|
||||
current_user: CurrentUser,
|
||||
thread_id: str | None,
|
||||
before: date | None,
|
||||
) -> HistorySnapshotResponse:
|
||||
from schemas.domain.chat_message import AgentChatMessage
|
||||
from v1.agent.utils import convert_message_to_history
|
||||
@@ -648,20 +660,22 @@ class AgentService:
|
||||
if thread_id is not None:
|
||||
return await self.get_history_snapshot(
|
||||
thread_id=thread_id,
|
||||
before=before,
|
||||
current_user=current_user,
|
||||
)
|
||||
|
||||
summary_limit = 50
|
||||
raw_messages = (
|
||||
await self._repository.get_latest_assistant_messages_by_user_sessions(
|
||||
user_id=str(current_user.id),
|
||||
visibility_mask=bit_mask(bit=int(SystemVisibilityBit.UI_HISTORY)),
|
||||
session_limit=50,
|
||||
session_limit=summary_limit + 1,
|
||||
)
|
||||
)
|
||||
has_more = len(raw_messages) > summary_limit
|
||||
visible_messages = raw_messages[:summary_limit]
|
||||
|
||||
messages: list[HistoryMessage] = []
|
||||
for msg_dict in raw_messages:
|
||||
for msg_dict in visible_messages:
|
||||
msg = AgentChatMessage.model_validate(msg_dict)
|
||||
converted = convert_message_to_history(msg)
|
||||
messages.append(HistoryMessage.model_validate(converted))
|
||||
@@ -670,7 +684,7 @@ class AgentService:
|
||||
scope="history_sessions_latest_assistant",
|
||||
threadId=None,
|
||||
day=None,
|
||||
hasMore=False,
|
||||
hasMore=has_more,
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from schemas.agent.runtime_models import AgentOutput
|
||||
from pydantic import TypeAdapter
|
||||
from schemas.agent.runtime_models import RuntimeAgentOutput
|
||||
from schemas.domain.chat_message import (
|
||||
AgentChatMessage,
|
||||
AgentChatMessageMetadata,
|
||||
@@ -18,6 +19,7 @@ ALLOWED_ATTACHMENT_MIME_TYPES = {"image/png", "image/jpeg", "image/webp"}
|
||||
MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024
|
||||
MAX_TOTAL_ATTACHMENT_BYTES = 12 * 1024 * 1024
|
||||
MAX_ATTACHMENTS_PER_MESSAGE = 3
|
||||
_RUNTIME_AGENT_OUTPUT_ADAPTER = TypeAdapter(RuntimeAgentOutput)
|
||||
|
||||
|
||||
def convert_message_to_history(
|
||||
@@ -46,6 +48,7 @@ def convert_message_to_history(
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"id": str(message.id),
|
||||
"threadId": str(message.session_id),
|
||||
"seq": message.seq,
|
||||
"role": role,
|
||||
"content": content,
|
||||
@@ -78,12 +81,9 @@ def _convert_user_attachments(
|
||||
|
||||
signed_attachments: list[dict[str, str]] = []
|
||||
for attachment in resolved:
|
||||
try:
|
||||
signed_url = get_signed_url_fn(
|
||||
{"bucket": attachment.bucket, "path": attachment.path}
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
signed_url = get_signed_url_fn(
|
||||
{"bucket": attachment.bucket, "path": attachment.path}
|
||||
)
|
||||
signed_attachments.append(
|
||||
{
|
||||
"url": signed_url,
|
||||
@@ -106,16 +106,12 @@ def _extract_worker_agent_output(
|
||||
agent_output_data = metadata.get("agent_output")
|
||||
if not agent_output_data:
|
||||
return None
|
||||
try:
|
||||
agent_output = AgentOutput.model_validate(agent_output_data)
|
||||
except Exception:
|
||||
return None
|
||||
agent_output = _RUNTIME_AGENT_OUTPUT_ADAPTER.validate_python(agent_output_data)
|
||||
|
||||
if not agent_output:
|
||||
return None
|
||||
|
||||
payload = agent_output.model_dump(mode="json", by_alias=True, exclude_none=True)
|
||||
payload.pop("ui_hints", None)
|
||||
return payload or None
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user