Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 94 additions & 10 deletions Sources/CheWordMCP/Server.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4119,7 +4119,30 @@ actor WordMCPServer {
),
Tool(
name: "estimate_paragraph_for_page",
description: "估算 Word UI 第 N 頁大約落在哪些 get_paragraphs 段落索引。OOXML 不儲存頁面邊界;此工具使用 section page size / margins 與字元數啟發式估計,回傳 JSON、confidence 與 warning(支援 Direct Mode)。",
description: """
估算 Word UI 第 N 頁大約落在哪些 get_paragraphs 段落索引(支援 Direct Mode)。

OOXML 不儲存頁面邊界;此工具用 char-count heuristic + section page size / margins 估計,回傳 JSON 含 estimated_paragraph_range / raw_estimated_paragraph_range / confidence / confidence_reason / warning 等欄位。

Confidence ladder(confidence + confidence_reason 兩個欄位一起讀):
- high / caller_provided_chars_per_page — caller 提供 chars_per_page,視為 ground truth
- medium / default_heuristic_long_doc — 預設 heuristic + paragraph_count >= 10
- low / default_heuristic_short_doc — 預設 heuristic + 文件較短
- low / page_beyond_estimated_document — 請求頁碼超過估算總頁數
- low / empty_document — 文件 0 段落

錯誤回傳格式(JSON):
- invalid_parameter — 含 field / reason / received,例如 page > 100000、chars_per_page <= 0、context_paragraphs > 1024
- empty_document — 文件 0 段落(仍回完整 JSON 含 confidence/method 等欄位,error 標 'empty_document')

預設 chars_per_page 推導(內部):
charsPerLine = max(20, usableWidthTwips / 220)
linesPerPage = max(10, usableHeightTwips / 480)
default = max(400, charsPerLine * linesPerPage)
≈ A4 default margins → ~1189 chars/page(CJK thesis 友好)
≈ US Letter default margins → ~1134 chars/page
英文 IEEE / ACM single-column 約 3000-3500 chars/page,建議用 chars_per_page 校準。
""",
inputSchema: .object([
"type": .string("object"),
"properties": .object([
Expand All @@ -4133,15 +4156,15 @@ actor WordMCPServer {
]),
"page": .object([
"type": .string("integer"),
"description": .string("Word UI 頁碼(1-based;page=1 表示第一頁)")
"description": .string("Word UI 頁碼(1-based;page=1 表示第一頁;上限 100000,超過會回 invalid_parameter 防 Int overflow)")
]),
"chars_per_page": .object([
"type": .string("integer"),
"description": .string("可選校準值。若提供,直接使用此每頁字元數;否則依 section page size / margins 估算。")
"description": .string("可選校準值(範圍 1..200000)。若提供,使用此每頁字元數並升 confidence 為 'high'(caller_provided_chars_per_page);否則依 section page size / margins 估算(CJK thesis ≈ 1189,英文 IEEE 建議 ≈ 3000-3500)。")
]),
"context_paragraphs": .object([
"type": .string("integer"),
"description": .string("可選,向前/向後擴張的段落數(預設 2;設 0 可取得純估計範圍)")
"description": .string("可選,向前/向後擴張的段落數(預設 2,範圍 0..1024;設 0 可取得純估計範圍)")
])
]),
"required": .array([.string("page")])
Expand Down Expand Up @@ -11571,7 +11594,11 @@ actor WordMCPServer {
// from triggering Int overflow trap on caller-controlled Int.max input.
// 100_000 pages exceeds any real document by ~3 orders of magnitude.
guard page >= 1 && page <= 100_000 else {
return "Error: estimate_paragraph_for_page: page must be 1..100000, got \(page)"
return try Self.estimateValidationError(
field: "page",
reason: "must be 1..100000",
received: page
)
}

let charsPerPage: Int
Expand All @@ -11580,7 +11607,11 @@ actor WordMCPServer {
// Same overflow concern: page * charsPerPage with charsPerPage=Int.max.
// 200_000 chars/page is far beyond any plausible single-page density.
guard override > 0 && override <= 200_000 else {
return "Error: estimate_paragraph_for_page: chars_per_page must be 1..200000, got \(override)"
return try Self.estimateValidationError(
field: "chars_per_page",
reason: "must be 1..200000",
received: override
)
}
charsPerPage = override
layoutBasis = "caller_chars_per_page"
Expand All @@ -11593,16 +11624,23 @@ actor WordMCPServer {
// Upper bound prevents rawStart - contextParagraphs underflow and
// rawEnd + contextParagraphs overflow on Int.max input.
guard contextParagraphs >= 0 && contextParagraphs <= 1024 else {
return "Error: estimate_paragraph_for_page: context_paragraphs must be 0..1024, got \(contextParagraphs)"
return try Self.estimateValidationError(
field: "context_paragraphs",
reason: "must be 0..1024",
received: contextParagraphs
)
}

let paragraphs = doc.getParagraphs()
guard !paragraphs.isEmpty else {
return try Self.renderJSONString([
"error": "empty_document",
"reason": "document has 0 paragraphs",
"estimated_paragraph_range": [],
"confidence": "low",
"confidence_reason": "empty_document",
"method": "char_count_heuristic",
"layout_basis": layoutBasis,
"assumed_chars_per_page": charsPerPage,
"warning": Self.pageEstimateWarning,
])
Expand Down Expand Up @@ -11634,14 +11672,41 @@ actor WordMCPServer {

let startIndex = max(0, rawStart - contextParagraphs)
let endIndex = min(paragraphs.count - 1, rawEnd + contextParagraphs)
let confidence = (!beyondEstimatedDocument && layoutBasis == "section_properties" && paragraphs.count >= 3)
? "medium"
: "low"

// Confidence semantics (see schema description for the full ladder):
//
// high — caller provided a calibrated chars_per_page; we trust their
// ground-truth knowledge of their document over the heuristic.
// medium — default heuristic on section-derived layout with enough
// paragraphs (≥10) to smooth out per-paragraph noise.
// low — short docs, beyond-document extrapolation, fallback layout,
// or empty document.
//
// Pre-fix (#143): caller-supplied chars_per_page was DOWNGRADED to "low"
// because layoutBasis switched to "caller_chars_per_page", failing the
// medium predicate. Inverted semantics — caller calibration is the most
// reliable input we have. Fixed: caller-provided is now "high".
let confidence: String
let confidenceReason: String
if beyondEstimatedDocument {
confidence = "low"
confidenceReason = "page_beyond_estimated_document"
} else if layoutBasis == "caller_chars_per_page" {
confidence = "high"
confidenceReason = "caller_provided_chars_per_page"
} else if paragraphs.count >= 10 {
confidence = "medium"
confidenceReason = "default_heuristic_long_doc"
} else {
confidence = "low"
confidenceReason = "default_heuristic_short_doc"
}

return try Self.renderJSONString([
"estimated_paragraph_range": [startIndex, endIndex],
"raw_estimated_paragraph_range": [rawStart, rawEnd],
"confidence": confidence,
"confidence_reason": confidenceReason,
"method": "char_count_heuristic",
"layout_basis": layoutBasis,
"page": page,
Expand All @@ -11655,6 +11720,25 @@ actor WordMCPServer {
])
}

/// Build the structured-JSON validation-error response shared by all
/// `estimate_paragraph_for_page` argument guards. Replaces the previous
/// plain-text `"Error: estimate_paragraph_for_page: ..."` strings (#145).
/// `received` is echoed so an LLM caller can self-correct without guessing
/// what it sent (#129 same pattern as PR #115's match_options validators).
private static func estimateValidationError(
field: String,
reason: String,
received: Int
) throws -> String {
return try renderJSONString([
"error": "invalid_parameter",
"tool": "estimate_paragraph_for_page",
"field": field,
"reason": reason,
"received": received,
])
}

private static let pageEstimateWarning = "OOXML does not store page boundaries; this is an estimate based on character density and section page setup. Actual page boundaries depend on rendering, fonts, margins, line spacing, images, tables, and Word layout."

private static func estimatedLayoutCharacterCount(for paragraph: Paragraph) -> Int {
Expand Down
148 changes: 131 additions & 17 deletions Tests/CheWordMCPTests/Issue89EstimateParagraphForPageTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,10 @@ final class Issue89EstimateParagraphForPageTests: XCTestCase {
"page": .int(0)
]
)
XCTAssertTrue(textOf(invalidPage).contains("page must be"))
let invalidPageObj = try jsonObject(from: textOf(invalidPage))
XCTAssertEqual(invalidPageObj["error"] as? String, "invalid_parameter")
XCTAssertEqual(invalidPageObj["field"] as? String, "page")
XCTAssertEqual((invalidPageObj["received"] as? NSNumber)?.intValue, 0)

let invalidCalibration = await server.invokeToolForTesting(
name: "estimate_paragraph_for_page",
Expand All @@ -79,15 +82,19 @@ final class Issue89EstimateParagraphForPageTests: XCTestCase {
"chars_per_page": .int(0)
]
)
XCTAssertTrue(textOf(invalidCalibration).contains("chars_per_page must be"))
let invalidCalibrationObj = try jsonObject(from: textOf(invalidCalibration))
XCTAssertEqual(invalidCalibrationObj["error"] as? String, "invalid_parameter")
XCTAssertEqual(invalidCalibrationObj["field"] as? String, "chars_per_page")
XCTAssertEqual((invalidCalibrationObj["received"] as? NSNumber)?.intValue, 0)
}

// MARK: - Int.max overflow regression (P1 from 6-AI verify)

func testEstimateParagraphForPageRejectsHugePage() async throws {
// Pre-fix: `(page - 1) * charsPerPage` and `page * charsPerPage` with
// page = Int.max trapped on arithmetic overflow → MCP server actor
// crashed. Post-fix clamps page to 1..100_000.
// crashed. Post-fix clamps page to 1..100_000 and returns a structured
// invalid_parameter JSON error (#145 unification).
let url = try docxWithFixedParagraphs(count: 1, charsPerParagraphBeforeBreak: 10)
defer { try? FileManager.default.removeItem(at: url) }

Expand All @@ -99,11 +106,12 @@ final class Issue89EstimateParagraphForPageTests: XCTestCase {
"page": .int(.max)
]
)
let text = textOf(result)
XCTAssertTrue(
text.contains("page must be") && text.contains("100000"),
"expected structured upper-bound rejection, got: \(text)"
)
let obj = try jsonObject(from: textOf(result))
XCTAssertEqual(obj["error"] as? String, "invalid_parameter")
XCTAssertEqual(obj["field"] as? String, "page")
XCTAssertEqual(obj["reason"] as? String, "must be 1..100000")
// received echoes the offending value so an LLM caller can self-correct (#129).
XCTAssertEqual((obj["received"] as? NSNumber)?.intValue, .max)
}

func testEstimateParagraphForPageRejectsHugeCharsPerPage() async throws {
Expand All @@ -120,11 +128,10 @@ final class Issue89EstimateParagraphForPageTests: XCTestCase {
"chars_per_page": .int(.max)
]
)
let text = textOf(result)
XCTAssertTrue(
text.contains("chars_per_page must be") && text.contains("200000"),
"expected structured upper-bound rejection, got: \(text)"
)
let obj = try jsonObject(from: textOf(result))
XCTAssertEqual(obj["error"] as? String, "invalid_parameter")
XCTAssertEqual(obj["field"] as? String, "chars_per_page")
XCTAssertEqual(obj["reason"] as? String, "must be 1..200000")
}

func testEstimateParagraphForPageRejectsHugeContextParagraphs() async throws {
Expand All @@ -142,11 +149,118 @@ final class Issue89EstimateParagraphForPageTests: XCTestCase {
"context_paragraphs": .int(.max)
]
)
let text = textOf(result)
XCTAssertTrue(
text.contains("context_paragraphs must be") && text.contains("1024"),
"expected structured upper-bound rejection, got: \(text)"
let obj = try jsonObject(from: textOf(result))
XCTAssertEqual(obj["error"] as? String, "invalid_parameter")
XCTAssertEqual(obj["field"] as? String, "context_paragraphs")
XCTAssertEqual(obj["reason"] as? String, "must be 0..1024")
}

// MARK: - Confidence label calibration (#143)

func testCallerProvidedCharsPerPageGivesHighConfidence() async throws {
// Pre-fix: caller-supplied chars_per_page DOWNGRADED confidence to
// "low" because layoutBasis switched to "caller_chars_per_page" and
// the medium predicate required "section_properties". Inverted —
// caller calibration is the most reliable input we have.
let url = try docxWithFixedParagraphs(count: 5, charsPerParagraphBeforeBreak: 200)
defer { try? FileManager.default.removeItem(at: url) }

let server = await WordMCPServer()
let result = await server.invokeToolForTesting(
name: "estimate_paragraph_for_page",
arguments: [
"source_path": .string(url.path),
"page": .int(1),
"chars_per_page": .int(500)
]
)
let obj = try jsonObject(from: textOf(result))
XCTAssertEqual(obj["confidence"] as? String, "high")
XCTAssertEqual(obj["confidence_reason"] as? String, "caller_provided_chars_per_page")
XCTAssertEqual(obj["layout_basis"] as? String, "caller_chars_per_page")
}

func testLongDocumentDefaultHeuristicGivesMediumConfidence() async throws {
// 12 paragraphs (>= 10 threshold) on default heuristic → medium.
let url = try docxWithFixedParagraphs(count: 12, charsPerParagraphBeforeBreak: 200)
defer { try? FileManager.default.removeItem(at: url) }

let server = await WordMCPServer()
let result = await server.invokeToolForTesting(
name: "estimate_paragraph_for_page",
arguments: [
"source_path": .string(url.path),
"page": .int(1)
]
)
let obj = try jsonObject(from: textOf(result))
XCTAssertEqual(obj["confidence"] as? String, "medium")
XCTAssertEqual(obj["confidence_reason"] as? String, "default_heuristic_long_doc")
}

func testShortDocumentDefaultHeuristicGivesLowConfidence() async throws {
// 3 paragraphs (< 10 threshold) on default heuristic → low.
let url = try docxWithFixedParagraphs(count: 3, charsPerParagraphBeforeBreak: 100)
defer { try? FileManager.default.removeItem(at: url) }

let server = await WordMCPServer()
let result = await server.invokeToolForTesting(
name: "estimate_paragraph_for_page",
arguments: [
"source_path": .string(url.path),
"page": .int(1)
]
)
let obj = try jsonObject(from: textOf(result))
XCTAssertEqual(obj["confidence"] as? String, "low")
XCTAssertEqual(obj["confidence_reason"] as? String, "default_heuristic_short_doc")
}

func testBeyondDocumentRequestKeepsLowConfidence() async throws {
// page beyond estimated total → "low" + page_beyond_estimated_document
// regardless of layout basis. Caller calibration cannot rescue an
// out-of-range page.
let url = try docxWithFixedParagraphs(count: 5, charsPerParagraphBeforeBreak: 50)
defer { try? FileManager.default.removeItem(at: url) }

let server = await WordMCPServer()
let result = await server.invokeToolForTesting(
name: "estimate_paragraph_for_page",
arguments: [
"source_path": .string(url.path),
"page": .int(99),
"chars_per_page": .int(100)
]
)
let obj = try jsonObject(from: textOf(result))
XCTAssertEqual(obj["confidence"] as? String, "low")
XCTAssertEqual(obj["confidence_reason"] as? String, "page_beyond_estimated_document")
XCTAssertEqual(obj["requested_page_beyond_estimated_document"] as? Bool, true)
}

// MARK: - Empty document path documented (#145)

func testEmptyDocumentReturnsStructuredErrorWithFullSchema() async throws {
// Empty doc still returns method / layout_basis / confidence so callers
// can parse uniformly without branching on error key first.
let url = try docxWithFixedParagraphs(count: 0, charsPerParagraphBeforeBreak: 0)
defer { try? FileManager.default.removeItem(at: url) }

let server = await WordMCPServer()
let result = await server.invokeToolForTesting(
name: "estimate_paragraph_for_page",
arguments: [
"source_path": .string(url.path),
"page": .int(1)
]
)
let obj = try jsonObject(from: textOf(result))
XCTAssertEqual(obj["error"] as? String, "empty_document")
XCTAssertEqual(obj["confidence"] as? String, "low")
XCTAssertEqual(obj["confidence_reason"] as? String, "empty_document")
XCTAssertNotNil(obj["method"])
XCTAssertNotNil(obj["layout_basis"])
XCTAssertNotNil(obj["warning"])
}

func testEstimateParagraphForPageSchemaDocumentsHeuristicWarning() throws {
Expand Down
Loading