Charles Grandjean commited on
Commit
102353e
·
1 Parent(s): acc767b

fix tools more tests bettter iteration count

Browse files
subagents/doc_editor.py CHANGED
@@ -12,8 +12,7 @@ from langchain_core.messages import SystemMessage, HumanMessage, AIMessage, Tool
12
 
13
  from agent_states.doc_editor_state import DocEditorState
14
  from utils.doc_editor_tools import (
15
- replace_html, add_html, delete_html, inspect_document, attempt_completion,
16
- _replace_html, _add_html, _delete_html, _inspect_document, _attempt_completion
17
  )
18
  from prompts.doc_editor import get_doc_editor_system_prompt, get_summary_system_prompt
19
 
@@ -39,11 +38,9 @@ class DocumentEditorAgent:
39
  llm: Language model instance (ChatOpenAI or compatible)
40
  """
41
  self.llm = llm
42
- # Real tools (with _ prefix for internal functions) - these are actually called
43
- self.tools_real = [_replace_html, _add_html, _delete_html, _inspect_document, _attempt_completion]
44
- # Facade tools (exposed to LLM without doc_text parameter)
45
- self.tools_facade = [replace_html, add_html, delete_html, inspect_document, attempt_completion]
46
- self.llm_with_tools = self.llm.bind_tools(self.tools_facade)
47
  self.workflow = self._build_workflow()
48
 
49
  def _build_workflow(self) -> StateGraph:
@@ -73,6 +70,14 @@ class DocumentEditorAgent:
73
  Returns:
74
  "continue" if agent made tool calls or if attempt_completion not yet called, "end" if complete
75
  """
 
 
 
 
 
 
 
 
76
  intermediate_steps = state.get("intermediate_steps", [])
77
  if not intermediate_steps:
78
  return "continue"
@@ -96,8 +101,8 @@ class DocumentEditorAgent:
96
  logger.info("✅ attempt_completion already called - ending workflow")
97
  return "end"
98
 
99
- # Agent didn't make tool calls but also didn't complete - this shouldn't happen
100
- # Continue to force the agent to make a tool call
101
  logger.warning("⚠️ Agent response without tool calls - forcing continue (should call attempt_completion)")
102
  return "continue"
103
 
@@ -165,6 +170,9 @@ class DocumentEditorAgent:
165
  # Call LLM
166
  response = await self.llm_with_tools.ainvoke(intermediate_steps)
167
  intermediate_steps.append(response)
 
 
 
168
  state["intermediate_steps"] = intermediate_steps
169
  return state
170
 
@@ -176,24 +184,28 @@ class DocumentEditorAgent:
176
  if not (hasattr(last_message, 'tool_calls') and last_message.tool_calls):
177
  return state
178
 
179
- state["iteration_count"] = state.get("iteration_count", 0) + 1
180
-
181
  for tool_call in last_message.tool_calls:
182
  tool_name = tool_call['name']
183
 
184
- # Get the real tool function (with _ prefix)
185
- real_tool_func = next((t for t in self.tools_real if t.__name__ == "_" + tool_name), None)
186
 
187
- if real_tool_func:
188
  args = tool_call['args'].copy()
189
 
190
- # Inject doc_text for editing tools and inspect_document
191
- if tool_name in ["replace_html", "add_html", "delete_html", "inspect_document"]:
192
  args["doc_text"] = state["doc_text"]
193
  logger.info(f"📝 Injecting doc_text ({len(state['doc_text'])}b) into {tool_name}")
194
 
 
 
 
 
 
195
  try:
196
- result = await real_tool_func.ainvoke(args)
 
197
 
198
  # Update doc_text if tool was successful
199
  if result.get("ok") and "doc_text" in result:
 
12
 
13
  from agent_states.doc_editor_state import DocEditorState
14
  from utils.doc_editor_tools import (
15
+ replace_html, add_html, delete_html, inspect_document, attempt_completion
 
16
  )
17
  from prompts.doc_editor import get_doc_editor_system_prompt, get_summary_system_prompt
18
 
 
38
  llm: Language model instance (ChatOpenAI or compatible)
39
  """
40
  self.llm = llm
41
+ # All tools are LangChain tools with @tool decorator
42
+ self.tools = [replace_html, add_html, delete_html, inspect_document, attempt_completion]
43
+ self.llm_with_tools = self.llm.bind_tools(self.tools)
 
 
44
  self.workflow = self._build_workflow()
45
 
46
  def _build_workflow(self) -> StateGraph:
 
70
  Returns:
71
  "continue" if agent made tool calls or if attempt_completion not yet called, "end" if complete
72
  """
73
+ # Check max iterations first - stop regardless of tool calls
74
+ iteration_count = state.get("iteration_count", 0)
75
+ max_iterations = state.get("max_iterations", 10)
76
+
77
+ if iteration_count >= max_iterations:
78
+ logger.warning(f"⚠️ Max iterations ({max_iterations}) reached - ending workflow")
79
+ return "end"
80
+
81
  intermediate_steps = state.get("intermediate_steps", [])
