-
Notifications
You must be signed in to change notification settings - Fork 1
feat(api): keep invoice figures searchable at their source offset #663
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| # Changelog | ||
|
|
||
| All notable changes to this project are documented in this file. | ||
|
|
||
| The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), | ||
| and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). | ||
|
|
||
| ## [Unreleased] | ||
|
|
||
| ### Added | ||
|
|
||
| - Chat messages accept OpenAI `text` + `image_url` content parts. The gateway | ||
| records a 3NF `image_content_catalog` (`image_payload` / `image_placement` / | ||
| `image_recognition_event`) so an invoice PNG stays next to | ||
| `Please pay invoice 1042`. Raw base64 is hashed, not stored. Next action: | ||
| send the figure as `data:image/png;base64,...` or `https://...` and read | ||
| `orchestration.image_content_catalog` to find it. | ||
|
|
||
| ### References | ||
|
|
||
| - Faysse, M., Sibille, H., Wu, T., Omrani, B., Viaud, G., Hudelot, C., & | ||
| Colombo, P. (2024). *ColPali: Efficient document retrieval with vision | ||
| language models* (arXiv:2407.01449). arXiv. | ||
| https://doi.org/10.48550/arXiv.2407.01449 | ||
| - Xu, Y., Li, M., Cui, L., Huang, S., Wei, F., & Zhou, M. (2020). LayoutLM: | ||
| Pre-training of text and layout for document image understanding. In | ||
| *Proceedings of the 26th ACM SIGKDD International Conference on Knowledge | ||
| Discovery & Data Mining* (pp. 1192–1200). Association for Computing | ||
| Machinery. https://doi.org/10.1145/3394486.3403172 | ||
| - Masinter, L. (1998). *The "data" URL scheme* (RFC 2397). Internet | ||
| Engineering Task Force. https://doi.org/10.17487/RFC2397 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,8 @@ | |
|
|
||
| from collections import Counter, deque, OrderedDict | ||
| from contextvars import ContextVar | ||
| import base64 | ||
| import binascii | ||
| import copy | ||
| from dataclasses import dataclass, replace | ||
| from functools import wraps | ||
|
|
@@ -29,7 +31,7 @@ | |
| from .credentials import NotConfigured, get_credential | ||
|
|
||
|
|
||
| ChatMessage = dict[str, str] | ||
| ChatMessage = dict[str, Any] | ||
|
|
||
| class BudgetExceededError(RuntimeError): | ||
| """Raised when an operator-configured spend budget is already exhausted.""" | ||
|
|
@@ -39,6 +41,106 @@ def __init__(self, message: str, detail: dict[str, Any] | None = None) -> None: | |
| self.detail = detail or {} | ||
|
|
||
|
|
||
| def flatten_message_text(content: Any) -> str: | ||
| """Return concatenated text parts from a chat ``content`` value. | ||
|
|
||
| OpenAI vision callers send a list of ``text`` and ``image_url`` parts. | ||
| Routing and adjacent-text anchors need the words that sat next to the | ||
| figure, not the base64 payload. | ||
| """ | ||
| if isinstance(content, str): | ||
| return content | ||
| if not isinstance(content, list): | ||
| return "" | ||
| texts: list[str] = [] | ||
| for part in content: | ||
| if isinstance(part, dict) and isinstance(part.get("text"), str): | ||
| texts.append(part["text"]) | ||
| return " ".join(texts) | ||
|
|
||
|
|
||
| def _parse_image_source(url: str) -> tuple[str, str, int, str] | None: | ||
| """Return ``(payload_digest, mime_type, byte_length, source_kind)`` or None.""" | ||
| if url.startswith("data:"): | ||
| header, separator, payload = url.partition(",") | ||
| if not separator or ";base64" not in header.lower(): | ||
| return None | ||
| media = header[5:].split(";", 1)[0].strip().lower() | ||
| if not media.startswith("image/"): | ||
| return None | ||
| try: | ||
| raw = base64.b64decode(payload, validate=True) | ||
| except (ValueError, binascii.Error): | ||
| return None | ||
| if not raw: | ||
| return None | ||
| return hashlib.sha256(raw).hexdigest(), media, len(raw), "inline_data_uri" | ||
| parsed = urlparse(url) | ||
| if parsed.scheme != "https" or not parsed.hostname: | ||
| return None | ||
| return hashlib.sha256(url.encode("utf-8")).hexdigest(), "image/remote", 0, "remote_https" | ||
|
|
||
|
|
||
| def collect_image_catalog(messages: list[Any]) -> dict[str, Any]: | ||
| """Build a 3NF image catalog that keeps each figure at its source offset. | ||
|
|
||
| ``image_payload`` is identity by digest so the same invoice PNG on a | ||
| reminder thread is one payload with two ``image_placement`` rows. | ||
| ``image_recognition_event`` stays empty until a later vision/OCR pass | ||
| (temporal modeling: tags are not attributes of the bytes). | ||
| """ | ||
| payloads: dict[str, dict[str, Any]] = {} | ||
| placements: list[dict[str, Any]] = [] | ||
| if not isinstance(messages, list): | ||
| return { | ||
| "image_payloads": [], | ||
| "image_placements": [], | ||
| "image_recognition_events": [], | ||
| } | ||
| for message_index, message in enumerate(messages): | ||
| if not isinstance(message, dict): | ||
| continue | ||
| content = message.get("content") | ||
| adjacent_text = flatten_message_text(content) | ||
| if not isinstance(content, list): | ||
| continue | ||
| for part_index, part in enumerate(content): | ||
| if not isinstance(part, dict) or part.get("type") != "image_url": | ||
| continue | ||
| image_url = part.get("image_url") | ||
| if isinstance(image_url, str): | ||
| url = image_url | ||
| elif isinstance(image_url, dict): | ||
| url = image_url.get("url") | ||
| else: | ||
| continue | ||
| if not isinstance(url, str) or not url.strip(): | ||
| continue | ||
| parsed = _parse_image_source(url.strip()) | ||
| if parsed is None: | ||
| continue | ||
| payload_digest, mime_type, byte_length, source_kind = parsed | ||
| payloads[payload_digest] = { | ||
| "payload_digest": payload_digest, | ||
| "mime_type": mime_type, | ||
| "byte_length": byte_length, | ||
| } | ||
| placements.append( | ||
| { | ||
| "payload_digest": payload_digest, | ||
| "message_index": message_index, | ||
| "part_index": part_index, | ||
| "source_kind": source_kind, | ||
| "adjacent_text": adjacent_text, | ||
|
Comment on lines
+128
to
+134
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SQL/ERD |
||
| } | ||
| ) | ||
| return { | ||
| "image_payloads": list(payloads.values()), | ||
| "image_placements": placements, | ||
| "image_recognition_events": [], | ||
| } | ||
|
|
||
|
|
||
| def estimate_tokens(text: str) -> int: | ||
| """Rough token estimate (~4 chars/token). ponytail: heuristic, not a real tokenizer. | ||
|
|
||
|
|
@@ -492,9 +594,13 @@ def _provider_url(self, agent: ModelAgent, path: str) -> str: | |
| return f"{agent.base_url.rstrip('/')}{path}" | ||
|
|
||
| def _mock(self, agent: ModelAgent, messages: list[ChatMessage]) -> str: | ||
| last = next((m["content"] for m in reversed(messages) if m.get("role") == "user"), "") | ||
| last = next( | ||
| (flatten_message_text(m.get("content", "")) for m in reversed(messages) if m.get("role") == "user"), | ||
| "", | ||
| ) | ||
| role = "worker" | ||
| system = messages[0]["content"] if messages and messages[0].get("role") == "system" else "" | ||
| system_content = messages[0].get("content", "") if messages and messages[0].get("role") == "system" else "" | ||
| system = flatten_message_text(system_content) | ||
| match = re.search(r"Role: ([a-z]+)", system) | ||
| if match: | ||
| role = match.group(1) | ||
|
|
@@ -924,8 +1030,11 @@ def complete(self, messages: list[ChatMessage], mode: str = "auto") -> dict[str, | |
| def _dispatch(self, messages: list[ChatMessage], mode: str) -> dict[str, Any]: | ||
| text = self._latest_user_text(messages) | ||
| if mode == "route" or (mode == "auto" and not self._needs_workflow(text)): | ||
| return self.route_once(messages) | ||
| return self.conduct(messages) | ||
| result = self.route_once(messages) | ||
| else: | ||
| result = self.conduct(messages) | ||
| result["image_content_catalog"] = collect_image_catalog(messages) | ||
| return result | ||
|
|
||
| def would_route(self, messages: list[ChatMessage], mode: str = "auto") -> bool: | ||
| """True when this request takes the single-worker route path (vs the conduct workflow).""" | ||
|
|
@@ -993,6 +1102,8 @@ def run(self, messages: list[ChatMessage], mode: str = "auto", workflow_run_id: | |
| "trace": result["trace"], | ||
| "policy_snapshot": self.policy.as_dict(), | ||
| "verification": result.get("verification"), | ||
| "image_content_catalog": result.get("image_content_catalog") | ||
| or collect_image_catalog(messages), | ||
| } | ||
| self._workflow_runs[record["workflow_run_id"]] = record | ||
| self._run_order.appendleft(record["workflow_run_id"]) | ||
|
|
@@ -1600,7 +1711,10 @@ def _needs_workflow(self, text: str) -> bool: | |
| return hits >= self.policy.conduct_hint_threshold or len(text) > 700 | ||
|
|
||
| def _latest_user_text(self, messages: list[ChatMessage]) -> str: | ||
| return next((m.get("content", "") for m in reversed(messages) if m.get("role") == "user"), "") # pragma: no cover | ||
| return next( | ||
| (flatten_message_text(m.get("content", "")) for m in reversed(messages) if m.get("role") == "user"), | ||
| "", | ||
| ) | ||
|
|
||
| def _model_judge_verification(self, task: str, fallback: dict[str, Any]) -> dict[str, Any]: | ||
| """Ask a model to judge the verifier report (fixes term-matching false negatives). | ||
|
|
@@ -8500,6 +8614,9 @@ def chat_completion_response( | |
| } | ||
| if include_trace: | ||
| orchestration["trace"] = redact_value(result["trace"]) | ||
| catalog = result.get("image_content_catalog") | ||
| if catalog: | ||
| orchestration["image_content_catalog"] = catalog | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This attaches |
||
| return { | ||
| "id": f"chatcmpl-{int(time.time() * 1000)}", | ||
| "object": "chat.completion", | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -183,16 +183,56 @@ def _validate_mode(mode: Any) -> str: | |
| return mode | ||
|
|
||
|
|
||
| def _validate_messages(messages: Any) -> list[dict[str, str]]: | ||
| def _validate_content_parts(content: list[Any]) -> list[dict[str, Any]]: | ||
| """Accept OpenAI text + image_url parts; keep the figure at its offset.""" | ||
| if not content: | ||
| raise RequestError(400, "invalid_message_content", "content parts must be a non-empty array") | ||
| cleaned: list[dict[str, Any]] = [] | ||
| for part in content: | ||
| if not isinstance(part, dict): | ||
| raise RequestError(400, "invalid_message_content", "each content part must be an object") | ||
| part_type = part.get("type") | ||
| if part_type == "text": | ||
| text = part.get("text") | ||
| if not isinstance(text, str) or not text.strip(): | ||
| raise RequestError(400, "invalid_message_content", "text content part requires non-empty text") | ||
| cleaned.append({"type": "text", "text": text}) | ||
| continue | ||
| if part_type == "image_url": | ||
| image_url = part.get("image_url") | ||
| if isinstance(image_url, str): | ||
| url = image_url | ||
| elif isinstance(image_url, dict): | ||
| url = image_url.get("url") | ||
| else: | ||
| raise RequestError(400, "invalid_message_content", "image_url content part requires a url") | ||
| if not isinstance(url, str) or not url.strip(): | ||
| raise RequestError(400, "invalid_message_content", "image_url content part requires a non-empty url") | ||
| url = url.strip() | ||
| if url.startswith("javascript:") or url.startswith("data:text/"): | ||
| raise RequestError(400, "invalid_message_content", "image_url must be https or data:image") | ||
| if not (url.startswith("https://") or url.lower().startswith("data:image/")): | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| raise RequestError(400, "invalid_message_content", "image_url must be https or data:image") | ||
| cleaned.append({"type": "image_url", "image_url": {"url": url}}) | ||
| continue | ||
| raise RequestError(400, "invalid_message_content", "content part type must be text or image_url") | ||
| return cleaned | ||
|
|
||
|
|
||
| def _validate_messages(messages: Any) -> list[dict[str, Any]]: | ||
| if not isinstance(messages, list) or not messages: | ||
| raise RequestError(400, "invalid_message", "messages must be a non-empty array") | ||
| validated: list[dict[str, str]] = [] | ||
| validated: list[dict[str, Any]] = [] | ||
| for message in messages: | ||
| if not isinstance(message, dict): | ||
| raise RequestError(400, "invalid_message", "each message must be an object") | ||
| role = message.get("role") | ||
| content = message.get("content") | ||
| if not isinstance(role, str) or role not in ALLOWED_MESSAGE_ROLES or not isinstance(content, str): | ||
| if not isinstance(role, str) or role not in ALLOWED_MESSAGE_ROLES: | ||
| raise RequestError(400, "invalid_message", "message role or content is invalid") | ||
| if isinstance(content, list): | ||
| content = _validate_content_parts(content) | ||
| elif not isinstance(content, str): | ||
| raise RequestError(400, "invalid_message", "message role or content is invalid") | ||
| validated.append({"role": role, "content": content}) | ||
| return validated | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
validate=Truerejects RFC 2397 whitespace. Combined withurl.startswith("data:")(case-sensitive), a wrappedDATA:IMAGE/PNG;BASE64,...invoice is accepted by HTTP and then omitted from the catalog. Strip whitespace, casefold the scheme, then decode. Fixed on #684.