feat(cv-import): add source-grounded review and explicit corrections
This commit is contained in:
@@ -37,6 +37,21 @@ otherSections.N.title or otherSections.N.items.N. Relationships across ambiguous
|
||||
FIELD = re.compile(r"^(?:contact\.(?:fullName|headline|email|phone|location|website|linkedIn|gitHub)|contact\.links\.\d{1,3}\.(?:label|url)|(?:summary|skills|interests|awards|publications|organisations|references)\.\d{1,3}|(?:jobs|education|certifications|projects|languages)\.\d{1,3}\.(?:title|company|name|institution|qualification|qualificationLevel|issuer|role|location|start|end|date|level|notes|(?:bullets|skills|details)\.\d{1,3})|otherSections\.\d{1,3}\.(?:title|items\.\d{1,3}))$")
|
||||
|
||||
|
||||
def _supported_field(field):
|
||||
parts = field.split(".")
|
||||
fields = {
|
||||
"jobs": ({"title", "company", "location", "start", "end"}, {"bullets", "skills"}),
|
||||
"education": ({"institution", "qualification", "qualificationLevel", "location", "start", "end"}, {"details"}),
|
||||
"certifications": ({"name", "issuer", "location", "date"}, {"details"}),
|
||||
"projects": ({"name", "role", "location", "start", "end"}, {"bullets", "skills"}),
|
||||
"languages": ({"name", "level", "notes"}, set()),
|
||||
}
|
||||
if parts[0] not in fields:
|
||||
return bool(FIELD.fullmatch(field))
|
||||
scalar, lists = fields[parts[0]]
|
||||
return bool(FIELD.fullmatch(field)) and ((len(parts) == 3 and parts[2] in scalar) or (len(parts) == 4 and parts[2] in lists))
|
||||
|
||||
|
||||
def _validate_config(config):
|
||||
url = urlsplit(config.base_url)
|
||||
if url.scheme != "http" or url.username or url.password or url.query or url.fragment or url.path not in ("", "/") or not config.model or len(config.model) > 200:
|
||||
@@ -88,7 +103,8 @@ def analyse(blocks, config=None, generate=None):
|
||||
for item in parsed["suggestions"]:
|
||||
if not isinstance(item, dict) or set(item) != {"field", "value", "block_id", "quote"} or any(not isinstance(value, str) or not value.strip() or len(value) > 8000 for value in item.values()):
|
||||
raise ValueError("Invalid model suggestion.")
|
||||
if not FIELD.fullmatch(item["field"]) or item["field"] in seen or item["block_id"] not in by_id or clean(item["quote"]) not in clean(by_id[item["block_id"]]) or clean(item["value"]) not in clean(item["quote"]):
|
||||
grounded_value = re.search(r"(?<![\w+#.])" + re.escape(clean(item["value"])) + r"(?![\w+#.])", clean(item["quote"]))
|
||||
if not _supported_field(item["field"]) or item["field"] in seen or item["block_id"] not in by_id or clean(item["quote"]) not in clean(by_id[item["block_id"]]) or not grounded_value:
|
||||
raise ValueError("Unsupported model suggestion.")
|
||||
seen.add(item["field"])
|
||||
suggestions.append({**item, "method": "model-assisted", "review_state": "needs_review"})
|
||||
|
||||
@@ -242,6 +242,10 @@ def _validate_import_docx(data: bytes) -> None:
|
||||
if node.tag.rsplit("}", 1)[-1] in {"altChunk", "OLEObject", "object"}:
|
||||
raise ParserError("external_content")
|
||||
if node.attrib.get("TargetMode", "").lower() == "external":
|
||||
# Hyperlinks are inert source data; converters cannot follow them in the sandbox.
|
||||
from urllib.parse import urlsplit
|
||||
if node.attrib.get("Type", "").endswith("/hyperlink") and urlsplit(node.attrib.get("Target", "")).scheme.lower() in {"http", "https", "mailto"}:
|
||||
continue
|
||||
raise ParserError("external_content")
|
||||
target = node.attrib.get("Target", "")
|
||||
if ":" in target or "\\" in target or target.startswith("/"):
|
||||
@@ -265,7 +269,7 @@ def _validate_import_pdf(data: bytes) -> int:
|
||||
count = len(reader.pages)
|
||||
if count > MAX_PDF_PAGES:
|
||||
raise ParserError("pdf_page_limit")
|
||||
forbidden = {"/OpenAction", "/AA", "/JS", "/JavaScript", "/Launch", "/GoToR", "/URI", "/SubmitForm", "/ImportData", "/EmbeddedFiles", "/Filespec", "/RichMedia", "/XFA"}
|
||||
forbidden = {"/OpenAction", "/AA", "/JS", "/JavaScript", "/Launch", "/GoToR", "/SubmitForm", "/ImportData", "/EmbeddedFiles", "/Filespec", "/RichMedia", "/XFA"}
|
||||
pending = [(reader.trailer, 0)]
|
||||
seen: set[int] = set()
|
||||
while pending:
|
||||
@@ -278,6 +282,9 @@ def _validate_import_pdf(data: bytes) -> int:
|
||||
if depth > 40 or len(seen) > 10_000:
|
||||
raise ParserError("pdf_object_limit")
|
||||
if isinstance(value, DictionaryObject):
|
||||
from urllib.parse import urlsplit
|
||||
if "/URI" in value and urlsplit(str(value["/URI"])).scheme.lower() not in {"http", "https", "mailto"}:
|
||||
raise ParserError("external_content")
|
||||
if forbidden.intersection(value) or str(value.get("/S", "")) in forbidden or str(value.get("/Type", "")) in forbidden:
|
||||
raise ParserError("external_content")
|
||||
pending.extend((item, depth + 1) for item in value.values())
|
||||
|
||||
@@ -27,6 +27,8 @@ def test_grounded_suggestion_is_explicitly_unverified_model_output():
|
||||
{"field": "jobs.0.start", "value": "2020-01", "block_id": "b0001", "quote": "2020"},
|
||||
{"field": "education.0.qualification", "value": "PhD", "block_id": "b0001", "quote": "Python"},
|
||||
{"field": "metadata.owner", "value": "Python", "block_id": "b0001", "quote": "Python"},
|
||||
{"field": "jobs.0.issuer", "value": "Python", "block_id": "b0001", "quote": "Python"},
|
||||
{"field": "skills.0", "value": "Py", "block_id": "b0001", "quote": "Python"},
|
||||
{"field": "skills.0", "value": "Python", "block_id": "missing", "quote": "Python"},
|
||||
{"field": "skills.0", "value": "Python", "block_id": "b0001", "quote": "Python", "confidence": 100},
|
||||
])
|
||||
|
||||
@@ -11,6 +11,7 @@ from pathlib import Path
|
||||
import pytest
|
||||
from docx import Document
|
||||
from pypdf import PdfWriter
|
||||
from pypdf.annotations import Link
|
||||
from pypdf.generic import DecodedStreamObject, DictionaryObject, NameObject, TextStringObject
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
@@ -103,6 +104,30 @@ def test_docx_rejects_external_relationships():
|
||||
assert failure.value.code == "external_content"
|
||||
|
||||
|
||||
def test_docx_allows_inert_hyperlinks_without_network_access():
|
||||
payload = io.BytesIO()
|
||||
with zipfile.ZipFile(io.BytesIO(docx_bytes())) as source, zipfile.ZipFile(payload, "w", zipfile.ZIP_DEFLATED) as output:
|
||||
for entry in source.infolist():
|
||||
body = source.read(entry)
|
||||
if entry.filename == "word/_rels/document.xml.rels":
|
||||
body = body.replace(b"</Relationships>", b'<Relationship Id="safe-link" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" TargetMode="External" Target="https://portfolio.example.test"/></Relationships>')
|
||||
output.writestr(entry.filename, body)
|
||||
assert parser_child.extract_document(".docx", payload.getvalue())["method"] == "docling-docx-v1"
|
||||
|
||||
|
||||
def test_pdf_allows_inert_web_links_but_rejects_script_links():
|
||||
for target, allowed in [("https://portfolio.example.test", True), ("javascript:alert(1)", False)]:
|
||||
writer = PdfWriter(clone_from=io.BytesIO(pdf_bytes()))
|
||||
writer.add_annotation(page_number=0, annotation=Link(rect=(40, 700, 200, 720), url=target))
|
||||
output = io.BytesIO()
|
||||
writer.write(output)
|
||||
if allowed:
|
||||
assert parser_child.extract_document(".pdf", output.getvalue())["method"] == "docling-native-pdf-v1"
|
||||
else:
|
||||
with pytest.raises(parser_child.ParserError):
|
||||
parser_child.extract_document(".pdf", output.getvalue())
|
||||
|
||||
|
||||
def test_docx_rejects_archive_traversal():
|
||||
payload = io.BytesIO()
|
||||
with zipfile.ZipFile(io.BytesIO(docx_bytes())) as source, zipfile.ZipFile(payload, "w", zipfile.ZIP_DEFLATED) as output:
|
||||
|
||||
Reference in New Issue
Block a user