Skip to content

Commit 49da0cf

Browse files
authored
feat(documents): enhance contract comparison with clause frequency analysis (#750)
1 parent d8b307a commit 49da0cf

22 files changed

Lines changed: 696 additions & 94 deletions

File tree

examples/workflows/contract-comparison/config.json

Lines changed: 61 additions & 38 deletions
Large diffs are not rendered by default.

services/crawler/app/services/base_converter.py

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,45 @@
1010

1111
import asyncio
1212
import logging
13+
import re
1314

1415
from playwright.async_api import Browser, Page, async_playwright
1516

1617
logger = logging.getLogger(__name__)
1718

19+
_ATX_HEADING_RE = re.compile(r"^#{1,6}\s", re.MULTILINE)
20+
21+
22+
def _normalize_markdown_headings(text: str) -> str:
23+
"""Ensure blank lines before ATX headings for reliable parsing.
24+
25+
Skips content inside fenced code blocks (``` or ~~~).
26+
"""
27+
lines = text.split("\n")
28+
result: list[str] = []
29+
in_fence = False
30+
fence_marker = ""
31+
32+
for line in lines:
33+
stripped = line.strip()
34+
35+
if not in_fence:
36+
if stripped.startswith("```") or stripped.startswith("~~~"):
37+
in_fence = True
38+
fence_marker = stripped[:3]
39+
elif stripped == fence_marker or (stripped.startswith(fence_marker) and stripped.rstrip("`~") == ""):
40+
in_fence = False
41+
result.append(line)
42+
continue
43+
44+
if not in_fence and _ATX_HEADING_RE.match(line) and result and result[-1].strip():
45+
result.append("")
46+
47+
result.append(line)
48+
49+
return "\n".join(result)
50+
51+
1852
# Default HTML template for rendering content
1953
# Uses Noto fonts for multi-language support:
2054
# - Noto Sans: Latin (English, German, French, Spanish), Cyrillic (Russian)
@@ -150,15 +184,15 @@ async def markdown_to_html(self, markdown: str) -> str:
150184
"""Convert markdown to HTML using Python-Markdown."""
151185
import markdown as md
152186

153-
# Convert markdown to HTML with common extensions
187+
normalized = _normalize_markdown_headings(markdown)
188+
154189
html = md.markdown(
155-
markdown,
190+
normalized,
156191
extensions=[
157192
"tables",
158193
"fenced_code",
159194
"codehilite",
160195
"toc",
161-
"nl2br",
162196
],
163197
)
164198
return html
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""Tests for _normalize_markdown_headings in base_converter."""
2+
3+
import pytest
4+
5+
from app.services.base_converter import _normalize_markdown_headings
6+
7+
8+
class TestNormalizeMarkdownHeadings:
9+
def test_heading_after_table_row(self):
10+
md = "| a | b |\n|---|---|\n| c | d |\n### Heading"
11+
result = _normalize_markdown_headings(md)
12+
assert "| c | d |\n\n### Heading" in result
13+
14+
def test_heading_after_paragraph(self):
15+
md = "Some text\n### Heading"
16+
result = _normalize_markdown_headings(md)
17+
assert result == "Some text\n\n### Heading"
18+
19+
def test_heading_already_has_blank_line(self):
20+
md = "Some text\n\n### Heading"
21+
result = _normalize_markdown_headings(md)
22+
assert result == "Some text\n\n### Heading"
23+
24+
def test_heading_at_start_of_document(self):
25+
md = "# Title\nSome text"
26+
result = _normalize_markdown_headings(md)
27+
assert result == "# Title\nSome text"
28+
29+
def test_consecutive_headings(self):
30+
md = "## Section\n### Subsection"
31+
result = _normalize_markdown_headings(md)
32+
assert result == "## Section\n\n### Subsection"
33+
34+
def test_hash_inside_fenced_code_block(self):
35+
md = "```python\n# comment\ndef foo():\n pass\n```"
36+
result = _normalize_markdown_headings(md)
37+
assert result == md
38+
39+
def test_hash_inside_tilde_fence(self):
40+
md = "~~~\n# not a heading\n~~~"
41+
result = _normalize_markdown_headings(md)
42+
assert result == md
43+
44+
def test_heading_after_code_block(self):
45+
md = "```\ncode\n```\n## Heading"
46+
result = _normalize_markdown_headings(md)
47+
assert "```\n\n## Heading" in result
48+
49+
def test_hash_in_middle_of_line_unchanged(self):
50+
md = "Use the # symbol for headings"
51+
result = _normalize_markdown_headings(md)
52+
assert result == md
53+
54+
def test_multiple_headings_with_tables(self):
55+
md = "### Section A\n| h1 | h2 |\n|---|---|\n| c1 | c2 |\n### Section B\n| h3 | h4 |\n|---|---|\n| c3 | c4 |"
56+
result = _normalize_markdown_headings(md)
57+
assert "| c2 |\n\n### Section B" in result
58+
59+
def test_empty_input(self):
60+
assert _normalize_markdown_headings("") == ""
61+
62+
def test_no_headings(self):
63+
md = "Just some text\nAnother line"
64+
result = _normalize_markdown_headings(md)
65+
assert result == md
66+
67+
68+
class TestMarkdownToHtmlHeadings:
69+
"""Integration tests verifying headings produce correct HTML tags."""
70+
71+
@pytest.mark.asyncio
72+
async def test_heading_after_table_produces_h3(self):
73+
from app.services.base_converter import BaseConverterService
74+
75+
converter = BaseConverterService()
76+
md = "| a | b |\n|---|---|\n| c | d |\n### Heading"
77+
html = await converter.markdown_to_html(md)
78+
assert "<h3" in html
79+
assert "Heading</h3>" in html
80+
81+
@pytest.mark.asyncio
82+
async def test_heading_not_swallowed_by_table(self):
83+
from app.services.base_converter import BaseConverterService
84+
85+
converter = BaseConverterService()
86+
md = "### Section A\n\n| h1 | h2 |\n|---|---|\n| c1 | c2 |\n### Section B\n\nSome text."
87+
html = await converter.markdown_to_html(md)
88+
assert html.count("</h3>") == 2

services/platform/app/features/chat/components/workflow-update-approval-card.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
CheckCircle,
77
XCircle,
88
Loader2,
9-
RefreshCw,
9+
PenLine,
1010
ChevronDown,
1111
ChevronRight,
1212
Copy,
@@ -159,7 +159,7 @@ function WorkflowUpdateApprovalCardComponent({
159159
{/* Header */}
160160
<HStack gap={2} align="start" justify="between" className="mb-2">
161161
<HStack gap={2}>
162-
<RefreshCw className="text-primary size-4 shrink-0" />
162+
<PenLine className="text-primary size-4 shrink-0" />
163163
<div>
164164
<Text as="div" variant="label">
165165
{metadata.workflowName}

services/platform/convex/agent_tools/documents/document_list_tool.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,11 +97,14 @@ USE THIS TOOL TO:
9797
9898
DO NOT USE THIS TOOL FOR:
9999
• Semantic/content search — use rag_search instead
100-
• Reading indexed document content — use document_retrieve with the document ID instead
101-
• Extracting data from uploaded files — use pdf, docx, txt, excel, image, or pptx tools instead
100+
• Reading indexed document content — use document_retrieve with the document ID (id) instead
101+
• Extracting data from uploaded files — use pdf, docx, txt, excel, image, or pptx tools with the file ID (fileId) instead
102102
103103
RESPONSE FIELDS:
104-
• documents: Array of {id, title, extension, folderPath, teamId, createdAt (Unix ms UTC), sizeBytes}
104+
• documents: Array of {id, fileId, title, extension, folderPath, teamId, createdAt (Unix ms UTC), sizeBytes}
105+
- id: The document ID (Convex record ID). Use with document_retrieve to read indexed content.
106+
- fileId: The storage file ID. Use with file extraction tools (pdf, docx, txt, excel, image, pptx) and any tool that operates on stored files.
107+
- IMPORTANT: id and fileId are DIFFERENT identifiers for different purposes. File extraction tools (pdf, docx, excel, image, etc.) require fileId, NOT id. document_retrieve requires id, NOT fileId.
105108
• totalCount: Total matching documents (number), or null if the scan limit was reached and the true count is unknown — this does NOT mean zero results.
106109
• hasMore: Whether more results are available
107110
• cursor: Pass to next call to get the next page

services/platform/convex/agent_tools/documents/document_retrieve_tool.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export const documentRetrieveArgs = z
1919
.string()
2020
.min(1)
2121
.describe(
22-
'The Convex document ID to retrieve content from. Get IDs from document_list.',
22+
'The Convex document ID (the "id" field from document_list, NOT "fileId"). Use fileId with file extraction tools instead.',
2323
),
2424
chunkStart: z
2525
.number()

services/platform/convex/agent_tools/documents/helpers/fetch_document_comparison.ts

Lines changed: 101 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ function mapChangeBlock(block: RagChangeBlock): ChangeBlock {
105105
}
106106

107107
/**
108-
* Compare two documents via the RAG service's deterministic diff endpoint.
108+
* Compare two documents by ID via the RAG service's deterministic diff endpoint.
109109
*/
110110
export async function fetchDocumentComparison(
111111
ragServiceUrl: string,
@@ -155,28 +155,7 @@ export async function fetchDocumentComparison(
155155
}
156156

157157
const result = await fetchJson<RagCompareResponse>(response);
158-
159-
return {
160-
baseDocument: {
161-
documentId: result.base_document.document_id,
162-
title: result.base_document.title,
163-
},
164-
comparisonDocument: {
165-
documentId: result.comparison_document.document_id,
166-
title: result.comparison_document.title,
167-
},
168-
changeBlocks: result.change_blocks.map(mapChangeBlock),
169-
stats: {
170-
totalParagraphsBase: result.stats.total_paragraphs_base,
171-
totalParagraphsComparison: result.stats.total_paragraphs_comparison,
172-
unchanged: result.stats.unchanged,
173-
modified: result.stats.modified,
174-
added: result.stats.added,
175-
deleted: result.stats.deleted,
176-
highDivergence: result.stats.high_divergence,
177-
},
178-
truncated: result.truncated,
179-
};
158+
return mapRagResponse(result);
180159
} catch (error) {
181160
clearTimeout(timeoutId);
182161

@@ -190,3 +169,102 @@ export async function fetchDocumentComparison(
190169
throw error;
191170
}
192171
}
172+
173+
function mapRagResponse(result: RagCompareResponse): DocumentComparisonResult {
174+
return {
175+
baseDocument: {
176+
documentId: result.base_document.document_id,
177+
title: result.base_document.title,
178+
},
179+
comparisonDocument: {
180+
documentId: result.comparison_document.document_id,
181+
title: result.comparison_document.title,
182+
},
183+
changeBlocks: result.change_blocks.map(mapChangeBlock),
184+
stats: {
185+
totalParagraphsBase: result.stats.total_paragraphs_base,
186+
totalParagraphsComparison: result.stats.total_paragraphs_comparison,
187+
unchanged: result.stats.unchanged,
188+
modified: result.stats.modified,
189+
added: result.stats.added,
190+
deleted: result.stats.deleted,
191+
highDivergence: result.stats.high_divergence,
192+
},
193+
truncated: result.truncated,
194+
};
195+
}
196+
197+
/**
198+
* Compare two files by URL via the RAG service — no pre-indexing required.
199+
*
200+
* Downloads both files and uploads them to the RAG service in a single
201+
* function to avoid passing large Blobs through function parameters.
202+
*/
203+
export async function fetchDocumentComparisonByUrls(
204+
ragServiceUrl: string,
205+
baseFileUrl: string,
206+
baseFileName: string,
207+
comparisonFileUrl: string,
208+
comparisonFileName: string,
209+
maxChanges?: number,
210+
): Promise<DocumentComparisonResult> {
211+
const [baseResponse, compResponse] = await Promise.all([
212+
fetch(baseFileUrl),
213+
fetch(comparisonFileUrl),
214+
]);
215+
if (!baseResponse.ok) {
216+
throw new Error(`Failed to download base file: ${baseResponse.status}`);
217+
}
218+
if (!compResponse.ok) {
219+
throw new Error(
220+
`Failed to download comparison file: ${compResponse.status}`,
221+
);
222+
}
223+
224+
const [baseBlob, compBlob] = await Promise.all([
225+
baseResponse.blob(),
226+
compResponse.blob(),
227+
]);
228+
229+
const url = `${ragServiceUrl}/api/v1/documents/compare-files`;
230+
231+
const formData = new FormData();
232+
formData.append('base_file', baseBlob, baseFileName);
233+
formData.append('comparison_file', compBlob, comparisonFileName);
234+
if (maxChanges != null) {
235+
formData.append('max_changes', String(maxChanges));
236+
}
237+
238+
const controller = new AbortController();
239+
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
240+
241+
try {
242+
const response = await fetch(url, {
243+
method: 'POST',
244+
body: formData,
245+
signal: controller.signal,
246+
});
247+
clearTimeout(timeoutId);
248+
249+
if (!response.ok) {
250+
const errorText = await response.text().catch(() => '');
251+
throw new Error(
252+
`RAG service error (${response.status}): ${errorText || 'Unknown error'}`,
253+
);
254+
}
255+
256+
const result = await fetchJson<RagCompareResponse>(response);
257+
return mapRagResponse(result);
258+
} catch (error) {
259+
clearTimeout(timeoutId);
260+
261+
if (error instanceof Error && error.name === 'AbortError') {
262+
throw new Error(
263+
`RAG service timed out after ${FETCH_TIMEOUT_MS / 1000}s while comparing files.`,
264+
{ cause: error },
265+
);
266+
}
267+
268+
throw error;
269+
}
270+
}

services/platform/convex/agent_tools/integrations/create_bound_integration_tool.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ export function createBoundIntegrationTool(
100100
requiresApproval: true,
101101
approvalId: result.approvalId,
102102
approvalCreated: true,
103-
approvalMessage: `APPROVAL CREATED SUCCESSFULLY: An approval card (ID: ${result.approvalId}) has been created for "${result.operationTitle || args.operation}" on ${integrationName}. The user must approve or reject this operation in the chat UI before it will be executed.`,
103+
approvalMessage: `APPROVAL CREATED SUCCESSFULLY: An approval card (ID: ${result.approvalId}) has been created below your message for "${result.operationTitle || args.operation}" on ${integrationName}. The user must approve or reject this operation before it will be executed.`,
104104
data: {
105105
approvalId: result.approvalId,
106106
operationName: result.operationName,
@@ -147,7 +147,7 @@ function buildDescription(
147147
lines.push(
148148
'',
149149
`Usage: { operation: "<operation_name>", params: { ... } }`,
150-
'Write operations create approval cards for user review.',
150+
'Write operations create approval cards below your message for user review.',
151151
);
152152

153153
return lines.join('\n');

services/platform/convex/agent_tools/integrations/integration_tool.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ Steps:
4949
1. First call integration_introspect(operation="xxx") to get required parameters
5050
2. Then call this tool with ALL required params filled in
5151
52-
Write operations create approval cards. Use integration_batch for multiple parallel reads.`,
52+
Write operations create approval cards below your message. Use integration_batch for multiple parallel reads.`,
5353

5454
args: integrationArgs,
5555

@@ -122,7 +122,7 @@ Write operations create approval cards. Use integration_batch for multiple paral
122122
requiresApproval: true,
123123
approvalId: approvalResult.approvalId,
124124
approvalCreated: true,
125-
approvalMessage: `APPROVAL CREATED SUCCESSFULLY: An approval card (ID: ${approvalResult.approvalId}) has been created for "${approvalResult.operationTitle || args.operation}" on ${args.integrationName}. The user must approve or reject this operation in the chat UI before it will be executed.`,
125+
approvalMessage: `APPROVAL CREATED SUCCESSFULLY: An approval card (ID: ${approvalResult.approvalId}) has been created below your message for "${approvalResult.operationTitle || args.operation}" on ${args.integrationName}. The user must approve or reject this operation before it will be executed.`,
126126
data: {
127127
approvalId: approvalResult.approvalId,
128128
operationName: approvalResult.operationName,

services/platform/convex/agent_tools/workflows/create_bound_workflow_tool.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,8 +152,8 @@ export function createBoundWorkflowTool(
152152
requiresApproval: true,
153153
approvalId,
154154
approvalCreated: true,
155-
approvalMessage: `APPROVAL CREATED SUCCESSFULLY: An approval card (ID: ${approvalId}) has been created to run workflow "${resolvedWf.name}". The user must approve this in the chat UI before execution begins.`,
156-
message: `Workflow "${resolvedWf.name}" is ready to run. An approval card has been created. The workflow will start once the user approves it.`,
155+
approvalMessage: `APPROVAL CREATED SUCCESSFULLY: An approval card (ID: ${approvalId}) has been created below your message to run workflow "${resolvedWf.name}". The user must approve this before execution begins.`,
156+
message: `Workflow "${resolvedWf.name}" is ready to run. An approval card has been created below. The workflow will start once the user approves it.`,
157157
};
158158
} catch (error) {
159159
return {

0 commit comments

Comments
 (0)