Files
HartOMat/backend/app/services/chat_service.py
T

1075 lines
46 KiB
Python

"""Chat service — Azure OpenAI with function calling for tenant AI agent.
Uses tenant-specific Azure OpenAI credentials to provide an actionable AI
assistant that can query orders, products, materials, dispatch renders, etc.
All operations are scoped to the user's tenant_id.
"""
import json
import logging
import uuid
from datetime import datetime
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.core.render_paths import result_path_to_public_url
logger = logging.getLogger(__name__)
# ── System prompt ────────────────────────────────────────────────────────────
SYSTEM_PROMPT = """You are the HartOMat AI assistant. You help users manage their automated render pipeline for HartOMat product images.
You can:
- List and search orders and products
- Find and show existing product renders as inline images
- Create new render orders (auto-submit + auto-dispatch)
- Dispatch and check render status
- Set material overrides and render overrides
- Check material mapping status
- Query the database for statistics and metadata
RULES:
1. NEVER ask the user for information you can look up yourself. Use your tools to find products, orders, and renders.
2. For READ-ONLY actions (search, list, show images, check status): execute immediately, no confirmation needed.
3. For WRITE actions (create orders, dispatch renders, set material overrides, set render overrides): ALWAYS describe what you will do and ask the user to confirm BEFORE executing. NEVER call create_order, dispatch_renders, set_material_override, or set_render_overrides without explicit user confirmation first. Example: "I'll create an order for [Product] with [OutputType] and material override Steel-Bare at 1080p WebP. Shall I proceed?"
4. If the user asks for "any product" or "a random product", just pick one — query the database and choose.
5. If the user asks to see a render, use find_product_renders first. If no render exists, offer to create one (but ask first).
6. When showing renders, use Markdown image syntax: ![description](url). URLs are relative paths like /renders/...
7. Respond in the same language the user writes in.
8. Be concise — short answers are better than long ones.
9. When the user says "beliebig", "any", "random", "irgendein" — just pick one yourself, don't ask back.
10. Material system: Materials have HARTOMAT library names (e.g. HARTOMAT_020101_Durotect-Blue). Common names like "Durotect", "Stahl", "Bronze" are aliases that map to these library names. When the user asks for a material by a common name, use list_materials to find the correct HARTOMAT name, then use that for material_override.
11. When setting material_override, always use the full HARTOMAT library name (e.g. HARTOMAT_020101_Durotect-Blue), never the alias.
12. When mentioning a product, ALWAYS link to it: [ProductName](/products/UUID). When mentioning an order, link to it: [OrderNumber](/orders/UUID). This makes the response navigable.
13. Materials exist at TWO levels: (a) product_material — materials assigned to the product's CAD parts (from STEP/Excel import, e.g. "Durotect_M", "Stahl"), and (b) material_override — a single material applied to ALL parts at render time. When user asks for a product "with Durotect material", search product_material FIRST (products that naturally have Durotect parts). Only use material_override filter if they specifically say "override" or "alle Teile in".
14. NEVER say "no renders found" when renders DO exist. If no exact match, show the closest match and explain what's different."""
# ── Tool definitions (OpenAI function-calling schema) ────────────────────────
TOOLS = [
{
"type": "function",
"function": {
"name": "list_orders",
"description": "List recent orders with render progress. Returns order number, status, line count, and render progress.",
"parameters": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Filter by status: draft, submitted, processing, completed, rejected. Empty for all.",
"default": "",
},
"limit": {
"type": "integer",
"description": "Maximum number of orders to return (default 10, max 50).",
"default": 10,
},
},
},
},
},
{
"type": "function",
"function": {
"name": "search_products",
"description": "Search products by name, PIM-ID, baureihe, or category.",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search text (matches name, PIM-ID, baureihe).",
"default": "",
},
"category": {
"type": "string",
"description": "Filter by category key.",
"default": "",
},
"limit": {
"type": "integer",
"description": "Max results (default 20, max 50).",
"default": 20,
},
},
},
},
},
{
"type": "function",
"function": {
"name": "create_order",
"description": "Create a new render order with the given products and output type. Confirm with the user before executing.",
"parameters": {
"type": "object",
"properties": {
"product_ids": {
"type": "array",
"items": {"type": "string"},
"description": "List of product UUIDs to include.",
},
"output_type_name": {
"type": "string",
"description": "Name of the output type (e.g. 'Still Render', 'Turntable').",
},
"render_overrides": {
"type": "object",
"description": "Optional render setting overrides (output_format, width, height, samples, engine).",
},
"material_override": {
"type": "string",
"description": "Optional HARTOMAT library material name to apply to all lines.",
"default": "",
},
},
"required": ["product_ids", "output_type_name"],
},
},
},
{
"type": "function",
"function": {
"name": "dispatch_renders",
"description": "Dispatch (or retry) renders for all pending/failed lines in an order.",
"parameters": {
"type": "object",
"properties": {
"order_id": {
"type": "string",
"description": "UUID of the order.",
},
},
"required": ["order_id"],
},
},
},
{
"type": "function",
"function": {
"name": "get_order_status",
"description": "Get detailed status of a specific order including all lines and render progress.",
"parameters": {
"type": "object",
"properties": {
"order_id": {
"type": "string",
"description": "UUID of the order.",
},
},
"required": ["order_id"],
},
},
},
{
"type": "function",
"function": {
"name": "set_material_override",
"description": "Set a material override on all lines of an order. All parts will be rendered with this single material.",
"parameters": {
"type": "object",
"properties": {
"order_id": {
"type": "string",
"description": "UUID of the order.",
},
"material_name": {
"type": "string",
"description": "HARTOMAT library material name, or empty string to clear.",
},
},
"required": ["order_id", "material_name"],
},
},
},
{
"type": "function",
"function": {
"name": "set_render_overrides",
"description": "Set render overrides on all lines of an order (output_format, width, height, samples, engine, etc.).",
"parameters": {
"type": "object",
"properties": {
"order_id": {
"type": "string",
"description": "UUID of the order.",
},
"render_overrides": {
"type": "object",
"description": "Dict of render setting overrides. Pass null/empty to clear.",
},
},
"required": ["order_id", "render_overrides"],
},
},
},
{
"type": "function",
"function": {
"name": "get_render_stats",
"description": "Get render pipeline statistics: queue status, throughput, product/order counts.",
"parameters": {"type": "object", "properties": {}},
},
},
{
"type": "function",
"function": {
"name": "check_materials",
"description": "Check if all materials in an order are mapped to library materials. Returns unmapped materials.",
"parameters": {
"type": "object",
"properties": {
"order_id": {
"type": "string",
"description": "UUID of the order to check.",
},
},
"required": ["order_id"],
},
},
},
{
"type": "function",
"function": {
"name": "query_database",
"description": "Execute a read-only SQL SELECT query against the database. Key tables/columns: products(id, name, pim_id, category_key, cad_file_id, is_active, tenant_id, cad_metadata JSONB), orders(id, order_number, status, tenant_id), order_lines(id, order_id, product_id, render_status, material_override, render_overrides), cad_files(id, mesh_attributes->'dimensions_mm' with {x,y,z} in mm, parsed_objects, processing_status). products.cad_metadata JSONB contains: part_count, unique_part_count, instance_count, assembly_depth, total_volume_cm3, total_surface_area_cm2, total_triangle_count, complexity_score, largest_part (name + volume_cm3). To get product dimensions: JOIN cad_files cf ON cf.id = p.cad_file_id and use cf.mesh_attributes->'dimensions_mm'. Use :tenant_id parameter for tenant filtering. Category is 'category_key' not 'category'.",
"parameters": {
"type": "object",
"properties": {
"sql": {
"type": "string",
"description": "A SELECT SQL query to execute.",
},
},
"required": ["sql"],
},
},
},
{
"type": "function",
"function": {
"name": "list_materials",
"description": "List available HARTOMAT library materials with their aliases. Use this to find the correct material name for material_override. Materials have names like HARTOMAT_010101_Steel-Bare. Aliases map common names (Stahl, Bronze, Durotect, etc.) to these library materials. When user asks for a material by a common name, search aliases to find the correct HARTOMAT library material name.",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search term to filter materials by name or alias (e.g. 'durotect', 'steel', 'bronze'). Empty for all.",
"default": "",
},
},
},
},
},
{
"type": "function",
"function": {
"name": "find_product_renders",
"description": "Find existing render images. Returns URLs that can be shown inline. Call with NO parameters to get any recent render. Use product_name or product_id to filter. Use transparent_only=true for alpha renders. Always use this FIRST when user asks to see an image.",
"parameters": {
"type": "object",
"properties": {
"product_name": {
"type": "string",
"description": "Product name or partial match.",
"default": "",
},
"product_id": {
"type": "string",
"description": "Product UUID (if known).",
"default": "",
},
"transparent_only": {
"type": "boolean",
"description": "Only return renders with transparent background.",
"default": False,
},
"material_override": {
"type": "string",
"description": "Filter by material override applied at render time (partial match).",
"default": "",
},
"product_material": {
"type": "string",
"description": "Filter by material assigned to the product's CAD parts (partial match). Use this to find products that naturally HAVE a specific material (e.g. 'Durotect', 'Bronze', 'Kunststoff'). This searches the product's cad_part_materials, NOT the render override.",
"default": "",
},
},
},
},
},
]
# ── Azure OpenAI client helper ───────────────────────────────────────────────
def _resolve_credentials(tenant_config: dict | None) -> dict:
"""Resolve Azure OpenAI credentials from tenant config or global settings."""
if tenant_config and tenant_config.get("ai_enabled"):
return {
"api_key": tenant_config.get("ai_api_key") or settings.azure_openai_api_key,
"endpoint": tenant_config.get("ai_endpoint") or settings.azure_openai_endpoint,
"deployment": tenant_config.get("ai_deployment") or settings.azure_openai_deployment,
"api_version": tenant_config.get("ai_api_version") or settings.azure_openai_api_version,
"max_tokens": int(tenant_config.get("ai_max_tokens", 1000)),
"temperature": float(tenant_config.get("ai_temperature", 0.3)),
}
return {
"api_key": settings.azure_openai_api_key,
"endpoint": settings.azure_openai_endpoint,
"deployment": settings.azure_openai_deployment,
"api_version": settings.azure_openai_api_version,
"max_tokens": 1000,
"temperature": 0.3,
}
# ── Tool execution (async, tenant-scoped) ────────────────────────────────────
async def _execute_tool(
name: str,
arguments: dict,
tenant_id: str,
user_id: str,
db: AsyncSession,
) -> str:
"""Execute a tool call and return the result as a JSON string."""
try:
if name == "list_orders":
return await _tool_list_orders(db, tenant_id, **arguments)
elif name == "search_products":
return await _tool_search_products(db, tenant_id, **arguments)
elif name == "create_order":
return await _tool_create_order(db, tenant_id, user_id, **arguments)
elif name == "dispatch_renders":
return await _tool_dispatch_renders(db, tenant_id, user_id, **arguments)
elif name == "get_order_status":
return await _tool_get_order_status(db, tenant_id, **arguments)
elif name == "set_material_override":
return await _tool_set_material_override(db, tenant_id, user_id, **arguments)
elif name == "set_render_overrides":
return await _tool_set_render_overrides(db, tenant_id, user_id, **arguments)
elif name == "get_render_stats":
return await _tool_get_render_stats(db, tenant_id)
elif name == "check_materials":
return await _tool_check_materials(db, tenant_id, user_id, **arguments)
elif name == "list_materials":
return await _tool_list_materials(db, tenant_id, **arguments)
elif name == "find_product_renders":
return await _tool_find_product_renders(db, tenant_id, **arguments)
elif name == "query_database":
return await _tool_query_database(db, tenant_id, **arguments)
else:
return json.dumps({"error": f"Unknown tool: {name}"})
except Exception as exc:
logger.warning("Tool execution failed: %s(%s): %s", name, arguments, exc)
# Rollback the DB session so subsequent operations still work
try:
await db.rollback()
except Exception:
pass
return json.dumps({"error": str(exc)[:500]})
async def _tool_list_orders(db: AsyncSession, tenant_id: str, status: str = "", limit: int = 10) -> str:
limit = min(max(limit, 1), 50)
sql = """
SELECT o.id, o.order_number, o.status, o.created_at,
COUNT(ol.id) AS line_count,
COUNT(ol.id) FILTER (WHERE ol.render_status = 'completed') AS completed_lines,
COUNT(ol.id) FILTER (WHERE ol.render_status = 'failed') AS failed_lines
FROM orders o
LEFT JOIN order_lines ol ON ol.order_id = o.id
WHERE o.tenant_id = :tenant_id
"""
params: dict = {"tenant_id": tenant_id, "limit": limit}
if status:
sql += " AND o.status = :status"
params["status"] = status
sql += " GROUP BY o.id ORDER BY o.created_at DESC LIMIT :limit"
result = await db.execute(text(sql), params)
rows = result.mappings().all()
return json.dumps([dict(r) for r in rows], indent=2, default=str)
async def _tool_search_products(db: AsyncSession, tenant_id: str, query: str = "", category: str = "", limit: int = 20) -> str:
limit = min(max(limit, 1), 50)
sql = """
SELECT p.id, p.name, p.pim_id, p.category_key, p.baureihe,
p.cad_file_id IS NOT NULL AS has_step,
cf.processing_status,
cf.mesh_attributes->'dimensions_mm'->>'x' AS dim_x_mm,
cf.mesh_attributes->'dimensions_mm'->>'y' AS dim_y_mm,
cf.mesh_attributes->'dimensions_mm'->>'z' AS dim_z_mm,
p.cad_metadata->>'part_count' AS part_count,
p.cad_metadata->>'total_volume_cm3' AS volume_cm3,
p.cad_metadata->>'complexity_score' AS complexity,
p.cad_metadata->'largest_part'->>'name' AS largest_part_name,
p.cad_metadata->'largest_part'->>'volume_cm3' AS largest_part_volume
FROM products p
LEFT JOIN cad_files cf ON cf.id = p.cad_file_id
WHERE p.tenant_id = :tenant_id
"""
params: dict = {"tenant_id": tenant_id, "limit": limit}
if query:
sql += " AND (p.name ILIKE :q OR p.pim_id ILIKE :q OR p.baureihe ILIKE :q)"
params["q"] = f"%{query}%"
if category:
sql += " AND p.category_key = :category"
params["category"] = category
sql += " ORDER BY p.name LIMIT :limit"
result = await db.execute(text(sql), params)
rows = result.mappings().all()
return json.dumps([dict(r) for r in rows], indent=2, default=str)
async def _tool_create_order(
db: AsyncSession,
tenant_id: str,
user_id: str,
product_ids: list[str] | None = None,
output_type_name: str = "",
render_overrides: dict | None = None,
material_override: str = "",
) -> str:
"""Create an order via internal httpx call to the backend API."""
import httpx
if not product_ids:
return json.dumps({"error": "product_ids is required"})
# Resolve output type ID from name
ot_id = None
if output_type_name:
ot_result = await db.execute(
text("SELECT id FROM output_types WHERE name ILIKE :name AND is_active = true LIMIT 1"),
{"name": output_type_name},
)
ot_row = ot_result.mappings().first()
if ot_row:
ot_id = str(ot_row["id"])
else:
return json.dumps({"error": f"No active output type found matching '{output_type_name}'"})
lines = []
for pid in product_ids:
line: dict = {"product_id": pid}
if ot_id:
line["output_type_id"] = ot_id
if render_overrides:
line["render_overrides"] = render_overrides
if material_override:
line["material_override"] = material_override
lines.append(line)
# Call backend API internally using a service token
from app.utils.auth import create_access_token
token = create_access_token(user_id, "global_admin", tenant_id)
try:
async with httpx.AsyncClient(base_url="http://localhost:8888", timeout=30) as client:
resp = await client.post(
"/api/orders",
json={"lines": lines},
headers={"Authorization": f"Bearer {token}"},
)
resp.raise_for_status()
data = resp.json()
order_id = data["id"]
order_number = data["order_number"]
result_info = {
"order_id": order_id,
"order_number": order_number,
"status": data["status"],
"line_count": data.get("line_count", len(lines)),
}
# Auto-submit the order
try:
submit_resp = await client.post(
f"/api/orders/{order_id}/submit",
headers={"Authorization": f"Bearer {token}"},
)
submit_resp.raise_for_status()
result_info["status"] = "submitted"
# Auto-dispatch renders
try:
dispatch_resp = await client.post(
f"/api/orders/{order_id}/dispatch-renders",
headers={"Authorization": f"Bearer {token}"},
)
dispatch_resp.raise_for_status()
dispatch_data = dispatch_resp.json()
result_info["status"] = "processing"
result_info["dispatched"] = dispatch_data.get("dispatched", 0)
except Exception:
result_info["dispatch_note"] = "Order submitted but dispatch failed — dispatch manually"
except Exception:
result_info["submit_note"] = "Order created but submit failed — submit manually"
return json.dumps(result_info, indent=2)
except Exception as exc:
return json.dumps({"error": f"Failed to create order: {exc}"})
async def _tool_dispatch_renders(db: AsyncSession, tenant_id: str, user_id: str = "", order_id: str = "") -> str:
"""Dispatch renders via internal httpx call."""
import httpx
from app.utils.auth import create_access_token
# Verify order belongs to tenant
check = await db.execute(
text("SELECT id FROM orders WHERE id = :oid AND tenant_id = :tid"),
{"oid": order_id, "tid": tenant_id},
)
if not check.first():
return json.dumps({"error": "Order not found or not in your tenant"})
token = create_access_token(user_id, "global_admin", tenant_id)
try:
async with httpx.AsyncClient(base_url="http://localhost:8888", timeout=60) as client:
resp = await client.post(
f"/api/orders/{order_id}/dispatch-renders",
headers={"Authorization": f"Bearer {token}"},
)
resp.raise_for_status()
return json.dumps(resp.json(), indent=2, default=str)
except Exception as exc:
return json.dumps({"error": f"Failed to dispatch renders: {exc}"})
async def _tool_get_order_status(db: AsyncSession, tenant_id: str, order_id: str = "") -> str:
sql = """
SELECT o.id, o.order_number, o.status, o.created_at,
json_agg(json_build_object(
'line_id', ol.id,
'product_name', p.name,
'render_status', ol.render_status,
'output_type', ot.name,
'material_override', ol.material_override
)) AS lines
FROM orders o
LEFT JOIN order_lines ol ON ol.order_id = o.id
LEFT JOIN products p ON p.id = ol.product_id
LEFT JOIN output_types ot ON ot.id = ol.output_type_id
WHERE o.id = :oid AND o.tenant_id = :tid
GROUP BY o.id
"""
result = await db.execute(text(sql), {"oid": order_id, "tid": tenant_id})
row = result.mappings().first()
if not row:
return json.dumps({"error": "Order not found or not in your tenant"})
return json.dumps(dict(row), indent=2, default=str)
async def _tool_set_material_override(db: AsyncSession, tenant_id: str, user_id: str = "", order_id: str = "", material_name: str = "") -> str:
"""Set material override via internal httpx call."""
import httpx
from app.utils.auth import create_access_token
check = await db.execute(
text("SELECT id FROM orders WHERE id = :oid AND tenant_id = :tid"),
{"oid": order_id, "tid": tenant_id},
)
if not check.first():
return json.dumps({"error": "Order not found or not in your tenant"})
token = create_access_token(user_id, "global_admin", tenant_id)
try:
async with httpx.AsyncClient(base_url="http://localhost:8888", timeout=30) as client:
resp = await client.post(
f"/api/orders/{order_id}/batch-material-override",
json={"material_override": material_name or None},
headers={"Authorization": f"Bearer {token}"},
)
resp.raise_for_status()
return json.dumps(resp.json(), indent=2, default=str)
except Exception as exc:
return json.dumps({"error": f"Failed to set material override: {exc}"})
async def _tool_set_render_overrides(db: AsyncSession, tenant_id: str, user_id: str = "", order_id: str = "", render_overrides: dict | None = None) -> str:
"""Set render overrides via internal httpx call."""
import httpx
from app.utils.auth import create_access_token
check = await db.execute(
text("SELECT id FROM orders WHERE id = :oid AND tenant_id = :tid"),
{"oid": order_id, "tid": tenant_id},
)
if not check.first():
return json.dumps({"error": "Order not found or not in your tenant"})
token = create_access_token(user_id, "global_admin", tenant_id)
try:
async with httpx.AsyncClient(base_url="http://localhost:8888", timeout=30) as client:
resp = await client.post(
f"/api/orders/{order_id}/batch-render-overrides",
json={"render_overrides": render_overrides},
headers={"Authorization": f"Bearer {token}"},
)
resp.raise_for_status()
return json.dumps(resp.json(), indent=2, default=str)
except Exception as exc:
return json.dumps({"error": f"Failed to set render overrides: {exc}"})
async def _tool_get_render_stats(db: AsyncSession, tenant_id: str) -> str:
sql = """
SELECT
(SELECT count(*) FROM orders WHERE tenant_id = :tid) AS total_orders,
(SELECT count(*) FROM products WHERE tenant_id = :tid) AS total_products,
(SELECT count(*) FROM order_lines ol
JOIN orders o ON o.id = ol.order_id
WHERE o.tenant_id = :tid) AS total_lines,
(SELECT count(*) FROM order_lines ol
JOIN orders o ON o.id = ol.order_id
WHERE o.tenant_id = :tid AND ol.render_status = 'completed') AS completed_renders,
(SELECT count(*) FROM order_lines ol
JOIN orders o ON o.id = ol.order_id
WHERE o.tenant_id = :tid AND ol.render_status = 'failed') AS failed_renders,
(SELECT count(*) FROM order_lines ol
JOIN orders o ON o.id = ol.order_id
WHERE o.tenant_id = :tid AND ol.render_status = 'pending') AS pending_renders,
(SELECT count(*) FROM order_lines ol
JOIN orders o ON o.id = ol.order_id
WHERE o.tenant_id = :tid AND ol.render_status = 'processing') AS active_renders
"""
result = await db.execute(text(sql), {"tid": tenant_id})
row = result.mappings().first()
return json.dumps(dict(row) if row else {}, indent=2, default=str)
async def _tool_check_materials(db: AsyncSession, tenant_id: str, user_id: str = "", order_id: str = "") -> str:
"""Check unmapped materials for an order — uses internal API call."""
import httpx
from app.utils.auth import create_access_token
check = await db.execute(
text("SELECT id FROM orders WHERE id = :oid AND tenant_id = :tid"),
{"oid": order_id, "tid": tenant_id},
)
if not check.first():
return json.dumps({"error": "Order not found or not in your tenant"})
token = create_access_token(user_id, "global_admin", tenant_id)
try:
async with httpx.AsyncClient(base_url="http://localhost:8888", timeout=30) as client:
resp = await client.get(
f"/api/orders/{order_id}/check-materials",
headers={"Authorization": f"Bearer {token}"},
)
resp.raise_for_status()
return json.dumps(resp.json(), indent=2, default=str)
except Exception as exc:
return json.dumps({"error": f"Failed to check materials: {exc}"})
async def _tool_list_materials(db: AsyncSession, tenant_id: str, query: str = "") -> str:
"""List library materials with their aliases."""
sql = """
SELECT m.id, m.name, m.hartomat_code, m.description,
COALESCE(
(SELECT json_agg(ma.alias) FROM material_aliases ma WHERE ma.material_id = m.id),
'[]'::json
) AS aliases
FROM materials m
WHERE m.hartomat_code IS NOT NULL
"""
params: dict = {}
if query:
sql += """
AND (m.name ILIKE :q OR m.description ILIKE :q
OR EXISTS (SELECT 1 FROM material_aliases ma WHERE ma.material_id = m.id AND ma.alias ILIKE :q))
"""
params["q"] = f"%{query}%"
sql += " ORDER BY m.name LIMIT 30"
result = await db.execute(text(sql), params)
rows = result.mappings().all()
if not rows:
return json.dumps({"message": f"No materials found matching '{query}'.", "materials": []})
materials = []
for r in rows:
aliases = r["aliases"] if isinstance(r["aliases"], list) else []
materials.append({
"name": r["name"],
"hartomat_code": r["hartomat_code"],
"description": r["description"],
"aliases": aliases[:10], # cap to avoid token bloat
})
return json.dumps({
"message": f"Found {len(materials)} material(s). Use the 'name' field for material_override.",
"materials": materials,
}, indent=2, default=str)
async def _tool_find_product_renders(
db: AsyncSession, tenant_id: str,
product_name: str = "", product_id: str = "", transparent_only: bool = False,
material_override: str = "", product_material: str = "",
) -> str:
"""Find existing render images for a product, returning viewable URLs."""
conditions = ["o.tenant_id = :tenant_id", "ol.render_status = 'completed'", "ol.result_path IS NOT NULL"]
params: dict = {"tenant_id": tenant_id}
if product_id:
conditions.append("p.id = :pid")
params["pid"] = product_id
elif product_name:
conditions.append("p.name ILIKE :pname")
params["pname"] = f"%{product_name}%"
# If neither provided, return any renders (for "show me any render" requests)
if transparent_only:
conditions.append("ot.transparent_bg = true")
if material_override:
conditions.append("(ol.material_override ILIKE :mat OR ot.material_override ILIKE :mat)")
params["mat"] = f"%{material_override}%"
if product_material:
conditions.append("""
EXISTS (
SELECT 1 FROM jsonb_array_elements(p.cad_part_materials) cpm
WHERE cpm->>'material' ILIKE :pmat
)
""")
params["pmat"] = f"%{product_material}%"
where = " AND ".join(conditions)
sql = f"""
SELECT p.name AS product_name, p.id AS product_id,
ot.name AS output_type, ot.transparent_bg,
ot.output_format, ot.is_animation,
ot.material_override AS ot_material_override,
COALESCE(ol.render_log->>'width', ot.render_settings->>'width') AS ot_width,
COALESCE(ol.render_log->>'height', ot.render_settings->>'height') AS ot_height,
COALESCE(ol.render_log->>'engine_used', ol.render_log->>'engine', ot.render_settings->>'engine') AS ot_engine,
COALESCE(ol.render_log->>'samples', ot.render_settings->>'samples') AS ot_samples,
ol.render_log->>'type' AS render_type,
ol.render_log->>'template' AS template_used,
ol.material_override AS line_material_override,
ol.render_overrides,
ol.result_path, ol.id AS line_id,
ol.render_completed_at,
o.order_number, o.id AS order_id
FROM order_lines ol
JOIN orders o ON o.id = ol.order_id
JOIN products p ON p.id = ol.product_id
LEFT JOIN output_types ot ON ot.id = ol.output_type_id
WHERE {where}
ORDER BY ol.render_completed_at DESC
LIMIT 10
"""
result = await db.execute(text(sql), params)
rows = result.mappings().all()
if not rows:
return json.dumps({"message": "No completed renders found.", "renders": []})
renders = []
for r in rows:
path = r["result_path"] or ""
url = result_path_to_public_url(path, require_exists=True)
# Effective material override (line overrides output type)
material = r["line_material_override"] or r["ot_material_override"] or None
# Effective render overrides
overrides = r["render_overrides"] or {}
renders.append({
"product_name": r["product_name"],
"product_id": str(r["product_id"]),
"order_number": r["order_number"],
"order_id": str(r["order_id"]),
"output_type": r["output_type"],
"render_type": r.get("render_type") or ("animation" if r["is_animation"] else "still"),
"output_format": overrides.get("output_format") or r["output_format"],
"transparent_bg": r["transparent_bg"],
"is_animation": r["is_animation"],
"resolution": f"{overrides.get('width') or r['ot_width'] or '?'}x{overrides.get('height') or r['ot_height'] or '?'}",
"engine": overrides.get("engine") or r["ot_engine"],
"samples": overrides.get("samples") or r["ot_samples"],
"material_override": material,
"has_template": bool(r.get("template_used")),
"image_url": url,
"line_id": str(r["line_id"]),
"rendered_at": str(r["render_completed_at"]),
})
return json.dumps({
"message": f"Found {len(renders)} render(s). Each has metadata about format, resolution, material override, transparency. Show image URLs as Markdown images or links.",
"renders": renders,
}, indent=2, default=str)
async def _tool_query_database(db: AsyncSession, tenant_id: str, sql: str = "") -> str:
"""Execute a read-only SQL query, tenant-scoped."""
sql_upper = sql.strip().upper()
if not sql_upper.startswith("SELECT") and not sql_upper.startswith("WITH"):
return json.dumps({"error": "Only SELECT/WITH queries are allowed (read-only)."})
for kw in ("INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "TRUNCATE", "CREATE"):
check = sql_upper.split("--")[0].split("/*")[0]
if f" {kw} " in f" {check} ":
return json.dumps({"error": f"{kw} statements are not allowed (read-only)."})
# Auto-inject tenant_id parameter for safety
# The AI should use :tenant_id in its queries; we always bind it
try:
result = await db.execute(text(sql), {"tenant_id": tenant_id})
rows = result.mappings().all()
if not rows:
return "Query returned 0 rows."
return json.dumps([dict(r) for r in rows[:100]], indent=2, default=str)
except Exception as exc:
return json.dumps({"error": f"Query error: {exc}"})
# ── Message persistence ──────────────────────────────────────────────────────
async def _save_message(
db: AsyncSession,
tenant_id: str,
user_id: str,
session_id: str,
role: str,
content: str,
context_type: str | None = None,
context_id: str | None = None,
token_count: int | None = None,
) -> dict:
"""Persist a chat message and return it as a dict."""
msg_id = uuid.uuid4()
now = datetime.utcnow()
await db.execute(
text("""
INSERT INTO chat_messages (id, tenant_id, user_id, session_id, role, content,
context_type, context_id, token_count, created_at)
VALUES (:id, :tenant_id, :user_id, :session_id, :role, :content,
:context_type, :context_id, :token_count, :created_at)
"""),
{
"id": str(msg_id),
"tenant_id": tenant_id,
"user_id": user_id,
"session_id": session_id,
"role": role,
"content": content,
"context_type": context_type,
"context_id": context_id,
"token_count": token_count,
"created_at": now,
},
)
return {
"id": str(msg_id),
"role": role,
"content": content,
"context_type": context_type,
"context_id": str(context_id) if context_id else None,
"token_count": token_count,
"created_at": now.isoformat(),
}
async def _load_session_messages(db: AsyncSession, session_id: str, tenant_id: str) -> list[dict]:
"""Load conversation history for a session (for context window)."""
result = await db.execute(
text("""
SELECT role, content FROM chat_messages
WHERE session_id = :sid AND tenant_id = :tid
ORDER BY created_at ASC
LIMIT 50
"""),
{"sid": session_id, "tid": tenant_id},
)
rows = result.mappings().all()
return [{"role": r["role"], "content": r["content"]} for r in rows]
# ── Main chat function ───────────────────────────────────────────────────────
async def chat_with_agent(
message: str,
session_id: str,
tenant_id: str,
user_id: str,
db: AsyncSession,
tenant_config: dict | None = None,
context_type: str | None = None,
context_id: str | None = None,
) -> dict:
"""Process a user message through the Azure OpenAI agent with function calling.
Returns {"session_id": str, "user_message": dict, "assistant_message": dict}.
"""
# Resolve credentials
creds = _resolve_credentials(tenant_config)
if not creds["api_key"] or not creds["endpoint"]:
raise ValueError(
"AI not configured. Ask your admin to set up Azure OpenAI "
"credentials in Tenant Settings (ai_enabled, ai_api_key, ai_endpoint)."
)
from openai import AzureOpenAI
client = AzureOpenAI(
api_key=creds["api_key"],
azure_endpoint=creds["endpoint"],
api_version=creds["api_version"],
)
# Build message history
history = await _load_session_messages(db, session_id, tenant_id)
# Build context-aware system prompt
system_content = SYSTEM_PROMPT
# Load entity details for the current page context
if context_type == "order" and context_id:
try:
ctx_result = await db.execute(
text("""
SELECT o.order_number, o.status, o.notes, o.created_at,
COUNT(ol.id) AS line_count,
COUNT(ol.id) FILTER (WHERE ol.render_status = 'completed') AS completed,
COUNT(ol.id) FILTER (WHERE ol.render_status = 'processing') AS processing,
COUNT(ol.id) FILTER (WHERE ol.render_status = 'failed') AS failed,
COUNT(ol.id) FILTER (WHERE ol.render_status = 'pending') AS pending
FROM orders o
LEFT JOIN order_lines ol ON ol.order_id = o.id
WHERE o.id = :ctx_id AND o.tenant_id = :tid
GROUP BY o.id
"""),
{"ctx_id": context_id, "tid": tenant_id},
)
row = ctx_result.mappings().first()
if row:
system_content += (
f"\n\nThe user is currently viewing order {row['order_number']} "
f"(ID: {context_id}, status: {row['status']}, "
f"{row['line_count']} lines: {row['completed']} completed, "
f"{row['processing']} processing, {row['failed']} failed, "
f"{row['pending']} pending). "
f"When the user says 'this order' or 'current order', they mean {row['order_number']} ({context_id})."
)
except Exception:
system_content += f"\n\nThe user is viewing order {context_id}."
elif context_type == "product" and context_id:
try:
ctx_result = await db.execute(
text("""
SELECT p.name, p.pim_id, p.category_key, p.baureihe,
CASE WHEN p.cad_file_id IS NOT NULL THEN true ELSE false END AS has_step
FROM products p
WHERE p.id = :ctx_id AND p.tenant_id = :tid
"""),
{"ctx_id": context_id, "tid": tenant_id},
)
row = ctx_result.mappings().first()
if row:
system_content += (
f"\n\nThe user is currently viewing product '{row['name']}' "
f"(ID: {context_id}, PIM-ID: {row['pim_id']}, "
f"category: {row['category_key']}, "
f"has STEP: {row['has_step']}). "
f"When the user says 'this product' or 'current product', they mean '{row['name']}' ({context_id})."
)
except Exception:
system_content += f"\n\nThe user is viewing product {context_id}."
system_content += f"\n\nThe user's tenant_id is '{tenant_id}'. Always filter queries by this tenant_id."
messages: list[dict] = [{"role": "system", "content": system_content}]
messages.extend(history)
messages.append({"role": "user", "content": message})
# Save user message
user_msg = await _save_message(
db, tenant_id, user_id, session_id, "user", message,
context_type=context_type, context_id=context_id,
)
# OpenAI function-calling loop
max_iterations = 10
iteration = 0
total_tokens = 0
response = client.chat.completions.create(
model=creds["deployment"],
messages=messages,
tools=TOOLS,
tool_choice="auto",
max_completion_tokens=creds["max_tokens"],
temperature=creds["temperature"],
)
if response.usage:
total_tokens += response.usage.total_tokens
while response.choices[0].message.tool_calls and iteration < max_iterations:
iteration += 1
assistant_msg = response.choices[0].message
# Append assistant message with tool calls to context
messages.append({
"role": "assistant",
"content": assistant_msg.content or "",
"tool_calls": [
{
"id": tc.id,
"type": "function",
"function": {"name": tc.function.name, "arguments": tc.function.arguments},
}
for tc in assistant_msg.tool_calls
],
})
# Execute each tool call
for tool_call in assistant_msg.tool_calls:
fn_name = tool_call.function.name
fn_args = json.loads(tool_call.function.arguments)
logger.info("Chat tool call: %s(%s) [tenant=%s]", fn_name, fn_args, tenant_id)
tool_result = await _execute_tool(fn_name, fn_args, tenant_id, user_id, db)
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": tool_result,
})
# Next LLM call with tool results
response = client.chat.completions.create(
model=creds["deployment"],
messages=messages,
tools=TOOLS,
tool_choice="auto",
max_completion_tokens=creds["max_tokens"],
temperature=creds["temperature"],
)
if response.usage:
total_tokens += response.usage.total_tokens
# Extract final text response
final_content = response.choices[0].message.content or ""
# Save assistant response
assistant_msg_out = await _save_message(
db, tenant_id, user_id, session_id, "assistant", final_content,
context_type=context_type, context_id=context_id,
token_count=total_tokens,
)
await db.commit()
return {
"session_id": session_id,
"user_message": user_msg,
"assistant_message": assistant_msg_out,
}