From 10046a92e5b02ea0d180f4762d52910eaa1deaaf Mon Sep 17 00:00:00 2001 From: Tom Date: Thu, 2 Jul 2026 14:02:20 -0400 Subject: [PATCH] Require doc_type and summary in the AI output schema Both were in the schema but not required, so the model omitted them under constrained generation and pydantic defaulted them to empty strings. Mark every field required (also makes the schema OpenAI strict-mode compliant / portable to cloud providers) and add minLength 1 to doc_type and summary so the model cannot satisfy the constraint with an empty string. date stays nullable; tags may be empty. Prompt updated to flag both as required. Verified live against a vision-enabled llama-server (Qwen3.6-35B + mmproj): doc_type now populated as drawing/receipt/invoice where it was previously blank. Co-Authored-By: Claude Opus 4.8 --- scanfiler/ai/prompt.py | 8 +++++--- scanfiler/ai/schema.py | 28 +++++++++++++++++----------- tests/test_schema.py | 17 +++++++++++++++++ 3 files changed, 39 insertions(+), 14 deletions(-) diff --git a/scanfiler/ai/prompt.py b/scanfiler/ai/prompt.py index 9416272..f7f7672 100644 --- a/scanfiler/ai/prompt.py +++ b/scanfiler/ai/prompt.py @@ -17,12 +17,14 @@ - subdir: PREFER one of the existing subfolders below. Only set is_new_subdir=true and invent a new one when none genuinely fits.{new_rule} - date: the document's own date as ISO (YYYY, YYYY-MM, or YYYY-MM-DD); null if none. -- doc_type: a short lowercase noun (receipt, invoice, medical_record, drawing, letter...). -- summary: 1-2 sentences capturing what this document is, for later search. +- doc_type: REQUIRED, never empty — a short lowercase noun (receipt, invoice, + medical_record, drawing, letter, statement...). Use "unknown" only as a last resort. +- summary: REQUIRED, never empty — 1-2 sentences capturing what this document is, for + later search. - tags: 2-6 short lowercase keywords. - confidence: 0..1, your certainty in the filename+subdir. Be honest; low is fine. -Existing subfolders: {subdirs} +Every field is required. Existing subfolders: {subdirs} """ _NO_NEW = "\n- New subfolders are DISABLED: you MUST pick from the existing list." diff --git a/scanfiler/ai/schema.py b/scanfiler/ai/schema.py index 74883f3..add91a1 100644 --- a/scanfiler/ai/schema.py +++ b/scanfiler/ai/schema.py @@ -39,20 +39,26 @@ def build_response_format(existing_subdirs: list[str], allow_new: bool) -> dict: else: subdir_schema = {"type": "string"} + properties = { + "filename": {"type": "string", "minLength": 1}, + "subdir": subdir_schema, + "is_new_subdir": {"type": "boolean"}, + # minLength forces non-empty: the model can't satisfy the constraint by + # emitting "" for doc_type/summary (which it did when they were optional). + "doc_type": {"type": "string", "minLength": 1}, + "date": {"type": ["string", "null"]}, + "summary": {"type": "string", "minLength": 1}, + "tags": {"type": "array", "items": {"type": "string"}}, + "confidence": {"type": "number", "minimum": 0, "maximum": 1}, + } schema = { "type": "object", "additionalProperties": False, - "required": ["filename", "subdir", "confidence"], - "properties": { - "filename": {"type": "string", "minLength": 1}, - "subdir": subdir_schema, - "is_new_subdir": {"type": "boolean"}, - "doc_type": {"type": "string"}, - "date": {"type": ["string", "null"]}, - "summary": {"type": "string"}, - "tags": {"type": "array", "items": {"type": "string"}}, - "confidence": {"type": "number", "minimum": 0, "maximum": 1}, - }, + # All fields required: forces doc_type/summary to be populated and makes the + # schema OpenAI strict-mode compliant (portable to cloud providers). `date` + # stays nullable; `tags` may be an empty array. + "required": list(properties), + "properties": properties, } return { "type": "json_schema", diff --git a/tests/test_schema.py b/tests/test_schema.py index fe97a29..82d5625 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -24,3 +24,20 @@ def test_decision_defaults(): assert d.is_new_subdir is False assert d.tags == [] assert d.date is None + + +def test_all_fields_required(): + schema = build_response_format(["A"], allow_new=True)["json_schema"]["schema"] + # Every property is required (forces doc_type/summary; OpenAI strict-compliant). + assert set(schema["required"]) == set(schema["properties"]) + assert "doc_type" in schema["required"] + assert "summary" in schema["required"] + + +def test_doctype_and_summary_are_non_empty(): + props = build_response_format([], allow_new=True)["json_schema"]["schema"]["properties"] + assert props["doc_type"]["minLength"] == 1 + assert props["summary"]["minLength"] == 1 + # date stays nullable, tags may be empty + assert props["date"] == {"type": ["string", "null"]} + assert "minItems" not in props["tags"]