82
  if not intermediate_steps:
83
  return "continue"
 
101
  logger.info("✅ attempt_completion already called - ending workflow")
102
  return "end"
103
 
104
+ # Agent didn't make tool calls but also didn't complete
105
+ # Increment iteration count was already done in _agent_node, so just continue
106
  logger.warning("⚠️ Agent response without tool calls - forcing continue (should call attempt_completion)")
107
  return "continue"
108
 
 
170
  # Call LLM
171
  response = await self.llm_with_tools.ainvoke(intermediate_steps)
172
  intermediate_steps.append(response)
173
+
174
+ # Increment iteration count for this LLM call (regardless of tool calls)
175
+ state["iteration_count"] = iteration_count + 1
176
  state["intermediate_steps"] = intermediate_steps
177
  return state
178
 
 
184
  if not (hasattr(last_message, 'tool_calls') and last_message.tool_calls):
185
  return state
186
 
 
 
187
  for tool_call in last_message.tool_calls:
188
  tool_name = tool_call['name']
189
 
190
+ # Get the tool function directly from self.tools
191
+ tool_func = next((t for t in self.tools if t.name == tool_name), None)
192
 
193
+ if tool_func:
194
  args = tool_call['args'].copy()
195
 
196
+ # Inject doc_text for editing tools that need it
197
+ if tool_name in ["replace_html", "add_html", "delete_html"]:
198
  args["doc_text"] = state["doc_text"]
199
  logger.info(f"📝 Injecting doc_text ({len(state['doc_text'])}b) into {tool_name}")
200
 
201
+ # Special handling for inspect_document - inject doc_text and return content
202
+ if tool_name == "inspect_document":
203
+ args["doc_text"] = state["doc_text"]
204
+ logger.info(f"🔍 Inspecting document ({len(state['doc_text'])}b)")
205
+
206
  try:
207
+ # Call the LangChain tool with .ainvoke()
208
+ result = await tool_func.ainvoke(args)
209
 
210
  # Update doc_text if tool was successful
211
  if result.get("ok") and "doc_text" in result:
tests/test_doc_editor.py CHANGED
@@ -1,11 +1,10 @@
1
  #!/usr/bin/env python3
2
  """
3
  Test script for the Document Editor Agent
4
- Demonstrates Cline-like document editing with TipTap JSON
5
  """
6
 
7
  import asyncio
8
- import json
9
  import os
10
  import sys
11
  from dotenv import load_dotenv
@@ -20,117 +19,52 @@ from langchain_openai import ChatOpenAI
20
  from subagents.doc_editor import DocumentEditorAgent
21
 
22
 
23
- # Example TipTap JSON document (from user's example)
24
- SAMPLE_CONTRACT = {
25
- "type": "doc",
26
- "content": [
27
- {
28
- "type": "heading",
29
- "attrs": { "level": 1, "textAlign": "center" },
30
- "content": [
31
- { "type": "text", "text": "CONTRAT DE PRESTATION DE SERVICES" }
32
- ]
33
- },
34
- {
35
- "type": "heading",
36
- "attrs": { "level": 2, "textAlign": "left" },
37
- "content": [
38
- { "type": "text", "text": "Article 1 - Objet" }
39
- ]
40
- },
41
- {
42
- "type": "paragraph",
43
- "attrs": { "textAlign": "justify" },
44
- "content": [
45
- { "type": "text", "text": "Le présent contrat a pour objet de définir les conditions dans lesquelles " },
46
- { "type": "text", "marks": [{ "type": "bold" }], "text": "la Société X" },
47
- { "type": "text", "text": " (ci-après « le Prestataire ») s'engage à fournir des services à " },
48
- { "type": "text", "marks": [{ "type": "bold" }], "text": "la Société Y" },
49
- { "type": "text", "text": " (ci-après « le Client »)." }
50
- ]
51
- },
52
- {
53
- "type": "heading",
54
- "attrs": { "level": 2, "textAlign": "left" },
55
- "content": [
56
- { "type": "text", "text": "Article 2 - Durée" }
57
- ]
58
- },
59
- {
60
- "type": "paragraph",
61
- "attrs": { "textAlign": "justify" },
62
- "content": [
63
- { "type": "text", "text": "Le contrat prend effet le " },
64
- { "type": "text", "marks": [{ "type": "italic" }], "text": "1er janvier 2026" },
65
- { "type": "text", "text": " pour une durée de " },
66
- { "type": "text", "marks": [{ "type": "underline" }], "text": "12 mois" },
67
- { "type": "text", "text": "." }
68
- ]
69
- },
70
- {
71
- "type": "bulletList",
72
- "content": [
73
- {
74
- "type": "listItem",
75
- "content": [
76
- {
77
- "type": "paragraph",
78
- "content": [
79
- { "type": "text", "text": "Renouvellement tacite" }
80
- ]
81
- }
82
- ]
83
- },
84
- {
85
- "type": "listItem",
86
- "content": [
87
- {
88
- "type": "paragraph",
89
- "content": [
90
- { "type": "text", "text": "Préavis de 3 mois" }
91
- ]
92
- }
93
- ]
94
- }
95
- ]
96
- },
97
- {
98
- "type": "table",
99
- "content": [
100
- {
101
- "type": "tableRow",
102
- "content": [
103
- {
104
- "type": "tableHeader",
105
- "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Service" }] }]
106
- },
107
- {
108
- "type": "tableHeader",
109
- "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Tarif" }] }]
110
- }
111
- ]
112
- },
113
- {
114
- "type": "tableRow",
115
- "content": [
116
- {
117
- "type": "tableCell",
118
- "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Consultation" }] }]
119
- },
120
- {
121
- "type": "tableCell",
122
- "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "200€/h" }] }]
123
- }
124
- ]
125
- }
126
- ]
127
- }
128
- ]
129
- }
130
 
