Null in JSON response from model becomes application "DEVONthink" in AppleScript

\"new_date\": null in JSON response from model, becomes application “DEVONthink”’ in AppleScript. I would expect ““ in AppleScript instead.

Prompt

property globalPrompt : "¬
Generate a concise, descriptive filename for the following text.¬
# Rules/Expectations for the filename:¬
- 3-6 words maximum, no more than 50 letters¬
- Use only lowercase letters, numbers, and underscores¬
- Capture the main topic or purpose of the given text¬
- Be specific enough to identify the content¬
  - For invoices: if a company name is given always use it¬
  - For invoices: use the main product¬
  - Ignore city names in invoices¬
  - For bookings: use the name of the place or the destination¬
- Ignore prices for filename¬
- Ignore first names or surnames of persons¬
- Add type of document, but avoid generic terms at all costs like \"document\", \"file\", or \"text\"¬
- Avoid any names of persons at all costs¬
- Avoid adding a date to the new filename¬
# Steps¬
2. Capture the main topic or purpose based on the text¬
3. Determine type of document
3. Extract dates from the text - ignore ones before the date saved in the CURRENT_DATE-variable¬
4. Order dates from newest to oldest¬
5. Use the oldest date in YYYY-MM-DD notation¬
6. Generate the name in German for the file - follow the rule mentioned ealier¬
7. Make sure the filename follows the rules mentioned earlier - fix it, if it does stick to the rules¬
8. Make sure the filename follows the rules mentioned earlier - fix it, if it does stick to the rules¬
¬
# Expected output¬
You are an excellent JSON generator. You leave out any explanation! You only output the JSON object.¬
Extract date and filename from the input.¬
Ignore dates before the CURRENT_DATE-variable
¬
Filename format: [topic]-[subtopic]-[descriptor]-[doc_type] (if applicable)¬
# expected json output fields with data type¬
```json¬
{¬
 \"new_filename\": \"string\",¬
 \"new_date\": \"date\",¬
 \"doc_type\": \"enum(rechnung, vertrag, quittung, versicherungspolice, mietvertrag, garantieschein, steuerbescheid, kontoauszug, kreditvertrag, kostenvoranschlag, bescheinigung, anderes_dokument)\"¬
}¬
¬
# example for expected output¬
```json¬
{¬
 \"new_filename\": \"this-is-the-file-name\",¬
 \"new_date\": \"1970-10-01\",¬
 \"doc_type\": \"rechnung\"¬
}¬
```¬
¬
# Variables¬

"

LM Studio

[microsoft/phi-4] Generated prediction:  {
  "id": "chatcmpl-gwj0xt0earg64x8jf5mz4h",
  "object": "chat.completion",
  "created": 1760740196,
  "model": "microsoft/phi-4",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "```json\n{\n  \"new_filename\": \"xxxx-optic-brillen-service\",\n  \"new_date\": null,\n  \"doc_type\": \"anderes_dokument\"\n}\n```",
        "tool_calls": []
      },
      "logprobs": null,
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 1532,
    "completion_tokens": 41,
    "total_tokens": 1573
  },
  "stats": {},
  "system_fingerprint": "microsoft/phi-4"
}

Script Debugger

image


Generate a concise, descriptive filename for the following text.¬
# Rules/Expectations for the filename:¬
- 3-6 words maximum, no more than 50 letters¬
- Use only lowercase letters, numbers, and underscores¬
- Capture the main topic or purpose of the given text¬
- Be specific enough to identify the content¬
  - For invoices: if a company name is given always use it¬
  - For invoices: use the main product¬
  - Ignore city names in invoices¬
  - For bookings: use the name of the place or the destination¬
