fix(service): enhance message filtering to drop empty chat messages while retaining tool requests
Some checks failed
CI / lint (push) Has been cancelled
CI / unit tests (push) Has been cancelled
CI / integration tests (push) Has been cancelled
CI / package build (push) Has been cancelled
Commit lint / pull request title (push) Has been cancelled
Commit lint / commit messages (push) Has been cancelled
Docs / links (push) Has been cancelled
Some checks failed
CI / lint (push) Has been cancelled
CI / unit tests (push) Has been cancelled
CI / integration tests (push) Has been cancelled
CI / package build (push) Has been cancelled
Commit lint / pull request title (push) Has been cancelled
Commit lint / commit messages (push) Has been cancelled
Docs / links (push) Has been cancelled
This commit is contained in:
@ -285,10 +285,10 @@ LLM → metrics) before exiting.
|
|||||||
call them from your agent loop.
|
call them from your agent loop.
|
||||||
- **App + project scope** — set `app_id` / `project_id` to anything
|
- **App + project scope** — set `app_id` / `project_id` to anything
|
||||||
other than `"default"` to partition memory spaces inside one server.
|
other than `"default"` to partition memory spaces inside one server.
|
||||||
- **Multi-modal messages** — `messages[].content` accepts a list of
|
- **Mixed content messages** — `messages[].content` accepts a list of
|
||||||
typed `ContentItem`s (`text` / `image` / `audio` / `doc` / `pdf` /
|
typed `ContentItem`s (`text` / `md` / `image` / `audio` / `doc` /
|
||||||
`html` / `email`) for non-text input. Install the optional extra
|
`pdf` / `html` / `email`). Markdown (`md`) is read as UTF-8 text.
|
||||||
to enable parsing:
|
Install the optional extra to enable parsing for media/doc types:
|
||||||
`uv pip install 'everos[multimodal]'`. Office documents
|
`uv pip install 'everos[multimodal]'`. Office documents
|
||||||
(`doc` / `docx` / `xls` / `ppt` / `…`) additionally need
|
(`doc` / `docx` / `xls` / `ppt` / `…`) additionally need
|
||||||
**LibreOffice** on the host (`brew install --cask libreoffice` /
|
**LibreOffice** on the host (`brew install --cask libreoffice` /
|
||||||
|
|||||||
@ -168,9 +168,10 @@ read the markdown), see [QUICKSTART.md](QUICKSTART.md).
|
|||||||
|
|
||||||
### Optional: Ingest Multimodal Files
|
### Optional: Ingest Multimodal Files
|
||||||
|
|
||||||
To ingest non-text content (image / pdf / audio / office documents)
|
Markdown files can be sent as `type: "md"` and are read as UTF-8 text
|
||||||
through `/api/v1/memory/add` `content` items, install the optional
|
without the multimodal parser. To ingest non-text content (image / pdf /
|
||||||
extra:
|
audio / office documents) through `/api/v1/memory/add` `content` items,
|
||||||
|
install the optional extra:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv pip install 'everos[multimodal]' # or: pip install 'everos[multimodal]'
|
uv pip install 'everos[multimodal]' # or: pip install 'everos[multimodal]'
|
||||||
|
|||||||
@ -159,8 +159,9 @@ vLLM / Ollama / DeepInfra)。你可以覆盖生成的 `.env` 中的 `*__BASE_U
|
|||||||
|
|
||||||
### 可选:摄取多模态文件
|
### 可选:摄取多模态文件
|
||||||
|
|
||||||
如果要通过 `/api/v1/memory/add` 的 `content` items 摄取非文本内容
|
Markdown 文件可以用 `type: "md"` 发送,并会按 UTF-8 文本读取,不经过
|
||||||
(image / pdf / audio / office documents),安装可选 extra:
|
multimodal parser。如果要通过 `/api/v1/memory/add` 的 `content` items
|
||||||
|
摄取非文本内容(image / pdf / audio / office documents),安装可选 extra:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv pip install 'everos[multimodal]' # or: pip install 'everos[multimodal]'
|
uv pip install 'everos[multimodal]' # or: pip install 'everos[multimodal]'
|
||||||
|
|||||||
46
docs/api.md
46
docs/api.md
@ -239,10 +239,10 @@ file (`episode-<YYYY-MM-DD>.md` etc.).
|
|||||||
|
|
||||||
**`content`** — The message body.
|
**`content`** — The message body.
|
||||||
- A bare **string** is shorthand for a single text content item.
|
- A bare **string** is shorthand for a single text content item.
|
||||||
- An **array of `ContentItem`** is for mixed-modality input (text +
|
- An **array of `ContentItem`** is for mixed input (`text` / `md` +
|
||||||
image / pdf / audio / ...); non-text items are parsed by the
|
image / pdf / audio / ...). `md` items are read as UTF-8 text;
|
||||||
multimodal LLM configured via `EVEROS_MULTIMODAL__*` env vars. See
|
media/document items are parsed by the multimodal LLM configured via
|
||||||
[ContentItem](#contentitem).
|
`EVEROS_MULTIMODAL__*` env vars. See [ContentItem](#contentitem).
|
||||||
|
|
||||||
**`tool_calls`** — When `role: "assistant"`, the tool calls the
|
**`tool_calls`** — When `role: "assistant"`, the tool calls the
|
||||||
assistant emitted in this turn (OpenAI Chat Completions shape).
|
assistant emitted in this turn (OpenAI Chat Completions shape).
|
||||||
@ -252,34 +252,38 @@ message is the response to.
|
|||||||
|
|
||||||
### ContentItem
|
### ContentItem
|
||||||
|
|
||||||
Mixed-modality message-body element. Carry the payload in exactly one
|
Mixed message-body element. Carry the payload in exactly one of `text` /
|
||||||
of `text` / `uri` / `base64`; the others must be `null`. For
|
`uri` / `base64`; the others must be `null`. For `type: "text"` use
|
||||||
`type: "text"` use `text`; for every **non-text** type use `uri`
|
`text`. For `type: "md"` use `text`, a server-local `file://` URI, or
|
||||||
(`http(s)://`) or `base64` (with `ext`). Non-text items are routed
|
`base64` UTF-8 bytes. For every **non-text, non-md** type use `uri`
|
||||||
through the multimodal parser, which needs a fetchable or decodable
|
(`http(s)://`) or `base64` (with `ext`). Non-text, non-md items are
|
||||||
payload — a non-text item carrying only `text` returns `415`.
|
routed through the multimodal parser, which needs a fetchable or
|
||||||
|
decodable payload — passing only `text` returns `415`.
|
||||||
|
|
||||||
| Field | Type | Required | Default | Notes |
|
| Field | Type | Required | Default | Notes |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| `type` | `"text" \| "image" \| "audio" \| "doc" \| "pdf" \| "html" \| "email"` | yes | — | — |
|
| `type` | `"text" \| "md" \| "image" \| "audio" \| "doc" \| "pdf" \| "html" \| "email"` | yes | — | — |
|
||||||
| `text` | `string \| null` | no | `null` | Required when `type: "text"` |
|
| `text` | `string \| null` | no | `null` | Required when `type: "text"`; optional inline Markdown when `type: "md"` |
|
||||||
| `uri` | `string \| null` | no | `null` | `http(s)://` (fetched server-side) or `file://` (read from the server's local fs, guardrailed) pointer |
|
| `uri` | `string \| null` | no | `null` | `http(s)://` (fetched server-side) or `file://` (read from the server's local fs, guardrailed) pointer |
|
||||||
| `base64` | `string \| null` | no | `null` | Inline payload, plain base64 (no `data:` prefix) |
|
| `base64` | `string \| null` | no | `null` | Inline payload, plain base64 (no `data:` prefix) |
|
||||||
| `ext` | `string \| null` | no | `null` | File-extension hint when `uri` lacks one |
|
| `ext` | `string \| null` | no | `null` | File-extension hint when `uri` lacks one |
|
||||||
| `name` | `string \| null` | no | `null` | Display filename, used in logs |
|
| `name` | `string \| null` | no | `null` | Display filename, used in logs |
|
||||||
| `extras` | `object \| null` | no | `null` | Provider-specific metadata, opaque to EverOS |
|
| `extras` | `object \| null` | no | `null` | Provider-specific metadata, opaque to EverOS |
|
||||||
|
|
||||||
**`type`** — The content kind. Each non-text type is dispatched to the
|
**`type`** — The content kind. `text` and `md` are treated as text.
|
||||||
multimodal LLM. If the multimodal endpoint cannot handle the supplied
|
Each other type is dispatched to the multimodal LLM. If the multimodal
|
||||||
payload, `/add` returns `415 Unsupported Media Type`.
|
endpoint cannot handle the supplied payload, `/add` returns
|
||||||
|
`415 Unsupported Media Type`.
|
||||||
|
|
||||||
**`text`** — The literal text payload; valid **only** for
|
**`text`** — The literal text payload; valid for `type: "text"` and
|
||||||
`type: "text"`. A non-text type (including `"html"`) is always routed
|
inline `type: "md"`. A non-text, non-md type (including `"html"`) is
|
||||||
to the parser and must carry `uri` or `base64`; passing only `text` on
|
always routed to the parser and must carry `uri` or `base64`; passing
|
||||||
a non-text item returns `415`. To inline HTML as plain text, send it
|
only `text` on those items returns `415`. To inline HTML as plain text,
|
||||||
as `type: "text"`.
|
send it as `type: "text"`.
|
||||||
|
|
||||||
**`uri`** — `http(s)://` or `file://` pointer to the asset. An
|
**`uri`** — `http(s)://` or `file://` pointer to the asset. For
|
||||||
|
`type: "md"`, only `file://` is supported and the file is decoded as
|
||||||
|
UTF-8 text. For parser-backed content, an
|
||||||
`http(s)` uri is fetched by the server and dispatched by the response
|
`http(s)` uri is fetched by the server and dispatched by the response
|
||||||
Content-Type (use it for assets hosted elsewhere — S3 / OSS presigned
|
Content-Type (use it for assets hosted elsewhere — S3 / OSS presigned
|
||||||
URL, http server). A `file://` uri is read from the **server's** local
|
URL, http server). A `file://` uri is read from the **server's** local
|
||||||
|
|||||||
@ -251,6 +251,7 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
"text",
|
"text",
|
||||||
|
"md",
|
||||||
"image",
|
"image",
|
||||||
"audio",
|
"audio",
|
||||||
"doc",
|
"doc",
|
||||||
|
|||||||
@ -58,7 +58,7 @@ class ToolCallDTO(BaseModel):
|
|||||||
class ContentItemDTO(BaseModel):
|
class ContentItemDTO(BaseModel):
|
||||||
"""Content piece (v1 API brief appendix A)."""
|
"""Content piece (v1 API brief appendix A)."""
|
||||||
|
|
||||||
type: Literal["text", "image", "audio", "doc", "pdf", "html", "email"]
|
type: Literal["text", "md", "image", "audio", "doc", "pdf", "html", "email"]
|
||||||
text: str | None = None
|
text: str | None = None
|
||||||
uri: str | None = None
|
uri: str | None = None
|
||||||
base64: str | None = None
|
base64: str | None = None
|
||||||
|
|||||||
@ -11,12 +11,19 @@ them via ``IngestResult.unparsed_non_text_count``.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from everos.core.errors import UnsupportedModalityError
|
||||||
from everos.core.observability.logging import get_logger
|
from everos.core.observability.logging import get_logger
|
||||||
|
from everos.memory.extract.parser.mapping import read_file_uri
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
_TEXTUAL_CONTENT_TYPES = frozenset({"text", "md"})
|
||||||
|
|
||||||
_IMAGE_VISUAL_FACTS_NOTE = (
|
_IMAGE_VISUAL_FACTS_NOTE = (
|
||||||
"Context: image visual facts extracted from an uploaded image; "
|
"Context: image visual facts extracted from an uploaded image; "
|
||||||
"treat these as image content, not assistant actions."
|
"treat these as image content, not assistant actions."
|
||||||
@ -36,6 +43,34 @@ def coerce_items(
|
|||||||
return [_coerce_item(item) for item in content]
|
return [_coerce_item(item) for item in content]
|
||||||
|
|
||||||
|
|
||||||
|
async def hydrate_md_items(items: list[dict[str, Any]]) -> None:
|
||||||
|
"""Populate ``text`` for ``type="md"`` items before parser dispatch."""
|
||||||
|
for item in items:
|
||||||
|
if item.get("type") != "md":
|
||||||
|
continue
|
||||||
|
if item.get("text") is not None:
|
||||||
|
item["text"] = str(item["text"])
|
||||||
|
continue
|
||||||
|
uri = item.get("uri")
|
||||||
|
if uri:
|
||||||
|
if urlparse(str(uri)).scheme != "file":
|
||||||
|
raise UnsupportedModalityError("md uri must use file://")
|
||||||
|
raw, _ = await read_file_uri(str(uri), ext_hint=item.get("ext") or "md")
|
||||||
|
item["text"] = _decode_md(raw)
|
||||||
|
continue
|
||||||
|
encoded = item.get("base64")
|
||||||
|
if encoded:
|
||||||
|
try:
|
||||||
|
raw = base64.b64decode(str(encoded), validate=True)
|
||||||
|
except (binascii.Error, ValueError) as exc:
|
||||||
|
raise UnsupportedModalityError("invalid md base64 payload") from exc
|
||||||
|
item["text"] = _decode_md(raw)
|
||||||
|
continue
|
||||||
|
raise UnsupportedModalityError(
|
||||||
|
"md content item requires text, file:// uri, or base64"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def derive_text(items: list[dict[str, Any]]) -> tuple[str, int]:
|
def derive_text(items: list[dict[str, Any]]) -> tuple[str, int]:
|
||||||
"""Render items into the derived ``text`` + count still-unparsed non-text.
|
"""Render items into the derived ``text`` + count still-unparsed non-text.
|
||||||
|
|
||||||
@ -49,7 +84,7 @@ def derive_text(items: list[dict[str, Any]]) -> tuple[str, int]:
|
|||||||
rendered = _render_item(item)
|
rendered = _render_item(item)
|
||||||
if rendered:
|
if rendered:
|
||||||
parts.append(rendered)
|
parts.append(rendered)
|
||||||
elif item.get("type") != "text":
|
elif item.get("type") not in _TEXTUAL_CONTENT_TYPES:
|
||||||
non_text += 1
|
non_text += 1
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"multimodal_content_not_parsed",
|
"multimodal_content_not_parsed",
|
||||||
@ -75,11 +110,11 @@ def normalise_content(
|
|||||||
def _render_item(item: dict[str, Any]) -> str | None:
|
def _render_item(item: dict[str, Any]) -> str | None:
|
||||||
"""Render one item to text, or ``None`` if it contributes nothing.
|
"""Render one item to text, or ``None`` if it contributes nothing.
|
||||||
|
|
||||||
Text items yield their ``text``; non-text items yield
|
Text and md items yield their ``text``; non-text items yield
|
||||||
``[TYPE: name]\\n{parsed_content}`` once parsed; unparsed non-text yields
|
``[TYPE: name]\\n{parsed_content}`` once parsed; unparsed non-text yields
|
||||||
``None``.
|
``None``.
|
||||||
"""
|
"""
|
||||||
if item.get("type") == "text":
|
if item.get("type") in _TEXTUAL_CONTENT_TYPES:
|
||||||
text = item.get("text")
|
text = item.get("text")
|
||||||
return str(text) if text else None
|
return str(text) if text else None
|
||||||
parsed = item.get("parsed_content")
|
parsed = item.get("parsed_content")
|
||||||
@ -100,3 +135,10 @@ def _coerce_item(item: Any) -> dict[str, Any]:
|
|||||||
if isinstance(item, dict):
|
if isinstance(item, dict):
|
||||||
return dict(item)
|
return dict(item)
|
||||||
return {"type": "unknown", "raw": repr(item)}
|
return {"type": "unknown", "raw": repr(item)}
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_md(raw: bytes) -> str:
|
||||||
|
try:
|
||||||
|
return raw.decode("utf-8")
|
||||||
|
except UnicodeDecodeError as exc:
|
||||||
|
raise UnsupportedModalityError("md payload must be UTF-8") from exc
|
||||||
|
|||||||
@ -37,7 +37,7 @@ from everos.memory.extract.parser import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from .id_gen import gen_message_id
|
from .id_gen import gen_message_id
|
||||||
from .multimodal import coerce_items, derive_text
|
from .multimodal import coerce_items, derive_text, hydrate_md_items
|
||||||
|
|
||||||
|
|
||||||
async def process(payload: dict[str, Any]) -> IngestResult:
|
async def process(payload: dict[str, Any]) -> IngestResult:
|
||||||
@ -55,6 +55,7 @@ async def process(payload: dict[str, Any]) -> IngestResult:
|
|||||||
non_text_total = 0
|
non_text_total = 0
|
||||||
for idx, m in enumerate(raw_messages):
|
for idx, m in enumerate(raw_messages):
|
||||||
content_items = coerce_items(m["content"])
|
content_items = coerce_items(m["content"])
|
||||||
|
await hydrate_md_items(content_items)
|
||||||
if has_unparsed_multimodal(content_items):
|
if has_unparsed_multimodal(content_items):
|
||||||
require_multimodal()
|
require_multimodal()
|
||||||
await enrich_content_items(
|
await enrich_content_items(
|
||||||
|
|||||||
@ -17,11 +17,15 @@ _INSTALL_HINT = (
|
|||||||
"(or uv add 'everos[multimodal]')."
|
"(or uv add 'everos[multimodal]')."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_TEXTUAL_CONTENT_TYPES = frozenset({"text", "md"})
|
||||||
|
|
||||||
|
|
||||||
def has_unparsed_multimodal(items: list[dict[str, Any]]) -> bool:
|
def has_unparsed_multimodal(items: list[dict[str, Any]]) -> bool:
|
||||||
"""True if any content item is non-text and not yet parsed."""
|
"""True if any content item is non-text and not yet parsed."""
|
||||||
return any(
|
return any(
|
||||||
item.get("type") != "text" and "parsed_content" not in item for item in items
|
item.get("type") not in _TEXTUAL_CONTENT_TYPES
|
||||||
|
and "parsed_content" not in item
|
||||||
|
for item in items
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -206,8 +206,18 @@ def _filter_for_mode(
|
|||||||
) -> list[CanonicalMessage]:
|
) -> list[CanonicalMessage]:
|
||||||
"""Chat mode drops tool rows; agent mode keeps everything."""
|
"""Chat mode drops tool rows; agent mode keeps everything."""
|
||||||
if mode == "chat":
|
if mode == "chat":
|
||||||
return [m for m in msgs if m.role in ("user", "assistant") and not m.tool_calls]
|
return [
|
||||||
return list(msgs)
|
m
|
||||||
|
for m in msgs
|
||||||
|
if m.role in ("user", "assistant")
|
||||||
|
and not m.tool_calls
|
||||||
|
and m.text.strip()
|
||||||
|
]
|
||||||
|
return [m for m in msgs if _has_boundary_payload(m)]
|
||||||
|
|
||||||
|
|
||||||
|
def _has_boundary_payload(m: CanonicalMessage) -> bool:
|
||||||
|
return bool(m.text.strip()) or bool(m.tool_calls) or m.role == "tool"
|
||||||
|
|
||||||
|
|
||||||
# ── Boundary dispatch ─────────────────────────────────────────────────────
|
# ── Boundary dispatch ─────────────────────────────────────────────────────
|
||||||
|
|||||||
@ -0,0 +1,32 @@
|
|||||||
|
"""Validation paths for ``POST /api/v1/memory/add`` request DTOs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from everos.entrypoints.api.routes.memorize import ContentItemDTO, MemorizeAddRequest
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_request_accepts_md_content_item() -> None:
|
||||||
|
req = MemorizeAddRequest.model_validate(
|
||||||
|
{
|
||||||
|
"session_id": "s_md",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"sender_id": "u1",
|
||||||
|
"role": "user",
|
||||||
|
"timestamp": 1_700_000_000_000,
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "md",
|
||||||
|
"text": "# Deploy\nUse nginx.",
|
||||||
|
"name": "deploy.md",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
content = req.messages[0].content
|
||||||
|
assert isinstance(content, list)
|
||||||
|
assert isinstance(content[0], ContentItemDTO)
|
||||||
|
assert content[0].type == "md"
|
||||||
@ -2,11 +2,18 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from everos.config import load_settings
|
||||||
from everos.memory.extract.ingest.multimodal import (
|
from everos.memory.extract.ingest.multimodal import (
|
||||||
coerce_items,
|
coerce_items,
|
||||||
derive_text,
|
derive_text,
|
||||||
normalise_content,
|
normalise_content,
|
||||||
)
|
)
|
||||||
|
from everos.memory.extract.ingest.service import process
|
||||||
|
|
||||||
|
|
||||||
def test_coerce_str_to_text_item() -> None:
|
def test_coerce_str_to_text_item() -> None:
|
||||||
@ -46,3 +53,85 @@ def test_normalise_content_text_only_unchanged() -> None:
|
|||||||
assert items == [{"type": "text", "text": "hello"}]
|
assert items == [{"type": "text", "text": "hello"}]
|
||||||
assert text == "hello"
|
assert text == "hello"
|
||||||
assert non_text == 0
|
assert non_text == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear_settings_cache():
|
||||||
|
load_settings.cache_clear()
|
||||||
|
yield
|
||||||
|
load_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_process_renders_md_text_without_multimodal_parser(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
import everos.memory.extract.ingest.service as ingest_service
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ingest_service,
|
||||||
|
"require_multimodal",
|
||||||
|
lambda: (_ for _ in ()).throw(AssertionError("parser should not run")),
|
||||||
|
)
|
||||||
|
result = await process(
|
||||||
|
{
|
||||||
|
"session_id": "s_md_text",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"sender_id": "u1",
|
||||||
|
"role": "user",
|
||||||
|
"timestamp": 1_700_000_000_000,
|
||||||
|
"content": [{"type": "md", "text": "# Deploy\nUse nginx."}],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.messages[0].text == "# Deploy\nUse nginx."
|
||||||
|
assert result.messages[0].content_items[0]["type"] == "md"
|
||||||
|
assert result.messages[0].content_items[0]["text"] == "# Deploy\nUse nginx."
|
||||||
|
assert result.unparsed_non_text_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_process_reads_md_file_uri_as_utf8_text(tmp_path: Path) -> None:
|
||||||
|
doc = tmp_path / "guide.md"
|
||||||
|
doc.write_text("# 部署\n配置域名。", encoding="utf-8")
|
||||||
|
|
||||||
|
result = await process(
|
||||||
|
{
|
||||||
|
"session_id": "s_md_uri",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"sender_id": "u1",
|
||||||
|
"role": "user",
|
||||||
|
"timestamp": 1_700_000_000_000,
|
||||||
|
"content": [
|
||||||
|
{"type": "md", "uri": f"file://{doc}", "name": "guide.md"}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.messages[0].text == "# 部署\n配置域名。"
|
||||||
|
assert result.messages[0].content_items[0]["text"] == "# 部署\n配置域名。"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_process_decodes_md_base64_as_utf8_text() -> None:
|
||||||
|
encoded = base64.b64encode("## Notes\n记住配置。".encode()).decode("ascii")
|
||||||
|
|
||||||
|
result = await process(
|
||||||
|
{
|
||||||
|
"session_id": "s_md_base64",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"sender_id": "u1",
|
||||||
|
"role": "user",
|
||||||
|
"timestamp": 1_700_000_000_000,
|
||||||
|
"content": [{"type": "md", "base64": encoded, "ext": "md"}],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.messages[0].text == "## Notes\n记住配置。"
|
||||||
|
assert result.messages[0].content_items[0]["text"] == "## Notes\n记住配置。"
|
||||||
|
|||||||
@ -18,6 +18,11 @@ def test_has_unparsed_multimodal_false_when_all_text() -> None:
|
|||||||
assert availability.has_unparsed_multimodal(items) is False
|
assert availability.has_unparsed_multimodal(items) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_has_unparsed_multimodal_false_for_md() -> None:
|
||||||
|
items = [{"type": "md", "text": "# hi"}]
|
||||||
|
assert availability.has_unparsed_multimodal(items) is False
|
||||||
|
|
||||||
|
|
||||||
def test_has_unparsed_multimodal_false_when_already_parsed() -> None:
|
def test_has_unparsed_multimodal_false_when_already_parsed() -> None:
|
||||||
items = [{"type": "image", "uri": "x", "parsed_content": "ocr"}]
|
items = [{"type": "image", "uri": "x", "parsed_content": "ocr"}]
|
||||||
assert availability.has_unparsed_multimodal(items) is False
|
assert availability.has_unparsed_multimodal(items) is False
|
||||||
|
|||||||
@ -74,6 +74,26 @@ def test_filter_agent_keeps_everything() -> None:
|
|||||||
assert [m.message_id for m in out] == ["m1", "m2"]
|
assert [m.message_id for m in out] == ["m1", "m2"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_drops_empty_plain_chat_messages_but_keeps_tool_requests() -> None:
|
||||||
|
msgs = [
|
||||||
|
_msg("m1", "user", text=""),
|
||||||
|
_msg("m2", "assistant", text=" "),
|
||||||
|
_msg(
|
||||||
|
"m3",
|
||||||
|
"assistant",
|
||||||
|
text="",
|
||||||
|
tool_calls=[ToolCall(id="tc1", function={"name": "f", "arguments": "{}"})],
|
||||||
|
),
|
||||||
|
_msg("m4", "user", text="ok"),
|
||||||
|
]
|
||||||
|
|
||||||
|
chat_out = _filter_for_mode(msgs, "chat")
|
||||||
|
agent_out = _filter_for_mode(msgs, "agent")
|
||||||
|
|
||||||
|
assert [m.message_id for m in chat_out] == ["m4"]
|
||||||
|
assert [m.message_id for m in agent_out] == ["m3", "m4"]
|
||||||
|
|
||||||
|
|
||||||
# ── _to_conversation_item dispatch ────────────────────────────────────────
|
# ── _to_conversation_item dispatch ────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user