131
 
132
  async def test_document_editor():
133
- """Test the document editor agent with sample contract."""
134
 
135
  # Initialize LLM (requires OPENAI_API_KEY environment variable)
136
  api_key = os.getenv("OPENAI_API_KEY") or os.getenv("CEREBRAS_API_KEY")
@@ -148,13 +82,10 @@ async def test_document_editor():
148
  # Initialize document editor agent
149
  doc_editor = DocumentEditorAgent(llm=llm)
150
 
151
- # Convert document to canonical format
152
- canonical_doc = json.dumps(SAMPLE_CONTRACT, ensure_ascii=False, sort_keys=True, indent=2)
153
-
154
  print("=" * 80)
155
- print("📄 ORIGINAL DOCUMENT (TipTap JSON)")
156
  print("=" * 80)
157
- print(canonical_doc)
158
  print("\n")
159
 
160
  # Test 1: Simple text replacement
@@ -163,8 +94,8 @@ async def test_document_editor():
163
  print("=" * 80)
164
 
165
  result1 = await doc_editor.edit_document(
166
- doc_text=canonical_doc,
167
- user_instruction="Change the contract duration from 12 months to 24 months",
168
  doc_summaries=["Service contract between Company X and Company Y"],
169
  max_iterations=5
170
  )
@@ -177,16 +108,18 @@ async def test_document_editor():
177
  print("\n📄 MODIFIED DOCUMENT:")
178
  print(result1['doc_text'])
179
 
180
- # Update canonical_doc for next test
181
- canonical_doc = result1['doc_text']
 
 
182
 
183
  print("\n" + "=" * 80)
184
- print("🔧 TEST 2: Add Article 3 about pricing")
185
  print("=" * 80)
186
 
187
  result2 = await doc_editor.edit_document(
188
- doc_text=canonical_doc,
189
- user_instruction="Add a new Article 3 after Article 2 that covers pricing with a heading 'Article 3 - Prix' and a paragraph explaining that services are billed at 200€ per hour",
190
  doc_summaries=["Service contract between Company X and Company Y"],
191
  max_iterations=8
192
  )
@@ -205,53 +138,76 @@ async def test_document_editor():
205
 
206
 
207
  async def test_tools_directly():
208
- """Test the document editor tools directly without the agent."""
209
- from utils.doc_editor_tools import replace, add, delete, attempt_completion, _canon
210
 
211
  print("=" * 80)
212
  print("🔧 TESTING TOOLS DIRECTLY")
213
  print("=" * 80)
214
 
215
- # Convert to canonical format
216
- canonical_doc = json.dumps(SAMPLE_CONTRACT, ensure_ascii=False, sort_keys=True, indent=2)
217
 
218
- # Test replace
219
- print("\n1️⃣ Testing 'replace' tool...")
220
- result = await replace.ainvoke({
221
- "doc_text": canonical_doc,
222
- "search": '"text": "12 mois"',
223
- "replace": '"text": "24 mois"',
224
  "expected_matches": 1
225
  })
226
 
227
  if result['ok']:
228
  print(f"✅ Replace successful! Found {result['matches']} matches")
229
- canonical_doc = result['doc_text']
230
  else:
231
  print(f"❌ Replace failed: {result['error']}")
232
 
233
- # Test delete - simple text delete
234
- print("\n2️⃣ Testing 'delete' tool (simple text deletion)...")
235
- result = await delete.ainvoke({
236
- "doc_text": canonical_doc,
237
- "search": '"text": "Renouvellement tacite"',
 
 
238
  "expected_matches": 1
239
  })
240
 
241
  if result['ok']:
242
- print(f"✅ Delete successful! Removed {result['matches']} text node")
243
- canonical_doc = result['doc_text']
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  else:
245
  print(f"❌ Delete failed: {result['error']}")
246
- print(" Note: Delete works best for simple text nodes. Complex structures may need careful matching.")
247
 
248
- print("\n3️⃣ Final document after tool tests:")
249
- print(canonical_doc)
 
 
 
 
 
 
 
 
 
250
 
251
- # Test attempt_completion
252
- print("\n4️⃣ Testing 'attempt_completion' tool...")
253
  result = await attempt_completion.ainvoke({
254
- "message": "Successfully modified the document"
255
  })
