fix(ocr+safety): stop document-content hallucinations from empty OCR
Three independent bugs combined to make Shira fabricate the content of case-46-Friedman's appeal in production (CTS case rewritten as a knee injury, wrong dates, wrong court file number, wrong %-of-disability — written into save_memory/Task/Meeting on prod). * api/services/ocr.py — ocr_docx_images now reads word/document.xml first (text content), then OCRs embedded images. Previously it only OCR'd the word/media/* images, so a text-heavy DOCX with one signature image returned only "[signature]" to the LLM. Verified against the same Friedman appeal: 16633 chars / 80 paragraphs / 0 XML noise. * mcp_server/tools/document_tools.py — _ocr_fallback now refuses to wrap an empty/signature-only OCR result as success. If <80 useful chars or just "[signature]", returns an explicit failure that instructs the LLM not to describe / summarize the document. * api/services/prompt_builder.py — TOOL_RULES adds the absolute rule "DOCUMENT FAITHFULNESS": never quote a document not literally in the tool result for THIS turn; never infer content from a file name; treat dates and case numbers as especially dangerous to invent. Refs Task Master #5, #6, #7 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,20 +1,20 @@
|
|||||||
{
|
{
|
||||||
"models": {
|
"models": {
|
||||||
"main": {
|
"main": {
|
||||||
"provider": "anthropic",
|
"provider": "claude-code",
|
||||||
"modelId": "claude-sonnet-4-20250514",
|
"modelId": "sonnet",
|
||||||
"maxTokens": 64000,
|
"maxTokens": 64000,
|
||||||
"temperature": 0.2
|
"temperature": 0.2
|
||||||
},
|
},
|
||||||
"research": {
|
"research": {
|
||||||
"provider": "perplexity",
|
"provider": "claude-code",
|
||||||
"modelId": "sonar",
|
"modelId": "sonnet",
|
||||||
"maxTokens": 8700,
|
"maxTokens": 8700,
|
||||||
"temperature": 0.1
|
"temperature": 0.1
|
||||||
},
|
},
|
||||||
"fallback": {
|
"fallback": {
|
||||||
"provider": "anthropic",
|
"provider": "claude-code",
|
||||||
"modelId": "claude-3-7-sonnet-20250219",
|
"modelId": "sonnet",
|
||||||
"maxTokens": 120000,
|
"maxTokens": 120000,
|
||||||
"temperature": 0.2
|
"temperature": 0.2
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+61
-18
@@ -84,32 +84,75 @@ async def ocr_pdf(base64_data: str) -> str:
|
|||||||
return "\n\n".join(pages_text)
|
return "\n\n".join(pages_text)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_docx_xml_text(docx_zip) -> str:
|
||||||
|
"""Parse word/document.xml from a DOCX and return plain text.
|
||||||
|
|
||||||
|
The DOCX text payload is a flat sequence of <w:t>...</w:t> runs grouped
|
||||||
|
inside <w:p> paragraphs. We turn each paragraph into one text line.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
try:
|
||||||
|
xml = docx_zip.read("word/document.xml").decode("utf-8", errors="replace")
|
||||||
|
except KeyError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Anchor the run-open to either `<w:t>` (no attrs) or `<w:t ...>` (with attrs).
|
||||||
|
# The `(?:\s[^>]*)?` keeps us from also matching `<w:tab>` / `<w:tbl>` etc.
|
||||||
|
run_re = re.compile(r"<w:t(?:\s[^>]*)?>(.*?)</w:t>", flags=re.DOTALL)
|
||||||
|
para_re = re.compile(r"<w:p(?:\s[^>]*)?>.*?</w:p>", flags=re.DOTALL)
|
||||||
|
|
||||||
|
paragraphs = []
|
||||||
|
for p_match in para_re.finditer(xml):
|
||||||
|
block = p_match.group(0)
|
||||||
|
line = "".join(run_re.findall(block)).strip()
|
||||||
|
if line:
|
||||||
|
paragraphs.append(line)
|
||||||
|
return "\n".join(paragraphs).strip()
|
||||||
|
|
||||||
|
|
||||||
async def ocr_docx_images(base64_data: str) -> str:
|
async def ocr_docx_images(base64_data: str) -> str:
|
||||||
"""Extract images from a DOCX archive and OCR each via Claude Vision."""
|
"""Extract text + images from a DOCX archive.
|
||||||
|
|
||||||
|
Order of operations:
|
||||||
|
1. Read word/document.xml for the actual text content (the LLM needs this).
|
||||||
|
2. OCR any embedded images via Claude Vision (signatures, scanned inserts).
|
||||||
|
|
||||||
|
Previously this only OCR'd images, so a text-heavy DOCX with one signature
|
||||||
|
image returned only `[signature]` — and downstream LLMs hallucinated the rest.
|
||||||
|
"""
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
docx_bytes = base64.b64decode(base64_data)
|
docx_bytes = base64.b64decode(base64_data)
|
||||||
image_extracts = []
|
|
||||||
try:
|
try:
|
||||||
with zipfile.ZipFile(io.BytesIO(docx_bytes)) as z:
|
z = zipfile.ZipFile(io.BytesIO(docx_bytes))
|
||||||
media = [n for n in z.namelist() if n.startswith("word/media/")]
|
|
||||||
logger.info("[ocr] docx images found=%d", len(media))
|
|
||||||
for i, name in enumerate(media[:MAX_PDF_PAGES]):
|
|
||||||
ext = name.rsplit(".", 1)[-1].lower()
|
|
||||||
mime = {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", "gif": "image/gif"}.get(ext, "image/png")
|
|
||||||
img_bytes = z.read(name)
|
|
||||||
img_b64 = base64.b64encode(img_bytes).decode("ascii")
|
|
||||||
try:
|
|
||||||
text = await _call_vision([(mime, img_b64)])
|
|
||||||
image_extracts.append(f"=== תמונה {i + 1} ({name}) ===\n{text}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("[ocr] docx image %s failed: %s", name, e)
|
|
||||||
except zipfile.BadZipFile:
|
except zipfile.BadZipFile:
|
||||||
return "❌ הקובץ אינו DOCX תקין."
|
return "❌ הקובץ אינו DOCX תקין."
|
||||||
|
|
||||||
if not image_extracts:
|
sections = []
|
||||||
return "❌ לא נמצאו תמונות ב-DOCX לחילוץ טקסט."
|
|
||||||
return "\n\n".join(image_extracts)
|
# 1. Text from document.xml
|
||||||
|
xml_text = _extract_docx_xml_text(z)
|
||||||
|
logger.info("[ocr] docx xml text chars=%d", len(xml_text))
|
||||||
|
if xml_text:
|
||||||
|
sections.append(f"=== טקסט מהמסמך ({len(xml_text)} תווים) ===\n{xml_text}")
|
||||||
|
|
||||||
|
# 2. OCR each embedded image
|
||||||
|
media = [n for n in z.namelist() if n.startswith("word/media/")]
|
||||||
|
logger.info("[ocr] docx images found=%d", len(media))
|
||||||
|
for i, name in enumerate(media[:MAX_PDF_PAGES]):
|
||||||
|
ext = name.rsplit(".", 1)[-1].lower()
|
||||||
|
mime = {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", "gif": "image/gif"}.get(ext, "image/png")
|
||||||
|
img_bytes = z.read(name)
|
||||||
|
img_b64 = base64.b64encode(img_bytes).decode("ascii")
|
||||||
|
try:
|
||||||
|
text = await _call_vision([(mime, img_b64)])
|
||||||
|
sections.append(f"=== תמונה {i + 1} ({name}) ===\n{text}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("[ocr] docx image %s failed: %s", name, e)
|
||||||
|
|
||||||
|
if not sections:
|
||||||
|
return "❌ לא נמצא טקסט וגם לא תמונות ב-DOCX."
|
||||||
|
return "\n\n".join(sections)
|
||||||
|
|
||||||
|
|
||||||
async def extract_text_from_bytes(base64_data: str, mime_type: str, file_name: str = "") -> str:
|
async def extract_text_from_bytes(base64_data: str, mime_type: str, file_name: str = "") -> str:
|
||||||
|
|||||||
@@ -44,6 +44,13 @@ TOOL_RULES = (
|
|||||||
"- If a tool call fails, tell the user exactly what went wrong. Never pretend it succeeded.\n"
|
"- If a tool call fails, tell the user exactly what went wrong. Never pretend it succeeded.\n"
|
||||||
"- If you cannot perform an action (missing context, wrong mode), explain why honestly.\n"
|
"- If you cannot perform an action (missing context, wrong mode), explain why honestly.\n"
|
||||||
"- After a successful tool call, you will receive the result. Only THEN confirm to the user what happened.\n"
|
"- After a successful tool call, you will receive the result. Only THEN confirm to the user what happened.\n"
|
||||||
|
"- DOCUMENT FAITHFULNESS (absolute rule — violations have already caused real damage in production):\n"
|
||||||
|
" * NEVER quote or summarize a document unless you literally see its text in a tool result returned in THIS conversation.\n"
|
||||||
|
" * If read_document / read_document_ocr returns an error, an empty body, or a message saying 'לא הצלחתי לחלץ טקסט' — STOP. Tell the user 'לא הצלחתי לקרוא את המסמך' and stop. Do not guess. Do not infer. Do not fill from memory or from the file name.\n"
|
||||||
|
" * If the tool result contains only '[signature]', '[חתימה]', or fewer than ~80 useful characters — treat it as an empty read. Do NOT invent content around it.\n"
|
||||||
|
" * The file name (e.g. 'כתב ערעור') tells you the TYPE of document, NEVER its CONTENT. Never write 'במסמך כתוב X' based on the file name.\n"
|
||||||
|
" * If the user asks you to base an action on a document and you could not read it — ask the user to paste the relevant text, or to send it differently. Do not proceed with a fabricated version.\n"
|
||||||
|
" * Numbers (dates, percentages, case numbers, IDs) are ESPECIALLY dangerous to invent — they will end up in CRM records and in tasks. When in doubt, ask the user, never guess.\n"
|
||||||
"- Dates: pass in YYYY-MM-DD format. If user says DD/MM/YYYY, convert it before calling the tool.\n"
|
"- Dates: pass in YYYY-MM-DD format. If user says DD/MM/YYYY, convert it before calling the tool.\n"
|
||||||
"- If user does not specify a time for a task or meeting, default to 08:00 morning.\n"
|
"- If user does not specify a time for a task or meeting, default to 08:00 morning.\n"
|
||||||
"- CALL vs MEETING: When user reports they spoke/talked/called someone (שוחחתי, דיברתי, התקשרתי) — use create_call. When user reports a physical meeting or Zoom (נפגשתי, פגישה) — use create_meeting. NEVER use create_meeting for phone calls.\n"
|
"- CALL vs MEETING: When user reports they spoke/talked/called someone (שוחחתי, דיברתי, התקשרתי) — use create_call. When user reports a physical meeting or Zoom (נפגשתי, פגישה) — use create_meeting. NEVER use create_meeting for phone calls.\n"
|
||||||
|
|||||||
@@ -97,6 +97,12 @@ def register_document_tools(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return fail(f"שגיאה בשמירת נתיב: {e}")
|
return fail(f"שגיאה בשמירת נתיב: {e}")
|
||||||
|
|
||||||
|
# Minimum length (in characters) of an extracted body that we consider
|
||||||
|
# "real text". Below this, the OCR almost certainly returned only chrome
|
||||||
|
# like "[signature]" or "תמונה ריקה" and the LLM has nothing to work with —
|
||||||
|
# so we MUST report failure explicitly, never wrap it as success.
|
||||||
|
MIN_USEFUL_TEXT_CHARS = 80
|
||||||
|
|
||||||
async def _ocr_fallback(filePath: str, reason: str) -> str:
|
async def _ocr_fallback(filePath: str, reason: str) -> str:
|
||||||
"""Fetch file bytes and OCR via Claude Vision."""
|
"""Fetch file bytes and OCR via Claude Vision."""
|
||||||
from api.services.ocr import extract_text_from_bytes
|
from api.services.ocr import extract_text_from_bytes
|
||||||
@@ -115,10 +121,28 @@ def register_document_tools(
|
|||||||
text = await extract_text_from_bytes(
|
text = await extract_text_from_bytes(
|
||||||
bytes_result["base64"], mime_type, file_name
|
bytes_result["base64"], mime_type, file_name
|
||||||
)
|
)
|
||||||
return ok(f'=== {file_name} (OCR via Vision) ===\n\n{text}')
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return fail(f'שגיאה ב-OCR של "{file_name}": {e}')
|
return fail(f'שגיאה ב-OCR של "{file_name}": {e}')
|
||||||
|
|
||||||
|
# Guard against the hallucination trap: if OCR returned only a signature
|
||||||
|
# or a near-empty result, the LLM must NOT be allowed to invent content
|
||||||
|
# to fill the gap. Return an explicit failure so it tells the user it
|
||||||
|
# could not read the document.
|
||||||
|
body = (text or "").strip()
|
||||||
|
body_lower = body.lower()
|
||||||
|
looks_empty = (
|
||||||
|
len(body) < MIN_USEFUL_TEXT_CHARS
|
||||||
|
or body_lower in {"[signature]", "signature", "חתימה", "[חתימה]"}
|
||||||
|
or body.startswith("❌")
|
||||||
|
)
|
||||||
|
if looks_empty:
|
||||||
|
return fail(
|
||||||
|
f'לא הצלחתי לחלץ טקסט שמיש מ-"{file_name}" (התקבלו רק {len(body)} תווים, "{body[:60]}"). '
|
||||||
|
'אל תנסי לתאר או לסכם את המסמך — הטקסט לא הגיע אלייך. '
|
||||||
|
'הציעי למשתמש לפתוח את הקובץ ידנית או לנסות מסמך אחר.'
|
||||||
|
)
|
||||||
|
return ok(f'=== {file_name} (OCR via Vision) ===\n\n{body}')
|
||||||
|
|
||||||
async def read_document(filePath: str) -> str:
|
async def read_document(filePath: str) -> str:
|
||||||
try:
|
try:
|
||||||
result = await crm.post("SmartAssistant/action/readDocument", {"filePath": filePath})
|
result = await crm.post("SmartAssistant/action/readDocument", {"filePath": filePath})
|
||||||
|
|||||||
Reference in New Issue
Block a user