- Ignore prices for filename¬
- Ignore first names or surnames of persons¬
- Add type of document, but avoid generic terms at all costs like \"document\", \"file\", or \"text\"¬
- Avoid any names of persons at all costs¬
- Avoid adding a date to the new filename¬
- Use \"\" for date if no date can be found in document
# Steps¬
2. Capture the main topic or purpose based on the text¬
3. Determine type of document
3. Extract dates from the text - ignore ones before the date saved in the CURRENT_DATE-variable¬
4. Order dates from newest to oldest¬
5. Use the oldest date in YYYY-MM-DD notation¬
6. Generate the name in German for the file - follow the rule mentioned ealier¬
7. Make sure the filename follows the rules mentioned earlier - fix it, if it does stick to the rules¬
8. Make sure the filename follows the rules mentioned earlier - fix it, if it does stick to the rules¬
¬
# Expected output¬
You are an excellent JSON generator. You leave out any explanation! You only output the JSON object.¬
Extract date and filename from the input.¬
Ignore dates before the CURRENT_DATE-variable
¬
Filename format: [topic]-[subtopic]-[descriptor]-[doc_type] (if applicable)¬
# expected json output fields with data type¬
```json¬
{¬
 \"new_filename\": \"string\",¬
 \"new_date\": \"date\",¬
 \"doc_type\": \"enum(rechnung, vertrag, quittung, versicherungspolice, mietvertrag, garantieschein, steuerbescheid, kontoauszug, kreditvertrag, kostenvoranschlag, bescheinigung, anderes_dokument)\"¬
}¬
¬
# example for expected output¬
```json¬
{¬
 \"new_filename\": \"this-is-the-file-name\",¬
 \"new_date\": \"1970-10-01\",¬
 \"doc_type\": \"rechnung\"¬
}¬
```¬
¬
# Variables¬

"

Fixed by adding this to the prompt.

- Use \"\" for date if no date can be found in document

Full script

rename script - document-rename.scpt

use AppleScript version "2.5" -- Yosemite (10.10) or later
use scripting additions
use framework "Foundation"
use script "RegexAndStuffLib" version "1.0.7"

property globalSuccessTags : {"auto-processed-rename-ai"}
property globalAiRequestTimeout : 120

property globalPrompt : "¬
Generate a concise, descriptive filename for the following text.¬
# Rules/Expectations for the filename:¬
- 3-6 words maximum, no more than 50 letters¬
- Use only lowercase letters, numbers, and underscores¬
- Capture the main topic or purpose of the given text¬
- Be specific enough to identify the content¬
  - For invoices: if a company name is given always use it¬
  - For invoices: use the main product¬
  - Ignore city names in invoices¬
  - For bookings: use the name of the place or the destination¬
- Ignore prices for filename¬
- Ignore first names or surnames of persons¬
- Add type of document, but avoid generic terms at all costs like \"document\", \"file\", or \"text\"¬
- Avoid any names of persons at all costs¬
- Avoid adding a date to the new filename¬
- Use \"\" for date if no date can be found in document
# Steps¬
2. Capture the main topic or purpose based on the text¬
3. Determine type of document
3. Extract dates from the text - ignore ones before the date saved in the CURRENT_DATE-variable¬
4. Order dates from newest to oldest¬
5. Use the oldest date in YYYY-MM-DD notation¬
6. Generate the name in German for the file - follow the rule mentioned ealier¬
7. Make sure the filename follows the rules mentioned earlier - fix it, if it does stick to the rules¬
8. Make sure the filename follows the rules mentioned earlier - fix it, if it does stick to the rules¬
¬
# Expected output¬
You are an excellent JSON generator. You leave out any explanation! You only output the JSON object.¬
Extract date and filename from the input.¬
Ignore dates before the CURRENT_DATE-variable
¬
Filename format: [topic]-[subtopic]-[descriptor]-[doc_type] (if applicable)¬
# expected json output fields with data type¬
```json¬
{¬
 \"new_filename\": \"string\",¬
 \"new_date\": \"date\",¬
 \"doc_type\": \"enum(rechnung, vertrag, quittung, versicherungspolice, mietvertrag, garantieschein, steuerbescheid, kontoauszug, kreditvertrag, kostenvoranschlag, bescheinigung, anderes_dokument)\"¬
}¬
¬
# example for expected output¬
```json¬
{¬
 \"new_filename\": \"this-is-the-file-name\",¬
 \"new_date\": \"1970-10-01\",¬
 \"doc_type\": \"rechnung\"¬
}¬
```¬
¬
# Variables¬

"

-- code to run within Script Debugger
tell application id "DNtp" to my performSmartRule(selected records)

-- ==========

-- test performSmartRule
property globalIsDev : true