256
  print(f"✅ Completion: {result}")
257
 
 
1
  #!/usr/bin/env python3
2
  """
3
  Test script for the Document Editor Agent
4
+ Demonstrates Cline-like document editing with HTML
5
  """
6
 
7
  import asyncio
 
8
  import os
9
  import sys
10
  from dotenv import load_dotenv
 
19
  from subagents.doc_editor import DocumentEditorAgent
20
 
21
 
22
+ # Example HTML document - Service Contract
23
+ SAMPLE_CONTRACT = """<!DOCTYPE html>
24
+ <html>
25
+ <head>
26
+ <title>Contrat de prestation de services</title>
27
+ </head>
28
+ <body>
29
+ <h1>CONTRAT DE PRESTATION DE SERVICES</h1>
30
+
31
+ <h2>Article 1 - Objet</h2>
32
+ <p>Le présent contrat a pour objet de définir les conditions dans lesquelles <strong>la Société X</strong> (ci-après « le Prestataire ») s'engage à fournir des services à <strong>la Société Y</strong> (ci-après « le Client »).</p>
33
+
34
+ <h2>Article 2 - Durée</h2>
35
+ <p>Le contrat prend effet le <em>1er janvier 2026</em> pour une durée de <u>12 mois</u>.</p>
36
+
37
+ <ul>
38
+ <li>Renouvellement tacite</li>
39
+ <li>Préavis de 3 mois</li>
40
+ </ul>
41
+
42
+ <h2>Article 3 - Tarification</h2>
43
+ <p>Les services sont facturés selon les conditions suivantes :</p>
44
+ <table>
45
+ <thead>
46
+ <tr>
47
+ <th>Service</th>
48
+ <th>Tarif</th>
49
+ </tr>
50
+ </thead>
51
+ <tbody>
52
+ <tr>
53
+ <td>Consultation</td>
54
+ <td>200€/heure</td>
55
+ </tr>
56
+ <tr>
57
+ <td>Audit</td>
58
+ <td>500€/jour</td>
59
+ </tr>
60
+ </tbody>
61
+ </table>
62
+ </body>
63
+ </html>"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
 
65
 
66
  async def test_document_editor():
67
+ """Test document editor agent with sample contract."""
68
 
69
  # Initialize LLM (requires OPENAI_API_KEY environment variable)
70
  api_key = os.getenv("OPENAI_API_KEY") or os.getenv("CEREBRAS_API_KEY")
 
82
  # Initialize document editor agent
83
  doc_editor = DocumentEditorAgent(llm=llm)
84
 
 
 
 
85
  print("=" * 80)
86
+ print("📄 ORIGINAL DOCUMENT (HTML)")
87
  print("=" * 80)
88
+ print(SAMPLE_CONTRACT)
89
  print("\n")
90
 
91
  # Test 1: Simple text replacement
 
94
  print("=" * 80)
95
 
96
  result1 = await doc_editor.edit_document(
97
+ doc_text=SAMPLE_CONTRACT,
98
+ user_instruction="Change the contract duration from 12 months to 24 months in Article 2",
99
  doc_summaries=["Service contract between Company X and Company Y"],
100
  max_iterations=5
101
  )
 
108
  print("\n📄 MODIFIED DOCUMENT:")
109
  print(result1['doc_text'])
110
 
111
+ # Update for next test
112
+ current_doc = result1['doc_text']
113
+ else:
114
+ current_doc = SAMPLE_CONTRACT
115
 
116
  print("\n" + "=" * 80)
117
+ print("🔧 TEST 2: Add Article 4 about confidentiality")
118
  print("=" * 80)
119
 
120
  result2 = await doc_editor.edit_document(
121
+ doc_text=current_doc,
122
+ user_instruction="Add a new Article 4 after Article 3 that covers confidentiality with a heading 'Article 4 - Confidentialité' and a paragraph explaining that all information exchanged remains confidential",
123
  doc_summaries=["Service contract between Company X and Company Y"],
124
  max_iterations=8
125
  )
 
138
 
139
 
140
  async def test_tools_directly():
141
+ """Test document editor tools directly without the agent."""
142
+ from utils.doc_editor_tools import replace_html, add_html, delete_html, inspect_document, attempt_completion
143
 
144
  print("=" * 80)
145
  print("🔧 TESTING TOOLS DIRECTLY")
146
  print("=" * 80)
147
 
148
+ current_doc = SAMPLE_CONTRACT
 
149
 
150
+ # Test 1: replace_html
151
+ print("\n1️⃣ Testing 'replace_html' tool...")
152
+ result = await replace_html.ainvoke({
153
+ "doc_text": current_doc,
154
+ "search": "<u>12 mois</u>",
155
+ "replace": "<u>24 mois</u>",
156
  "expected_matches": 1
157
  })
158
 
159
  if result['ok']:
160
  print(f"✅ Replace successful! Found {result['matches']} matches")
161
+ current_doc = result['doc_text']
162
  else:
163
  print(f"❌ Replace failed: {result['error']}")
164
 
165
+ # Test 2: add_html
166
+ print("\n2️⃣ Testing 'add_html' tool...")
167
+ result = await add_html.ainvoke({
168
+ "doc_text": current_doc,
169
+ "anchor_search": "<h2>Article 1 - Objet</h2>",
170
+ "insert": "<p><strong>Article ajouté : </strong>Ceci est un nouveau paragraphe inséré avant Article 1.</p>",
171
+ "position": "before",
172
  "expected_matches": 1
173
  })
174
 
175
  if result['ok']:
176
+ print(f"✅ Add successful! Inserted before anchor")
177
+ current_doc = result['doc_text']
178
+ else:
179
+ print(f"❌ Add failed: {result['error']}")
180
+
181
+ # Test 3: delete_html
182
+ print("\n3️⃣ Testing 'delete_html' tool...")
183
+ result = await delete_html.ainvoke({
184
+ "doc_text": current_doc,
185
+ "search": "<li>Renouvellement tacite</li>",
186
+ "expected_matches": 1
187
+ })
188
+
189
+ if result['ok']:
190
+ print(f"✅ Delete successful! Removed {result['matches']} element")
191
+ current_doc = result['doc_text']
192
  else:
193
  print(f"❌ Delete failed: {result['error']}")
 
194
 
195
+ print("\n4️⃣ Testing 'inspect_document' tool...")
196
+ # Note: In direct testing, we can pass doc_text for backward compatibility
197
+ # But in agent mode, it will be called without arguments
198
+ result = await inspect_document.ainvoke({"doc_text": current_doc})
199
+ if result['ok']:
200
+ print(f"✅ inspect_document works! Document size: {len(result['content'])} bytes")
201
+ else:
202
+ print(f"❌ inspect_document failed")
203
+
204
+ print("\n5️⃣ Final document after tool tests:")
205
+ print(current_doc)
206
 
207
+ # Test 6: attempt_completion
208
+ print("\n6️⃣ Testing 'attempt_completion' tool...")
209
  result = await attempt_completion.ainvoke({
210
+ "message": "Successfully modified the HTML document"
211
  })
212
  print(f"✅ Completion: {result}")
213
 
tests/test_tools_ainvoke.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Simple test to verify tools work with .ainvoke()
4
+ """
5
+
6
+ import asyncio
7
+ import sys
8
+ import os
9
+
10
+ # Add parent directory to path
11
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
12
+
13
+ from utils.doc_editor_tools import replace_html, add_html, delete_html, inspect_document, attempt_completion
14
+
15
+
16
+ async def test_tool_ainvoke():
17
+ """Test that all tools work with .ainvoke() method."""
18
+
19
+ print("=" * 80)
20
+ print("Testing LangChain Tools with .ainvoke()")
21
+ print("=" * 80)
22
+
23
+ # Simple HTML document for testing
24
+ doc_html = """
25
+ <html>
26
+ <head><title>Test</title></head>
27
+ <body>
28
+ <h1>Hello World</h1>
29
+ <p>This is a test paragraph.</p>
30
+ </body>
31
+ </html>
32
+ """
33
+
34
+ # Test 1: replace_html
35
+ print("\n1️⃣ Testing replace_html.ainvoke()...")
36
+ try:
37
+ result = await replace_html.ainvoke({
38
+ "doc_text": doc_html,
39
+ "search": "Hello World",
40
+ "replace": "Bonjour Monde",
41
+ "expected_matches": 1
42
+ })
43
+ if result['ok']:
44
+ print(f"✅ replace_html works! Found {result['matches']} matches")
45
+ doc_html = result['doc_text']
46
+ else:
47
+ print(f"❌ replace_html failed: {result['error']}")
48
+ return False
49
+ except Exception as e:
50
+ print(f"❌ replace_html error: {e}")
51
+ return False
52
+
53
+ # Test 2: add_html
54
+ print("\n2️⃣ Testing add_html.ainvoke()...")
55
+ try:
56
+ result = await add_html.ainvoke({
57
+ "doc_text": doc_html,
58
+ "anchor_search": "<h1>Bonjour Monde</h1>",
59
+ "insert": "<p>Added paragraph</p>",
60
+ "position": "after",
61
+ "expected_matches": 1
62
+ })
63
+ if result['ok']:
64
+ print(f"✅ add_html works! Found {result['matches']} matches")
65
+ doc_html = result['doc_text']
66
+ else:
67
+ print(f"❌ add_html failed: {result['error']}")
68
+ return False
69
+ except Exception as e:
70
+ print(f"❌ add_html error: {e}")
71
+ return False
72
+
73
+ # Test 3: delete_html
74
+ print("\n3️⃣ Testing delete_html.ainvoke()...")
75
+ try:
76
+ result = await delete_html.ainvoke({
77
+ "doc_text": doc_html,
78
+ "search": "<p>This is a test paragraph.</p>",
79
+ "expected_matches": 1
80
+ })
81
+ if result['ok']:
82
+ print(f"✅ delete_html works! Removed {result['matches']} elements")
83
+ doc_html = result['doc_text']
84
+ else:
85
+ print(f"❌ delete_html failed: {result['error']}")
86
+ return False
87
+ except Exception as e:
88
+ print(f"❌ delete_html error: {e}")
89
+ return False
90
+
91
+ # Test 4: inspect_document
92
+ print("\n4️⃣ Testing inspect_document.ainvoke()...")
93
+ try:
94
+ result = await inspect_document.ainvoke({
95
+ "doc_text": doc_html
96
+ })
97
+ if result['ok']:
98
+ print(f"✅ inspect_document works! Document size: {len(result['content'])} bytes")
99
+ else:
100
+ print(f"❌ inspect_document failed")
101
+ return False
102
+ except Exception as e:
103
+ print(f"❌ inspect_document error: {e}")
104
+ return False
105
+
106
+ # Test 5: attempt_completion
107
+ print("\n5️⃣ Testing attempt_completion.ainvoke()...")
108
+ try:
109
+ result = await attempt_completion.ainvoke({
110
+ "message": "Successfully tested all tools"
111
+ })
112
+ if result['ok']:
113
+ print(f"✅ attempt_completion works! Message: {result['message']}")
114
+ else:
115
+ print(f"❌ attempt_completion failed")
116
+ return False
117
+ except Exception as e:
118
+ print(f"❌ attempt_completion error: {e}")
119
+ return False
120
+
121
+ print("\n" + "=" * 80)
122
+ print("✅ ALL TESTS PASSED!")
123
+ print("=" * 80)
124
+ return True
125
+
126
+
127
+ if __name__ == "__main__":
128
+ success = asyncio.run(test_tool_ainvoke())
129
+ sys.exit(0 if success else 1)
utils/doc_editor_tools.py CHANGED
@@ -53,9 +53,10 @@ async def _validate_html(html: str) -> tuple[bool, str]:
53
  return False, f"HTML parsing failed: {str(e)}"
