From ef4c8eefc9fa693ac3938a36afd8765dcc5f3f8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Mon, 16 Mar 2026 10:05:05 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20AI=20agent=20knows=20material=20library?= =?UTF-8?q?=20=E2=80=94=20list=5Fmaterials=20tool=20with=20alias=20search?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added list_materials tool to the chat agent: - Searches SCHAEFFLER library materials by name, description, or alias - Returns material name + schaeffler_code + aliases - Enables: "zeig mir ein Bild mit Durotect-Material" → agent searches for "durotect" → finds SCHAEFFLER_020101_Durotect-Blue → uses as material_override System prompt updated with rules 10-11: - Explains alias → library material mapping - Always use full SCHAEFFLER name for material_override Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/services/chat_service.py | 64 +++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/backend/app/services/chat_service.py b/backend/app/services/chat_service.py index 9fb94b3..7000179 100644 --- a/backend/app/services/chat_service.py +++ b/backend/app/services/chat_service.py @@ -38,7 +38,9 @@ RULES: 6. Combine multiple steps into one action. If creating an order, also submit and dispatch it automatically. 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.""" +9. When the user says "beliebig", "any", "random", "irgendein" — just pick one yourself, don't ask back. +10. Material system: Materials have SCHAEFFLER library names (e.g. SCHAEFFLER_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 SCHAEFFLER name, then use that for material_override. +11. When setting material_override, always use the full SCHAEFFLER library name (e.g. SCHAEFFLER_020101_Durotect-Blue), never the alias.""" # ── Tool definitions (OpenAI function-calling schema) ──────────────────────── @@ -241,6 +243,23 @@ TOOLS = [ }, }, }, + { + "type": "function", + "function": { + "name": "list_materials", + "description": "List available SCHAEFFLER library materials with their aliases. Use this to find the correct material name for material_override. Materials have names like SCHAEFFLER_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 SCHAEFFLER 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": { @@ -323,6 +342,8 @@ async def _execute_tool( 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": @@ -634,6 +655,47 @@ async def _tool_check_materials(db: AsyncSession, tenant_id: str, user_id: str = 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.schaeffler_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.schaeffler_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"], + "schaeffler_code": r["schaeffler_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,