-- copy template for smart rules
on performSmartRule(theRecords)
	local libraryHandler
	set libraryHandler to script "library-handling"
	
	tell application id "DNtp"
		set currentDb to current database
	end tell
	
	libraryHandler's canRun(currentDb, theRecords)
	
	--- start script 1
	
	local scriptName1
	set scriptName1 to "ai-base"
	
	local theScriptHandler1
	set theScriptHandler1 to libraryHandler's loadScript(scriptName1, globalIsDev)
	
	theScriptHandler1's globalLog("INFO", "Run script", "script=" & quoted form of scriptName1)
	
	theScriptHandler1's SetupLocalAiRuntime()
	
	theScriptHandler1's globalLog("INFO", "Script finished", "script=" & quoted form of scriptName1)
	
	--- start script 2
	
	local scriptName2
	set scriptName2 to "document-rename"
	
	local theScriptHandler2
	set theScriptHandler2 to libraryHandler's loadScriptSilent(scriptName2, globalIsDev)
	
	local thePrompt
	set thePrompt to get globalPrompt of theScriptHandler2
	
	local aiRequestTimeout
	set aiRequestTimeout to globalAiRequestTimeout of theScriptHandler2
	
	local aiEngine
	local theModel
	tell application id "DNtp"
		-- set aiEngine to Ollama
		-- set theModel to "phi4"
		
		set aiEngine to LM Studio
		set theModel to "microsoft/phi-4"
		-- set theModel to "mistralai/mistral-nemo-instruct-2407"
		-- set theModel to "microsoft/phi-4-mini-reasoning"
		-- set theModel to "qwen/qwen3-4b-2507"
	end tell
	
	theScriptHandler2's globalLog("INFO", "Run script", "script=" & quoted form of scriptName2)
	
	theScriptHandler2's ForAll(theRecords, globalIsDev, aiEngine, theModel, thePrompt, aiRequestTimeout)
	
	theScriptHandler2's globalLog("INFO", "Script finished", "script=" & quoted form of scriptName1)
end performSmartRule

-- Run job for all given records
on ForAll(theRecords, localIsDev, theEngine, theModel, thePrompt, localAiRequestTimeout)
	local titleProgressBar
	set titleProgressBar to "Rename documents"
	
	tell application id "DNtp"
		show progress indicator titleProgressBar steps (count of theRecords) with cancel button
		-- https://developer.apple.com/library/archive/documentation/AppleScript/Conceptual/AppleScriptLangGuide/reference/ASLR_error_xmpls.html
	end tell
	set len to length of theRecords
	set i to 1
	
	my globalLog("INFO", quoted form of "Job started", "count_of_records=" & len)
	
	try
		local theRecord
		repeat with theRecord in theRecords
			tell application id "DNtp"
				if cancelled progress then
					my recordLog(theRecord, "INFO", quoted form of "Cancel current run of script", missing value)
					
					exit repeat
				end if
				
				set theFilename to the filename of theRecord
				
				step progress indicator theFilename
			end tell
			my recordLog(theRecord, "INFO", quoted form of "Work on record", "index=" & i & "/" & len)
			
			set modifiedRecord to my ForOne(theRecord, localIsDev, theEngine, theModel, thePrompt, localAiRequestTimeout)
			
			tell application id "DNtp"
				set tags of modifiedRecord to (get tags of modifiedRecord) & globalSuccessTags
			end tell
			
			set i to i + 1
		end repeat
	on error errStr number errorNumber
		-- An unknown error occurred. Resignal, so the caller
		-- can handle it, or AppleScript can display the number.
		display alert errStr
		error errStr number errorNumber
	end try
	
	tell application id "DNtp"
		hide progress indicator
	end tell
	
	my globalLog("INFO", quoted form of "Job completed", "count_of_records=" & len)
end ForAll