54
 
55
 
56
- async def _replace_html(doc_text: str, search: str, replace: str, expected_matches: int = 1) -> Dict[str, Any]:
 
57
  """
58
- Internal implementation of replace_html (with doc_text parameter).
59
 
60
  This tool performs exact string matching on the HTML content.
61
  It's critical that the 'search' parameter matches exactly (including whitespace, tags, and attributes).
@@ -69,8 +70,12 @@ async def _replace_html(doc_text: str, search: str, replace: str, expected_match
69
  Returns:
70
  Dict with 'ok' (bool), 'doc_text' (updated HTML), 'matches' (int),
71
  and optionally 'error' (str) if something went wrong
 
 
 
 
72
  """
73
- logger.info(f" 🔧 replace_html | search:{search}b | replace:{replace}b | expect:{expected_matches}")
74
 
75
  # Count exact matches
76
  m = doc_text.count(search) if search else 0
@@ -107,11 +112,12 @@ async def _replace_html(doc_text: str, search: str, replace: str, expected_match
107
  }
108
 
109
 
110
- async def _add_html(doc_text: str, anchor_search: str, insert: str,
111
- position: Literal["before", "after"] = "after",
112
- expected_matches: int = 1) -> Dict[str, Any]:
 
113
  """
114
- Internal implementation of add_html (with doc_text parameter).
115
 
116
  This tool finds an exact anchor block and inserts new content adjacent to it.
117
  Useful for adding new paragraphs, sections, or elements.
@@ -126,8 +132,13 @@ async def _add_html(doc_text: str, anchor_search: str, insert: str,
126
  Returns:
127
  Dict with 'ok' (bool), 'doc_text' (updated HTML), 'matches' (int),
128
  and optionally 'error' (str) if something went wrong
 
 
 
 
 
129
  """
