161 lines
5.6 KiB
Python
161 lines
5.6 KiB
Python
"""Utilities for normalizing user input across DB and LangChain messages."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import dataclass, field, replace
|
|
from typing import Any
|
|
|
|
from langchain.messages import HumanMessage
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AgentRunInputMessage:
|
|
content: str
|
|
message_type: str
|
|
image_content: str | None
|
|
langchain_message: HumanMessage | None = None
|
|
extra_metadata: dict[str, Any] = field(default_factory=dict)
|
|
|
|
def raw_message(self) -> dict[str, Any] | None:
|
|
return self.langchain_message.model_dump() if self.langchain_message else None
|
|
|
|
def require_langchain_message(self) -> HumanMessage:
|
|
if not self.langchain_message:
|
|
raise ValueError("chat input message must include a LangChain HumanMessage")
|
|
return self.langchain_message
|
|
|
|
def with_metadata(self, metadata: dict[str, Any]) -> AgentRunInputMessage:
|
|
return replace(self, extra_metadata=dict(metadata))
|
|
|
|
|
|
def build_chat_input_message(query: str, image_content: str | None = None) -> AgentRunInputMessage:
|
|
if image_content:
|
|
langchain_message = HumanMessage(
|
|
content=[
|
|
{"type": "text", "text": query},
|
|
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{image_content}"}},
|
|
]
|
|
)
|
|
message_type = "multimodal_image"
|
|
else:
|
|
langchain_message = HumanMessage(content=query)
|
|
message_type = "text"
|
|
|
|
return AgentRunInputMessage(
|
|
content=query,
|
|
message_type=message_type,
|
|
image_content=image_content,
|
|
langchain_message=langchain_message,
|
|
)
|
|
|
|
|
|
def build_chat_input_message_from_openai_content(content: str | list[dict[str, Any]]) -> AgentRunInputMessage:
|
|
if isinstance(content, str):
|
|
if not content:
|
|
raise ValueError("user message content 必须是非空字符串或多模态数组")
|
|
return build_chat_input_message(content)
|
|
|
|
if not isinstance(content, list) or not content:
|
|
raise ValueError("user message content 必须是非空字符串或多模态数组")
|
|
|
|
parts: list[dict[str, Any]] = []
|
|
text_segments: list[str] = []
|
|
first_image_content: str | None = None
|
|
has_image = False
|
|
|
|
for part in content:
|
|
if not isinstance(part, dict):
|
|
raise ValueError("user message content 多模态数组元素必须是对象")
|
|
|
|
part_type = part.get("type")
|
|
if part_type == "text":
|
|
text = part.get("text")
|
|
if not isinstance(text, str):
|
|
raise ValueError("text content part 必须包含字符串 text")
|
|
if text:
|
|
text_segments.append(text)
|
|
parts.append({"type": "text", "text": text})
|
|
continue
|
|
|
|
if part_type == "image_url":
|
|
image_url = _normalize_openai_image_url_part(part.get("image_url"))
|
|
has_image = True
|
|
if first_image_content is None:
|
|
first_image_content = _extract_data_url_base64(image_url["url"])
|
|
parts.append({"type": "image_url", "image_url": image_url})
|
|
continue
|
|
|
|
raise ValueError(f"不支持的多模态 content part 类型: {part_type}")
|
|
|
|
if not text_segments and not has_image:
|
|
raise ValueError("user message content 必须包含非空文本或图片")
|
|
|
|
query = "\n".join(text_segments)
|
|
if not has_image:
|
|
return build_chat_input_message(query)
|
|
|
|
return AgentRunInputMessage(
|
|
content=query,
|
|
message_type="multimodal_image",
|
|
image_content=first_image_content,
|
|
langchain_message=HumanMessage(content=parts),
|
|
)
|
|
|
|
|
|
def _normalize_openai_image_url_part(image_url: object) -> dict[str, Any]:
|
|
if isinstance(image_url, str):
|
|
url = image_url
|
|
normalized: dict[str, Any] = {"url": url}
|
|
elif isinstance(image_url, dict):
|
|
url = image_url.get("url")
|
|
normalized = dict(image_url)
|
|
else:
|
|
raise ValueError("image_url content part 必须包含 image_url.url")
|
|
|
|
if not isinstance(url, str) or not url:
|
|
raise ValueError("image_url content part 必须包含 image_url.url")
|
|
normalized["url"] = url
|
|
return normalized
|
|
|
|
|
|
def _extract_data_url_base64(url: str) -> str | None:
|
|
marker = ";base64,"
|
|
if not url.startswith("data:image/") or marker not in url:
|
|
return None
|
|
return url.split(marker, 1)[1]
|
|
|
|
|
|
def build_resume_input_message(resume: object) -> AgentRunInputMessage:
|
|
return AgentRunInputMessage(
|
|
content=json.dumps(resume, ensure_ascii=False),
|
|
message_type="resume",
|
|
image_content=None,
|
|
)
|
|
|
|
|
|
def restore_chat_input_message(*, content: str, image_content: str | None, metadata: dict) -> AgentRunInputMessage:
|
|
raw_message = metadata.get("raw_message")
|
|
if isinstance(raw_message, dict):
|
|
try:
|
|
langchain_message = HumanMessage.model_validate(raw_message)
|
|
except Exception as exc:
|
|
raise ValueError("invalid raw_message for chat input message") from exc
|
|
raw_content = raw_message.get("content")
|
|
message_type = "multimodal_image" if image_content or _has_image_url_content_part(raw_content) else "text"
|
|
return AgentRunInputMessage(
|
|
content=content,
|
|
message_type=message_type,
|
|
image_content=image_content,
|
|
langchain_message=langchain_message,
|
|
extra_metadata=dict(metadata),
|
|
)
|
|
|
|
return build_chat_input_message(content, image_content)
|
|
|
|
|
|
def _has_image_url_content_part(content: object) -> bool:
|
|
return isinstance(content, list) and any(
|
|
isinstance(part, dict) and part.get("type") == "image_url" for part in content
|
|
)
|