-- Run job for single record
on ForOne(theRecord, localIsDev, theEngine, theModel, thePrompt, localAiRequestTimeout)
	local theResponse
	set theResponse to my GetNewNameLoop(theRecord, localIsDev, theEngine, theModel, thePrompt, localAiRequestTimeout)
	tell application id "DNtp"
		local oldName
		set oldName to quoted form of (name of theRecord as string)
	end tell
	
	local newName
	set newName to new_filename of theResponse
	
	local newDate
	set newDate to new_date of theResponse
	
	local docType
	set docType to doc_type of theResponse
	
	my recordLog(theRecord, "INFO", quoted form of "Change name of record", "oldname=" & oldName & " newname=" & quoted form of newName & " new_creation_date=" & quoted form of newDate)
	
	tell application id "DNtp"
		set name of theRecord to newDate & "-" & newName
		
		if newDate is not "" then
			set creation date of theRecord to my toDate(newDate)
		end if
		
		if docType is not "" then
			set tags of theRecord to (tags of theRecord) & {docType}
		end if
	end tell
	
	return theRecord
end ForOne

on GetNewNameLoop(theRecord, localIsDev, theEngine, theModel, thePrompt, localAiRequestTimeout)
	local oldName
	tell application id "DNtp"
		set oldName to filename of theRecord
	end tell
	
	local theResponse
	
	local repeatCounter
	set repeatCounter to 1
	repeat
		try
			my recordLog(theRecord, "DEBUG", quoted form of "Send request to AI models", missing value)
			set theResponse to my GetNewName(theRecord, localIsDev, theEngine, theModel, thePrompt, localAiRequestTimeout)
			exit repeat
		on error errStr number errorNumber
			delay 1
			-- nothing
		end try
		
		if repeatCounter ≥ 3 then
			error "Repeat counter ≥ 3 for GetNewName(): " & errStr & ". Exit"
		end if
		
		set repeatCounter to repeatCounter + 1
	end repeat
	
	return theResponse
end GetNewNameLoop

on GetNewName(theRecord, localIsDev, theEngine, theModel, thePrompt, localAiRequestTimeout)
	local AiBase
	set AiBase to script "ai-base"
	
	local now
	set now to current date
	
	tell application id "DNtp"
		-- Get date components
		set currentYear to year of now
		set currentMonth to rich texts -2 thru -1 of ("00" & ((month of now) as integer))
		set currentDay to rich texts -2 thru -1 of ("00" & (day of now))
	end tell
	
	set currentDate to currentYear - 40 & "-" & currentMonth & "-" & currentDay as string
	
	set thePrompt to thePrompt & "¬
			CURRENT_DATE=" & currentDate
	
	-- log "[DEBUG] Validate chosen AI engine"
	-- set theEngine to AiBase's ValidateAndReturnEngine(theEngine)
	
	local theJsonResponse
	set theJsonResponse to AiBase's AskModelTimeout(theRecord, theEngine, theModel, thePrompt, "JSON", localAiRequestTimeout)
	
	if theJsonResponse is missing value then
		log "[WARN] No (valid) result from AI"
		return missing value
	end if
	
	return theJsonResponse
end GetNewName

on recordLog(theRecord, level, msg, msgInfo)
	if msgInfo is missing value then
		set logMsg to "msg=" & msg
	else
		set logMsg to "msg=" & msg & " " & msgInfo
	end if
	
	if level is not "DEBUG" then
		tell application id "DNtp"
			log message record theRecord info "level=INFO " & logMsg
		end tell
		
		return
	end if
	
	if globalIsDev is true then
		tell application id "DNtp"
			log message record theRecord info "level=DEBUG " & logMsg
		end tell
	end if
end recordLog

on globalLog(level, msg, msgInfo)
	if msgInfo is missing value then
		set logMsg to "msg=" & msg
	else
		set logMsg to "msg=" & msg & " " & msgInfo
	end if
	
	if level is not "DEBUG" then
		tell application id "DNtp"
			log message "level=INFO " & logMsg
		end tell
		
		return
	end if
	
	if globalIsDev is true then
		tell application id "DNtp"
			log message "level=DEBUG " & logMsg
		end tell
	end if
end globalLog