130
- logger.info(f" 🔧 add_html | anchor:{anchor_search} | insert:{insert} | pos:{position} | expect:{expected_matches}")
131
 
132
  # Count exact matches of anchor
133
  m = doc_text.count(anchor_search) if anchor_search else 0
@@ -167,9 +178,10 @@ async def _add_html(doc_text: str, anchor_search: str, insert: str,
167
  }
168
 
169
 
170
- async def _delete_html(doc_text: str, search: str, expected_matches: int = 1) -> Dict[str, Any]:
 
171
  """
172
- Internal implementation of delete_html (with doc_text parameter).
173
 
174
  This is a convenience wrapper around replace_html with an empty replacement.
175
  Useful for removing unwanted sections, clauses, or content.
@@ -182,122 +194,49 @@ async def _delete_html(doc_text: str, search: str, expected_matches: int = 1) ->
182
  Returns:
183
  Dict with 'ok' (bool), 'doc_text' (updated HTML), 'matches' (int),
184
  and optionally 'error' (str) if something went wrong
185
- """
186
- logger.info(f" 🔧 delete_html | search:{search} | expect:{expected_matches}")
187
- return await _replace_html(doc_text, search, "", expected_matches)
188
-
189
-
190
- async def _inspect_document(doc_text: str) -> Dict[str, Any]:
191
- """
192
- Internal implementation of inspect_document (with doc_text parameter).
193
 
194
- Returns the current document state for inspection.
 
 
 
195
 
196
- Args:
197
- doc_text: The HTML document content
198
 
199
- Returns:
200
- Dict with 'ok' (bool) and 'content' (str)
201
- """
202
- logger.info(f" 🔍 inspect_document | size:{len(doc_text)}b")
203
- return {
204
- "ok": True,
205
- "content": doc_text
206
- }
207
-
208
-
209
- async def _attempt_completion(message: str) -> Dict[str, Any]:
210
- """
211
- Internal implementation of attempt_completion.
212
 
213
- Signals that document editing is complete.
 
214
 
215
- Args:
216
- message: Summary message describing what was changed
 
 
 
 
 
 
 
 
217
 
218
- Returns:
219
- Dict with 'ok' (bool) and 'message' (str)
220
- """
221
- logger.info(f" ✅ attempt_completion | {message}")
222
  return {
223
  "ok": True,
224
- "message": message
 
225
  }
