Coverage for agentos/multimodal/manager.py: 0%
165 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 10:59 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 10:59 +0800
1"""
2AgentOS v0.40 Multimodal — 多模态输入支持。
3支持:图片理解、语音转文字、PDF/文档解析。
4"""
6from __future__ import annotations
8import base64
9import logging
10from dataclasses import dataclass, field
11from enum import Enum
14logger = logging.getLogger(__name__)
17class Modality(str, Enum):
19 """模态类型枚举。"""
21 TEXT = "text"
22 IMAGE = "image"
23 AUDIO = "audio"
24 VIDEO = "video"
25 DOCUMENT = "document"
28@dataclass
29class MultimodalBlock:
30 """多模态输入块 — 遵循OpenAI/Anthropic content block格式。"""
31 type: str # text | image_url | audio | image
32 text: str = ""
33 source: dict = field(default_factory=dict)
34 mime_type: str = ""
36 @classmethod
37 def text_block(cls, text: str) -> "MultimodalBlock":
38 return cls(type="text", text=text)
40 @classmethod
41 def image_url(cls, url: str, detail: str = "auto") -> "MultimodalBlock":
42 return cls(type="image_url", source={"type": "image_url", "image_url": {"url": url, "detail": detail}})
44 @classmethod
45 def image_base64(cls, data: bytes, mime: str = "image/jpeg") -> "MultimodalBlock":
46 b64 = base64.b64encode(data).decode()
47 return cls(type="image_url", source={"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}})
49 @classmethod
50 def audio(cls, data: bytes, mime: str = "audio/wav") -> "MultimodalBlock":
51 b64 = base64.b64encode(data).decode()
52 return cls(type="audio", mime_type=mime, source={"data": b64})
54 def to_openai_format(self) -> dict:
55 if self.type == "text":
56 return {"type": "text", "text": self.text}
57 if self.type == "image_url":
58 return {"type": "image_url", "image_url": self.source["image_url"]}
59 return {"type": self.type, **self.source}
62class ImageProcessor:
63 """图片处理器 — 压缩、格式转换、OCR预处理。"""
65 MAX_SIZE = 2048
66 JPEG_QUALITY = 85
68 @staticmethod
69 def encode_file(path: str) -> tuple[str, str]:
70 """返回(base64, mime_type)。"""
71 import mimetypes
72 mime = mimetypes.guess_type(path)[0] or "image/png"
73 with open(path, "rb") as f:
74 data = f.read()
75 return base64.b64encode(data).decode(), mime
77 @staticmethod
78 def encode_bytes(data: bytes, mime: str = "image/jpeg") -> str:
79 return base64.b64encode(data).decode()
81 @staticmethod
82 def estimate_tokens(width: int, height: int, detail: str = "auto") -> int:
83 """估算图片token消耗(OpenAI定价模型)。"""
84 if detail == "low":
85 return 85
86 # high detail
87 short_side = min(width, height)
88 scale = min(768 / short_side, 1.0) if short_side > 768 else 1.0
89 w = int(width * scale)
90 h = int(height * scale)
91 tiles = ((w + 511) // 512) * ((h + 511) // 512)
92 return 85 + 170 * tiles
94 @staticmethod
95 def purge_metadata(data: bytes) -> bytes:
96 """清除图片EXIF元数据。"""
97 try:
98 from PIL import Image
99 import io
100 img = Image.open(io.BytesIO(data))
101 data_no_exif = list(img.getdata())
102 cleaned = Image.new(img.mode, img.size)
103 cleaned.putdata(data_no_exif)
104 buf = io.BytesIO()
105 cleaned.save(buf, format=img.format or "PNG")
106 return buf.getvalue()
107 except ImportError:
108 return data
111class AudioProcessor:
112 """音频处理器 — 转文字、格式转换。"""
114 SUPPORTED_FORMATS = ["wav", "mp3", "ogg", "flac", "m4a"]
116 @staticmethod
117 def transcribe(path: str, whisper_model: str = "base") -> str:
118 """使用whisper转文字。"""
119 try:
120 import whisper
121 model = whisper.load_model(whisper_model)
122 result = model.transcribe(path)
123 return result["text"]
124 except ImportError:
125 logger.warning("whisper not installed, returning empty")
126 return "[whisper not available]"
128 @staticmethod
129 def encode_file(path: str) -> tuple[str, str]:
130 import mimetypes
131 mime = mimetypes.guess_type(path)[0] or "audio/wav"
132 with open(path, "rb") as f:
133 data = f.read()
134 return base64.b64encode(data).decode(), mime
137class DocumentParser:
138 """文档解析器 — PDF/Word/Markdown。"""
140 @staticmethod
141 def parse_pdf(path: str) -> str:
142 try:
143 import PyPDF2
144 text = []
145 with open(path, "rb") as f:
146 reader = PyPDF2.PdfReader(f)
147 for page in reader.pages:
148 page_text = page.extract_text()
149 if page_text:
150 text.append(page_text)
151 return "\n\n".join(text)
152 except ImportError:
153 logger.warning("PyPDF2 not installed")
154 return "[PyPDF2 not available]"
156 @staticmethod
157 def parse_docx(path: str) -> str:
158 try:
159 from docx import Document
160 doc = Document(path)
161 return "\n".join(p.text for p in doc.paragraphs if p.text)
162 except ImportError:
163 logger.warning("python-docx not installed")
164 return "[python-docx not available]"
166 @staticmethod
167 def parse_auto(path: str) -> tuple[str, str]:
168 """自动检测文件类型并解析。返回 (content, format)。"""
169 ext = path.rsplit(".", 1)[-1].lower() if "." in path else ""
170 if ext == "pdf":
171 return DocumentParser.parse_pdf(path), "pdf"
172 elif ext in ("docx", "doc"):
173 return DocumentParser.parse_docx(path), "docx"
174 elif ext in ("md", "markdown", "txt"):
175 with open(path) as f:
176 return f.read(), ext
177 else:
178 try:
179 with open(path) as f:
180 return f.read(), "text"
181 except Exception:
182 return "", "unknown"
185class MultimodalManager:
186 """多模态管理器 — 统一入口。"""
188 def __init__(self):
189 self.image = ImageProcessor()
190 self.audio = AudioProcessor()
191 self.document = DocumentParser()
193 def prepare_input(self, blocks: list[MultimodalBlock]) -> list[dict]:
194 """转换为OpenAI兼容格式。"""
195 return [b.to_openai_format() for b in blocks]
197 def from_files(self, paths: list[str]) -> list[MultimodalBlock]:
198 """从文件路径自动推断模态。"""
199 blocks = []
200 image_exts = {"png", "jpg", "jpeg", "gif", "webp", "bmp"}
201 audio_exts = {"wav", "mp3", "ogg", "flac", "m4a"}
202 doc_exts = {"pdf", "docx", "doc", "md", "txt"}
204 for p in paths:
205 ext = p.rsplit(".", 1)[-1].lower() if "." in p else ""
206 try:
207 if ext in image_exts:
208 b64, mime = ImageProcessor.encode_file(p)
209 blocks.append(MultimodalBlock(type="image_url",
210 source={"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}}))
211 elif ext in audio_exts:
212 b64, mime = AudioProcessor.encode_file(p)
213 blocks.append(MultimodalBlock(type="audio", mime_type=mime, source={"data": b64}))
214 elif ext in doc_exts:
215 text, fmt = DocumentParser.parse_auto(p)
216 blocks.append(MultimodalBlock.text_block(text))
217 else:
218 with open(p) as f:
219 blocks.append(MultimodalBlock.text_block(f.read()))
220 except Exception as e:
221 blocks.append(MultimodalBlock.text_block(f"[Error reading {p}: {e}]"))
222 return blocks
224 def stats(self) -> dict:
225 return {"modalities": ["text", "image", "audio", "video", "document"]}