on toDate(isotDate)
	set df to current application's NSDateFormatter's new()
	df's setDateFormat:"y-M-d"
	
	local theDate
	set theDate to (df's dateFromString:isotDate) as date
	
	return theDate
end toDate

ai runner script - ai-base.scpt

use AppleScript version "2.4" -- Yosemite (10.10) or later
use scripting additions
use framework "Foundation"
use script "RegexAndStuffLib" version "1.0.7"


property globalIsDev : false

-- get list of known AI engines
tell application id "DNtp" to my ListModelsForEngines()

on ListModelsForEngines()
	tell application id "DNtp"
		set mChatGPT to get chat models for engine ChatGPT
		log message (join strings mChatGPT using delimiter ",")
		
		set mMistral to (get chat models for engine Mistral)
		log message (join strings mMistral using delimiter ",")
		
		set mOllama to (get chat models for engine Ollama)
		log message (join strings mOllama using delimiter ",")
		
		set mLMStudio to (get chat models for engine LM Studio)
		log message (join strings mLMStudio using delimiter ",")
		
		set mClaude to (get chat models for engine Claude)
		log message (join strings mClaude using delimiter ",")
	end tell
end ListModelsForEngines

on AskModelTimeout(theRecord, theEngine, theModel, thePrompt, theResultFormat, theTimeout)
	if theResultFormat is missing value then
		set theResultFormat to "JSON"
	end if
	
	if theTimeout is missing value then
		set theTimeout to 60 as integer
	end if
	
	tell application id "DNtp"
		with timeout of theTimeout seconds
			local theResponse
			
			my recordLog(theRecord, "DEBUG", "Get response based on engine and model selection", "engine=" & theEngine & " model=" & theModel)
			
			if theModel is missing value then
				set theResponse to get chat response for message thePrompt engine theEngine record theRecord temperature 0 as theResultFormat
			else
				set theResponse to get chat response for message thePrompt engine theEngine model theModel record theRecord temperature 0 as theResultFormat
			end if
			
			return theResponse
		end timeout
	end tell
end AskModelTimeout

on AskModel(theRecord, theEngine, theModel, thePrompt, theResultFormat)
	if theResultFormat is missing value then
		set theResultFormat to "JSON"
	end if
	
	tell application id "DNtp"
		with timeout of 60 seconds
			local theResponse
			
			my recordLog(theRecord, "DEBUG", "Get response based on engine and model selection", "engine=" & theEngine & " model=" & theModel)
			
			if theModel is missing value then
				set theResponse to get chat response for message thePrompt engine theEngine record theRecord temperature 0 as theResultFormat
			else
				set theResponse to get chat response for message thePrompt engine theEngine model theModel record theRecord temperature 0 as theResultFormat
			end if
			
			return theResponse
		end timeout
	end tell
end AskModel

on AskModelForUrlTimeout(theUrl, theEngine, theModel, thePrompt, theResultFormat, theTimeout)
	if theResultFormat is missing value then
		set theResultFormat to "JSON"
	end if
	
	if theTimeout is missing value then
		set theTimeout to 60 as integer
	end if
	
	tell application id "DNtp"
		with timeout of theTimeout seconds
			local theResponse
			
			my recordLog(theRecord, "DEBUG", "Get response based on engine and model selection for URL", missing value)
			
			if theModel is missing value then
				set theResponse to get chat response for message thePrompt engine theEngine URL theUrl temperature 0 as theResultFormat
			else
				set theResponse to get chat response for message thePrompt engine theEngine model theModel URL theUrl temperature 0 as theResultFormat
			end if
			
			return theResponse
		end timeout
	end tell
end AskModelForUrlTimeout

on AskModelForUrl(theUrl, theEngine, theModel, thePrompt, theResultFormat)
	if theResultFormat is missing value then
		set theResultFormat to "JSON"
	end if
	
	tell application id "DNtp"
		with timeout of 60 seconds
			local theResponse
			
			
			my recordLog(theRecord, "DEBUG", "Get response based on engine and model selection for URL", missing value)
			
			if theModel is missing value then
				set theResponse to get chat response for message thePrompt engine theEngine URL theUrl temperature 0 as theResultFormat
			else
				set theResponse to get chat response for message thePrompt engine theEngine model theModel URL theUrl temperature 0 as theResultFormat
			end if
			
			return theResponse
		end timeout
	end tell
end AskModelForUrl

on GenImage(theRecord, theEngine, thePrompt, theSize)
	if theSize is missing value then
		set theSize to "1024x1024"
	end if
	
	tell application id "DNtp"
		with timeout of 60 seconds
			local theResponse
			
			my recordLog(theRecord, "DEBUG", "Get response based on engine selection", missing value)
			
			set theResponse to download image for prompt thePrompt engine theEngine size theSize
			
			return theResponse
		end timeout
	end tell
end GenImage


on GetParentLocation(theRecord)
	tell application id "DNtp"
		local theParents
		set theParents to parents of theRecord
		
		local theParentGroup
		set theParentGroup to (item 1 of theParents)
		
		return theParentGroup
	end tell
end GetParentLocation

on SetupLocalAiRuntime()
	if isLocalAppRunning("MstyStudio") then
		return
	end if
	
	tell application "MstyStudio" to activate
	
	tell application "System Events"
		set visible of application process "MstyStudio" to false
	end tell
	
	tell application id "DNtp" to activate
	delay 10
end SetupLocalAiRuntime

on isLocalAppRunning(appName)
	tell application "System Events" to (name of processes) contains appName
end isLocalAppRunning

on recordLog(theRecord, level, msg, msgInfo)
	if msgInfo is missing value then
		set logMsg to "msg=" & msg
	else
		set logMsg to "msg=" & msg & " " & msgInfo
	end if
	
	if level is not "DEBUG" then
		tell application id "DNtp"
			log message record theRecord info "level=INFO " & logMsg
		end tell
		
		return
	end if
	
	if globalIsDev is true then
		tell application id "DNtp"
			log message record theRecord info "level=DEBUG " & logMsg
		end tell
	end if
end recordLog

on globalLog(level, msg, msgInfo)
	if msgInfo is missing value then
		set logMsg to "msg=" & msg
	else
		set logMsg to "msg=" & msg & " " & msgInfo
	end if
	
	if level is not "DEBUG" then
		tell application id "DNtp"
			log message "level=INFO " & logMsg
		end tell
		
		return
	end if
	
	if globalIsDev is true then
		tell application id "DNtp"
			log message "level=DEBUG " & logMsg
		end tell
	end if
end globalLog

library loader with logging - library-handling.scpt

use AppleScript version "2.4" -- Yosemite (10.10) or later
use scripting additions

property globalIsDev : true

on loadScript(scriptName, localIsDev)
	if localIsDev is true then
		display alert "Development Mode enabled"
	end if
	
	set theScriptHandler to my loadScriptSilent(scriptName, localIsDev)
	
	return theScriptHandler
end loadScript

on loadScriptSilent(scriptName, localIsDev)
	local theScriptPath
	set theScriptPath to (system attribute "HOME") & "/Library/Script Libraries/" & scriptName & ".scpt"
	
	my globalLog("DEBUG", "Set script path", "path=" & quoted form of theScriptPath)
	
	-- local theScriptHandler
	set theScriptHandler to load script (POSIX file theScriptPath as alias)
	
	if localIsDev is true then
		set theScriptHandler to script scriptName
	end if
	
	return theScriptHandler
end loadScriptSilent

on canRun(theDatabase, theRecords)
	tell application id "DNtp"
		if not (exists theDatabase) then error "No database is in use."
		if the length of theRecords is less than 1 then error "One or more record must be selected."
	end tell
end canRun

on globalLog(level, msg, msgInfo)
	if msgInfo is missing value then
		set logMsg to "msg=" & msg
	else
		set logMsg to "msg=" & msg & " " & msgInfo
	end if
	
	if level is not "DEBUG" then
		tell application id "DNtp"
			log message "level=INFO " & logMsg
		end tell
		
		return
	end if
	
	if globalIsDev is true then
		tell application id "DNtp"
			log message "level=DEBUG " & logMsg
		end tell
	end if
end globalLog

That’s either an issue of AppleScript or of Script Debugger as DEVONthink definitely returns the raw response or message including null and this should become missing value. However, in the end it’s hard to tell as the above code doesn’t contain the required external scripts and how get chat response for message is used, if at all.

However, in the end it’s hard to tell as the above code doesn’t contain the required external scripts and how get chat response for message is used, if at all.

ai runner script - ai-base.scpt is the script where get chat response for messageis called. I added the name of the files to make it more obvious.

The only script missing was “library loader with logging - library-handling.scpt”. I attached it as well. Sorry for that.

Dependencies:

  • document-rename → library-loader
  • document-rename → ai-base