The Obsidian plugin I’m building has an LLM read source material and organize it into several notes, one per concept. This problem was language mixing: notes drawn from an English source, with a Korean sentence popping up out of nowhere in the middle of the body. Compared with the previous problem, both the cause and the way I fixed it took a completely different path.
Three prompt fixes, all going nowhere
First I nailed “follow the source language” into a global rule. The reproduction rate didn’t budge. I moved that rule right next to the body text and stressed it again. Still the same. The third time, I put the sentence “every sentence you write from here on must follow the source language, without exception” directly above the instruction for writing the body. All three times, the rate stayed almost exactly where it was: roughly 5 to 7 notes out of 70 with Korean mixed in.
So I built it in code, and fixed two bugs along the way (Korean character ratio regex)
Since prompts alone weren’t catching it, I decided to write deterministic detection code. It counted the ratio of Korean to Latin characters in each paragraph, picked out only the paragraphs that leaned away from the note’s dominant language, and had just that one paragraph rewritten.
While building it, I fixed two bugs myself. The first was measuring the ratio over the whole note joined together: if one paragraph leaked at length, it numerically drowned out all the shorter paragraphs. Having each paragraph vote on its own fixed that. The second was wiki links: when a Korean sentence contained a note path written in English, the characters in that path ate into the ratio. I changed it to keep only the link’s display text and not count the path. A third bug (where the link’s display text itself is a long English phrase) was left uncaught, but for reasons that come next, I stopped chasing it.
Measured, the code had done nothing (dedup prompt)
With the code and the prompt changes all in, I measured in a real Obsidian vault. The result: 0 notes with Korean mixed in out of 65. Completely clean. I almost concluded that the code had won.
But when I looked at the debug log again, something was off. This plugin has a deduplication (dedup) step: when notes created together from one source (sibling notes) share the same paragraph, it deletes one copy and replaces it with a link. The language detection code was set to run twice, before and after that step, and both times it logged “fixed: 0.” The code had run, but it hadn’t done anything.
So what made all 65 clean? There was exactly one other thing I’d changed in the same round: I had added a language rule to the rewrite prompt dedup uses when it deletes a duplicate paragraph from one sibling note and writes a replacement into the other. Since that prompt was first created, it had never once been given an instruction about language. Even when the paragraph being rewritten sat inside an English note, from that prompt’s point of view no one had ever told it which language to write in. Adding one line of rule there was enough to make all 65 clean. The carefully built detect-and-fix code had never been needed in the first place.
Deleting the code and merging it into one rule (output language rule)
Once I’d confirmed the code contributed zero, there was no reason to keep it. I deleted all of it: the detection logic, the rewrite function I’d finished by fixing two bugs, and the wiring that called it. In its place I created a single rule, “output must follow the language of the source material,” and planted it right next to the points where generation actually happens: identically in all four places, the ingest body, merge, split, and that dedup rewrite. This was also when I learned that the merge and split prompts had never had any such language rule at all.
Hours after deleting it, the same question came back somewhere else
I had just settled on the conclusion that “prompts are enough, no need to go as far as code.” Then, while reopening a completely different bug, I ran into a situation that called for exactly the opposite conclusion.
This plugin also has a command (/refactor) that proposes and carries out merging or splitting
notes. In its split execution, there’s a rule to write “0” instead of a number to mean “this piece
replaces the original note in place.” That rule itself had been fixed once before, yet symptoms kept
reproducing intermittently for days: an orphan file named “0.md,” or file names and their actual
contents shifting by one slot. A hypothesis came up: “isn’t the model mistaking ‘0’ for the number
of the first piece, so everything shifts by one?”
The real cause, caught with two lines of logging
The existing debug log only captured circumstantial evidence; it couldn’t tell exactly which stage was corrupting the “0.” I added two debug-only log lines: one for the raw array exactly as the suggestion stage produced it, and one for the result of mapping that array to actual file paths.
As soon as I reproduced it for real, it was caught. Instead of “0,” the suggestion stage was producing the full path “wiki/2026/09/WeekOf14/0.md.” The catalog that shows the model the existing notes presents every one of them, without exception, in the form “path: wiki/…/filename.md,” and other instructions in the same prompt repeatedly stressed “always use the full path when referring to an existing note.” The “0” rule was the only exception to that strong, repeated pattern, yet nowhere in the prompt did it say it was an exception. Re-running the scan on the same source, it was corrupted the same way both times.
This time I knew a prompt alone wouldn’t do it, so I fixed the code too
I put a sentence into the prompt saying “this value is a special signal value, not a file name; never attach an extension or a path,” along with the exact case that had been corrupted, as a counterexample. But I’d learned something a few hours earlier: a prompt fix alone might not catch it 100%. So I added a defense in the code as well. I made it recognize values shaped like “0,” “0.md,” and path+0.md all as the same signal.
I deployed and scanned the same source again. The suggestion stage once again produced “wiki/…/0.md”: the prompt fix hadn’t removed the tendency itself. But this time, that corrupted value was absorbed back into the original note’s place in the execution stage, as it should be. No orphan files, no shifted file names.
A postscript: a month later, in the same kind of test, an all-English note got one Korean tag attached. The body of that note was entirely in English.
Final thoughts
Within the same session, I reached both “code isn’t needed” and “code is needed.” Both were right. Language mixing was a matter of how precisely, and how close to the right spot, the instruction to follow the source language was placed; filling in the one place where it was missing solved it completely, with no code. The problem of the special signal value “0” getting corrupted, on the other hand, was one where the wrong forms it could take were limited to a few. For a problem like that, normalizing all of those few forms in code was cheaper and more certain than continuing to refine the prompt.
“Should this be solved with code or with a prompt?” wasn’t a question you answer once and reuse next time. Each time, I had to tell again whether the problem was an open natural-language judgment (what language this is, how far to write) or a closed set of a few forms (which strings the signal value leaks as). Planting code again just hours after deleting it, I relearned how to make that distinction.