226
 
227
 
228
  @tool
229
- async def replace_html(search: str, replace: str, expected_matches: int = 1) -> Dict[str, Any]:
230
- """
231
- Replace an exact block of HTML text in the document.
232
-
233
- This tool performs exact string matching on the HTML content.
234
- It's critical that the 'search' parameter matches exactly (including whitespace, tags, and attributes).
235
-
236
- Args:
237
- search: The exact HTML block to replace (must match exactly, including whitespace)
238
- replace: The exact HTML block to insert
239
- expected_matches: Expected number of occurrences (default: 1)
240
-
241
- Returns:
242
- Dict with 'ok' (bool), 'doc_text' (updated HTML), 'matches' (int),
243
- and optionally 'error' (str) if something went wrong
244
-
245
- Example:
246
- search = "<p>12 mois</p>"
247
- replace = "<p>24 mois</p>"
248
- """
249
-
250
-
251
- @tool
252
- async def add_html(anchor_search: str, insert: str,
253
- position: Literal["before", "after"] = "after",
254
- expected_matches: int = 1) -> Dict[str, Any]:
255
- """
256
- Add HTML content before or after an anchor block in the document.
257
-
258
- This tool finds an exact anchor block and inserts new content adjacent to it.
259
- Useful for adding new paragraphs, sections, or elements.
260
-
261
- Args:
262
- anchor_search: The exact HTML block to find (must match exactly)
263
- insert: The exact HTML block to insert
264
- position: "before" or "after" (default: "after")
265
- expected_matches: Expected number of anchor occurrences (default: 1)
266
-
267
- Returns:
268
- Dict with 'ok' (bool), 'doc_text' (updated HTML), 'matches' (int),
269
- and optionally 'error' (str) if something went wrong
270
-
271
- Example:
272
- anchor_search = "<h2>Article 2 - Durée</h2>"
273
- insert = "<h3>Article 3 - Prix</h3>"
274
- position = "after"
275
- """
276
-
277
-
278
- @tool
279
- async def delete_html(search: str, expected_matches: int = 1) -> Dict[str, Any]:
280
- """
281
- Delete an exact block of HTML from document.
282
-
283
- This is a convenience wrapper around replace_html with an empty replacement.
284
- Useful for removing unwanted sections, clauses, or content.
285
-
286
- Args:
287
- search: The exact HTML block to delete (must match exactly)
288
- expected_matches: Expected number of occurrences (default: 1)
289
-
290
- Returns:
291
- Dict with 'ok' (bool), 'doc_text' (updated HTML), 'matches' (int),
292
- and optionally 'error' (str) if something went wrong
293
-
294
- Example:
295
- search = "<p>This paragraph should be deleted</p>"
296
- """
297
-
298
-
299
- @tool
300
- async def inspect_document() -> Dict[str, Any]:
301
  """
302
  Return the current state of the document.
303
 
@@ -305,14 +244,17 @@ async def inspect_document() -> Dict[str, Any]:
305
  This adds the document state to the conversation context so you can verify
306
  previous edits and understand the current structure.
307
 
308
- The doc_text is automatically injected from the current state.
309
 
310
  Returns:
311
- Dict with the current document content
312
  """
313
- # doc_text will be injected from state in _tools_node
 
 
314
  return {
315
- "ok": True
 
316
  }
317
 
318
 
@@ -334,4 +276,4 @@ async def attempt_completion(message: str) -> Dict[str, Any]:
334
  return {
335
  "ok": True,
336
  "message": message
337
- }
 
53
  return False, f"HTML parsing failed: {str(e)}"
54
 
55
 
