formatters.pyā¢17.6 kB
"""Data formatters for LLM-friendly output.
This module provides formatters that convert Odoo data into
hierarchical text format optimized for LLM consumption.
"""
import logging
from datetime import date, datetime
from typing import Any, Dict, List, Optional, Set
from .uri_schema import build_record_uri, build_search_uri
logger = logging.getLogger(__name__)
class RecordFormatter:
"""Formats Odoo records for LLM consumption.
This class converts complex Odoo data structures into hierarchical
text format that is easy for LLMs to understand and process.
"""
# Field types that should be omitted by default
OMIT_FIELDS = {
"__last_update",
"write_date",
"create_date",
"write_uid",
"create_uid",
"message_follower_ids",
"message_ids",
"message_main_attachment_id",
}
# Binary field types
BINARY_FIELDS = {"binary", "image", "file"}
def __init__(self, model: str, max_related_items: int = 5):
"""Initialize the formatter.
Args:
model: The Odoo model name
max_related_items: Maximum number of related items to show inline
"""
self.model = model
self.max_related_items = max_related_items
self._recursion_stack: Set[str] = set()
def format_record(
self,
record: Dict[str, Any],
fields_metadata: Optional[Dict[str, Dict[str, Any]]] = None,
indent_level: int = 0,
) -> str:
"""Format a single record into hierarchical text.
Args:
record: The record data dictionary
fields_metadata: Optional field metadata from fields_get()
indent_level: Current indentation level for nested structures
Returns:
Formatted text representation of the record
"""
lines = []
indent = " " * indent_level
# Record header
record_id = record.get("id", "Unknown")
record_name = record.get("display_name") or record.get("name", f"Record {record_id}")
lines.append(f"{indent}{'=' * 50}")
lines.append(f"{indent}Record: {self.model}/{record_id}")
lines.append(f"{indent}Name: {record_name}")
lines.append(f"{indent}{'=' * 50}")
# Group fields by category
simple_fields = []
relation_fields = []
for field_name, field_value in record.items():
# Skip omitted fields
if field_name in self.OMIT_FIELDS or field_name.startswith("_"):
continue
# Skip ID and name as they're in the header
if field_name in ("id", "name", "display_name"):
continue
# Get field metadata if available
field_meta = fields_metadata.get(field_name, {}) if fields_metadata else {}
field_type = field_meta.get("type", "unknown")
# Categorize fields
if field_type in ("many2one", "one2many", "many2many"):
relation_fields.append((field_name, field_value, field_meta))
else:
simple_fields.append((field_name, field_value, field_meta))
# Format simple fields first
if simple_fields:
lines.append(f"{indent}Fields:")
for field_name, field_value, field_meta in simple_fields:
formatted_value = self._format_field_value(
field_name, field_value, field_meta, indent_level + 1
)
lines.append(f"{indent} {field_name}: {formatted_value}")
# Format relationship fields
if relation_fields:
lines.append(f"{indent}Relationships:")
for field_name, field_value, field_meta in relation_fields:
lines.extend(
self._format_relation_field(
field_name, field_value, field_meta, indent_level + 1
)
)
return "\n".join(lines)
def format_list(
self,
records: List[Dict[str, Any]],
fields_metadata: Optional[Dict[str, Dict[str, Any]]] = None,
) -> str:
"""Format a list of records into hierarchical text.
Args:
records: List of record dictionaries
fields_metadata: Optional field metadata from fields_get()
Returns:
Formatted text representation of the record list
"""
if not records:
return f"No {self.model} records found."
lines = [f"{'=' * 60}", f"{self.model} Records ({len(records)} found)", f"{'=' * 60}", ""]
for idx, record in enumerate(records, 1):
lines.append(f"[{idx}] {self._get_record_summary(record)}")
lines.append("")
return "\n".join(lines)
def _format_field_value(
self, field_name: str, value: Any, field_meta: Dict[str, Any], indent_level: int
) -> str:
"""Format a field value based on its type.
Args:
field_name: The field name
value: The field value
field_meta: Field metadata
indent_level: Current indentation level
Returns:
Formatted field value
"""
if value is None or value is False:
return "Not set"
field_type = field_meta.get("type", "unknown")
# Text fields
if field_type in ("char", "text", "html"):
return str(value)
# Numeric fields
elif field_type in ("integer", "float", "monetary"):
if field_type == "monetary":
# Try to get currency information
# TODO: Use currency_field to get proper currency formatting
# currency_field = field_meta.get("currency_field", "currency_id")
return f"{value:,.2f}" # Format with thousand separators
elif field_type == "float":
digits = field_meta.get("digits", (16, 2))
precision = digits[1] if isinstance(digits, tuple) else 2
return f"{value:,.{precision}f}"
else:
return f"{value:,}" # Integer with thousand separators
# Date/time fields
elif field_type in ("date", "datetime"):
if isinstance(value, str):
# Handle Odoo's datetime format (YYYYMMDDTHH:MM:SS)
if (
field_type == "datetime"
and len(value) == 17
and "T" in value
and "-" not in value
):
try:
# Parse Odoo's compact datetime format
dt = datetime.strptime(value, "%Y%m%dT%H:%M:%S")
# Return proper ISO format with UTC timezone
return dt.strftime("%Y-%m-%dT%H:%M:%S+00:00")
except ValueError:
pass
# Handle standard datetime formats
elif field_type == "datetime" and " " in value:
try:
# Parse standard Odoo datetime format
dt = datetime.strptime(value, "%Y-%m-%d %H:%M:%S")
return dt.strftime("%Y-%m-%dT%H:%M:%S+00:00")
except ValueError:
pass
return value # Return as-is if parsing fails
elif isinstance(value, (datetime, date)):
if isinstance(value, datetime):
# Ensure datetime includes timezone
return value.strftime("%Y-%m-%dT%H:%M:%S+00:00")
else:
# Date only
return value.isoformat()
return str(value)
# Boolean fields
elif field_type == "boolean":
return "Yes" if value else "No"
# Selection fields
elif field_type == "selection":
# Try to get the human-readable selection value
selection = field_meta.get("selection", [])
for key, label in selection:
if key == value:
return f"{label} ({value})"
return str(value)
# Binary fields
elif field_type in self.BINARY_FIELDS:
return f"[Binary data - use {self.model}/{field_name} to retrieve]"
# Unknown type
else:
return str(value)
def _format_relation_field(
self, field_name: str, value: Any, field_meta: Dict[str, Any], indent_level: int
) -> List[str]:
"""Format a relationship field.
Args:
field_name: The field name
value: The field value
field_meta: Field metadata
indent_level: Current indentation level
Returns:
List of formatted lines
"""
lines = []
indent = " " * indent_level
field_type = field_meta.get("type", "unknown")
# Many2one fields
if field_type == "many2one":
if value and isinstance(value, (list, tuple)) and len(value) == 2:
related_id, related_name = value
related_model = field_meta.get("relation", "unknown")
uri = build_record_uri(related_model, related_id)
lines.append(f"{indent}{field_name}: {related_name} ({uri})")
else:
lines.append(f"{indent}{field_name}: Not set")
# One2many and Many2many fields
elif field_type in ("one2many", "many2many"):
if value and isinstance(value, list):
count = len(value)
related_model = field_meta.get("relation", "unknown")
# Build search URI for the related records
if field_type == "one2many":
# For one2many, search by the inverse field
inverse_field = field_meta.get("relation_field", "parent_id")
domain = [(inverse_field, "=", self._get_current_record_id())]
else:
# For many2many, we'd need the actual IDs
domain = [("id", "in", value)] if value else []
search_uri = build_search_uri(related_model, domain=domain)
lines.append(f"{indent}{field_name}: {count} record(s)")
lines.append(f"{indent} ā View all: {search_uri}")
# Show first few items if count is small
if count <= self.max_related_items and isinstance(value[0], dict):
lines.append(f"{indent} Items:")
for idx, item in enumerate(value[: self.max_related_items], 1):
summary = self._get_record_summary(item)
lines.append(f"{indent} [{idx}] {summary}")
elif count > self.max_related_items:
lines.append(
f"{indent} (Showing count only - too many items to display inline)"
)
else:
lines.append(f"{indent}{field_name}: No records")
return lines
def _get_record_summary(self, record: Dict[str, Any]) -> str:
"""Get a one-line summary of a record.
Args:
record: The record dictionary
Returns:
One-line summary string
"""
# Try different fields for the summary
summary_fields = ["display_name", "name", "complete_name", "partner_id", "title"]
for field in summary_fields:
if field in record and record[field]:
value = record[field]
if isinstance(value, (list, tuple)) and len(value) == 2:
return f"{value[1]} (ID: {value[0]})"
elif isinstance(value, str):
return value
# Fallback to ID
return f"ID: {record.get('id', 'Unknown')}"
def _get_current_record_id(self) -> Optional[int]:
"""Get the current record ID being formatted.
This is a placeholder that should be set when formatting a specific record.
Returns:
Current record ID or None
"""
# This would be set by the formatting context
return None
class DatasetFormatter:
"""Formats datasets and search results for LLM consumption."""
def __init__(self, model: str):
"""Initialize the dataset formatter.
Args:
model: The Odoo model name
"""
self.model = model
self.record_formatter = RecordFormatter(model)
def format_search_results(
self,
records: List[Dict[str, Any]],
domain: Optional[List] = None,
fields: Optional[List[str]] = None,
limit: Optional[int] = None,
offset: Optional[int] = None,
total_count: Optional[int] = None,
fields_metadata: Optional[Dict[str, Any]] = None,
next_uri: Optional[str] = None,
prev_uri: Optional[str] = None,
current_page: Optional[int] = None,
total_pages: Optional[int] = None,
) -> str:
"""Format search results with context and pagination.
Args:
records: List of record dictionaries
domain: Search domain used
fields: Fields that were requested
limit: Limit used in search
offset: Offset used in search
total_count: Total count of matching records
fields_metadata: Optional field metadata for rich formatting
next_uri: URI for next page of results
prev_uri: URI for previous page of results
current_page: Current page number
total_pages: Total number of pages
Returns:
Formatted search results with pagination
"""
lines = [
f"{'=' * 60}",
f"Search Results: {self.model}",
f"{'=' * 60}",
]
# Add search context
if domain:
lines.append(f"Search criteria: {self._format_domain(domain)}")
# Add pagination info
if total_count is not None:
showing = len(records)
if current_page and total_pages:
lines.append(f"Page {current_page} of {total_pages}")
if offset is not None:
lines.append(f"Showing records {offset + 1}-{offset + showing} of {total_count}")
else:
lines.append(f"Showing {showing} of {total_count} records")
else:
lines.append(f"Found {len(records)} records")
if fields:
lines.append(f"Fields: {', '.join(fields)}")
lines.append("")
# Format each record
if not records:
lines.append("No records found matching the criteria.")
else:
for idx, record in enumerate(records, 1):
if offset:
idx = offset + idx
lines.append(f"[{idx}] {self.record_formatter._get_record_summary(record)}")
# Add selected field values if specific fields were requested
if fields and len(fields) <= 5: # Only show inline for small field sets
for field in fields:
if field in record and field not in ("id", "name", "display_name"):
value = record[field]
formatted = self._format_simple_value(value)
lines.append(f" {field}: {formatted}")
lines.append("")
# Add navigation links
navigation = []
if prev_uri:
navigation.append(f"ā Previous page: {prev_uri}")
if next_uri:
navigation.append(f"ā Next page: {next_uri}")
if navigation:
lines.append("\nNavigation:")
lines.extend(navigation)
# Add summary statistics for large datasets
if total_count and total_count > 100:
lines.append("\nDataset Summary:")
lines.append(f"Total records: {total_count:,}")
if domain:
lines.append("Use additional filters to refine results")
return "\n".join(lines)
def _format_domain(self, domain: List) -> str:
"""Format a search domain in human-readable form.
Args:
domain: Odoo search domain
Returns:
Human-readable domain description
"""
if not domain:
return "All records"
conditions = []
for condition in domain:
if isinstance(condition, (list, tuple)) and len(condition) == 3:
field, operator, value = condition
conditions.append(f"{field} {operator} {value}")
elif condition in ("&", "|", "!"):
conditions.append(condition)
return " ".join(conditions) if conditions else str(domain)
def _format_simple_value(self, value: Any) -> str:
"""Format a simple value for inline display.
Args:
value: The value to format
Returns:
Formatted value string
"""
if value is None or value is False:
return "Not set"
elif isinstance(value, (list, tuple)) and len(value) == 2:
# Many2one value
return f"{value[1]} (ID: {value[0]})"
elif isinstance(value, list):
return f"{len(value)} items"
elif isinstance(value, bool):
return "Yes" if value else "No"
else:
return str(value)