56
+ @tool
57
+ async def replace_html(doc_text: str, search: str, replace: str, expected_matches: int = 1) -> Dict[str, Any]:
58
  """
59
+ Replace an exact block of HTML text in the document.
60
 
61
  This tool performs exact string matching on the HTML content.
62
  It's critical that the 'search' parameter matches exactly (including whitespace, tags, and attributes).
 
70
  Returns:
71
  Dict with 'ok' (bool), 'doc_text' (updated HTML), 'matches' (int),
72
  and optionally 'error' (str) if something went wrong
73
+
74
+ Example:
75
+ search = "<p>12 mois</p>"
76
+ replace = "<p>24 mois</p>"
77
  """
78
+ logger.info(f" 🔧 replace_html | search:{len(search)}b | replace:{len(replace)}b | expect:{expected_matches}")
79
 
80
  # Count exact matches
81
  m = doc_text.count(search) if search else 0
 
112
  }
113
 
114
 
115
+ @tool
116
+ async def add_html(doc_text: str, anchor_search: str, insert: str,
117
+ position: Literal["before", "after"] = "after",
118
+ expected_matches: int = 1) -> Dict[str, Any]:
119
  """
120
+ Add HTML content before or after an anchor block in the document.
121
 
122
  This tool finds an exact anchor block and inserts new content adjacent to it.
123
  Useful for adding new paragraphs, sections, or elements.
 
132
  Returns:
133
  Dict with 'ok' (bool), 'doc_text' (updated HTML), 'matches' (int),
134
  and optionally 'error' (str) if something went wrong
135
+
136
+ Example:
137
+ anchor_search = "<h2>Article 2 - Durée</h2>"
138
+ insert = "<h3>Article 3 - Prix</h3>"
139
+ position = "after"
140
  """
141
+ logger.info(f" 🔧 add_html | anchor:{len(anchor_search)}b | insert:{len(insert)}b | pos:{position} | expect:{expected_matches}")
142
 
143
  # Count exact matches of anchor
144
  m = doc_text.count(anchor_search) if anchor_search else 0
 
178
  }
179
 
180
 
181
+ @tool
182
+ async def delete_html(doc_text: str, search: str, expected_matches: int = 1) -> Dict[str, Any]:
183
  """
184
+ Delete an exact block of HTML from document.
185
 
186
  This is a convenience wrapper around replace_html with an empty replacement.
187
  Useful for removing unwanted sections, clauses, or content.
 
194
  Returns:
195
  Dict with 'ok' (bool), 'doc_text' (updated HTML), 'matches' (int),
196
  and optionally 'error' (str) if something went wrong
 
 
 
 
 
 
 
 
197
 
198
+ Example:
199
+ search = "<p>This paragraph should be deleted</p>"
200
+ """
201
+ logger.info(f" 🔧 delete_html | search:{len(search)}b | expect:{expected_matches}")
202
 
203
+ # Count exact matches
204
+ m = doc_text.count(search) if search else 0
205
 
206
+ if m != expected_matches:
207
+ error = f"Search not found. Expected {expected_matches}, found {m}"
208
+ logger.warning(f" ❌ {error}")
209
+ return {
210
+ "ok": False,
211
+ "error": error,
212
+ "matches": m,
213
+ "DANGER_PANEL": f"⚠️⚠️⚠️ MODIFICATION FAILED ⚠️⚠️⚠️\n{error}\nHTML must match EXACTLY and your search pattern has unexpected number of matches"
214
+ }
 
 
 
 
215
 
216
+ # Perform replacement with empty string
217
+ new_text = doc_text.replace(search, "", expected_matches)
218
 
219
+ # Validate result with BeautifulSoup
220
+ is_valid, validation_error = await _validate_html(new_text)
221
+ if not is_valid:
222
+ logger.warning(f" ❌ Validation failed: {validation_error}")
223
+ return {
224
+ "ok": False,
225
+ "error": f"Invalid HTML: {validation_error}",
226
+ "matches": m,
227
+ "DANGER_PANEL": f"⚠️⚠️⚠️ MODIFICATION FAILED ⚠️⚠️⚠️\n{validation_error}"
228
+ }
229
 
230
+ logger.info(f" ✅ Success | -{len(doc_text)-len(new_text)}b")
 
 
 
231
  return {
232
  "ok": True,
233
+ "doc_text": new_text,
234
+ "matches": m
235
  }
236
 
237
 
238
  @tool
239
+ async def inspect_document(doc_text: str = None) -> Dict[str, Any]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
  """
241
  Return the current state of the document.
242
 
 
244
  This adds the document state to the conversation context so you can verify
245
  previous edits and understand the current structure.
246
 
247
+ The document content will be automatically injected from the workflow state.
248
 
249
  Returns:
250
+ Dict with 'ok' (bool) and 'content' (str) containing the HTML document
251
  """
252
+ # Note: doc_text is injected by _tools_node in doc_editor.py
253
+ # but we accept it as optional parameter for backward compatibility with tests
254
+ logger.info(f" 🔍 inspect_document | size:{len(doc_text) if doc_text else 0}b")
255
  return {
256
+ "ok": True,
257
+ "content": doc_text
258
  }
259
 
260
 
 
276
  return {
277
  "ok": True,
278
  "message": message
279
+ }