<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: saman gh</title>
    <description>The latest articles on DEV Community by saman gh (@qatrehai).</description>
    <link>https://dev.to/qatrehai</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4113689%2F50b7252d-3181-4208-a711-76efdaaecc55.jpg</url>
      <title>DEV Community: saman gh</title>
      <link>https://dev.to/qatrehai</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/qatrehai"/>
    <language>en</language>
    <item>
      <title>How to Actually Test a Chatbot (Not "The Answer Looked Fine")</title>
      <dc:creator>saman gh</dc:creator>
      <pubDate>Wed, 09 Sep 2026 15:43:43 +0000</pubDate>
      <link>https://dev.to/qatrehai/how-to-actually-test-a-chatbot-not-the-answer-looked-fine-1ihi</link>
      <guid>https://dev.to/qatrehai/how-to-actually-test-a-chatbot-not-the-answer-looked-fine-1ihi</guid>
      <description>&lt;p&gt;Most teams accept a chatbot the same way: they ask it a handful of questions, the answers look reasonable, and the service is signed off. That procedure proves almost nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short answer
&lt;/h2&gt;

&lt;p&gt;Evaluating a Persian-language assistant properly needs three things: a fixed set of questions replayed identically every time, an explicit assertion for each one about &lt;strong&gt;which source&lt;/strong&gt; the answer must come from, and response time measured separately from network time. Without all three, any change to the system can break something nobody notices.&lt;/p&gt;

&lt;h2&gt;
  
  
  "The answer looked fine" is not a criterion
&lt;/h2&gt;

&lt;p&gt;A retrieval or generation system almost always returns something. If it does not recognise the topic it returns a default; if it recognises the wrong topic it returns an answer that is correct in itself and irrelevant to the question.&lt;/p&gt;

&lt;p&gt;The second failure is the more deceptive one, because it reads well. In our own evaluation, the question &lt;em&gt;do you offer enterprise training?&lt;/em&gt; was answered with the project-timeline text — entirely accurate, entirely unrelated. Anyone reading only the answer sees nothing wrong.&lt;/p&gt;

&lt;p&gt;The fix is for each test case to assert not "the answer was good" but that the answer contains a specific marker: the path of the page it should point to, a distinctive phrase from the reference text, or the contact number. A fluent irrelevant answer then fails.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Not this — it passes for any non-empty string:&lt;/span&gt;
&lt;span class="nf"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// This — it fails a fluent answer that came from the wrong place:&lt;/span&gt;
&lt;span class="nf"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/chatbot-karaj&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Build a question set, not a few scattered checks
&lt;/h2&gt;

&lt;p&gt;What worked in practice was a list where every entry carries four fields: the question text, the language, a string that must appear in the answer, and a label saying what the case is testing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CASES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;q&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;آموزش سازمانی دارید؟&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="na"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fa&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/enterprise-ai-training-karaj&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;why&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;enterprise vs project timeline&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;q&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;چت‌بات چقدر طول می‌کشد؟&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fa&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/chatbot-karaj&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                &lt;span class="na"&gt;why&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ZWNJ spelling&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;                &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;q&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;چت بات چقدر طول می‌کشد؟&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fa&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/chatbot-karaj&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                &lt;span class="na"&gt;why&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;space spelling — same target&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;q&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Do you build chatbots?&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="na"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/chatbot-karaj&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                &lt;span class="na"&gt;why&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;english routing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;              &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;q&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;asdkjh qweqwe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;           &lt;span class="na"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fa&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;__default__&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                   &lt;span class="na"&gt;why&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gibberish hits the fallback&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;q&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;وام می‌دهید؟&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;            &lt;span class="na"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fa&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;__not_offered__&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;               &lt;span class="na"&gt;why&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;must never promise this&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;      &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ours grew to 47 cases and covers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ordinary questions for each service, in Persian, English and Arabic&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The same question in two spellings&lt;/strong&gt; — with a space and with a zero-width non-joiner — which must both reach the same destination. The mechanics are in &lt;a href="https://qatrehai.ir/blog/persian-chatbot-text-normalization-en" rel="noopener noreferrer"&gt;why your Persian chatbot answers the wrong question&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Every question that previously produced a wrong answer. A fixed defect without a test case comes back&lt;/li&gt;
&lt;li&gt;One nonsense input, to confirm the default reply actually works&lt;/li&gt;
&lt;li&gt;A negative assertion: something the organisation does &lt;strong&gt;not&lt;/strong&gt; offer must never be promised&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do not skip that last one. If you do not provide a service, the system must say so plainly. A customer who calls on the strength of an incorrect promise costs you both time and credibility.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three silent failures only testing reveals
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Rate limiting that returns empty responses.&lt;/strong&gt; Our middleware allows 20 requests per minute per address. Sending 42 questions back to back meant everything from the twenty-first onward came back empty — which looked exactly like a broken service, while the service was healthy and the test was wrong. A question set must be paced and must report the status code, not only the answer body.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 20/min per IP, so ~3.2s between cases. Without this the suite&lt;/span&gt;
&lt;span class="c1"&gt;// reports a working service as broken from case 21 onward.&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;CASES&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;ask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;report&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// status, not just the body&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3200&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. An interface that never opens.&lt;/strong&gt; It is possible for the interface to fail to open for every user while the API answers perfectly. No API test can see this; only loading the site in a real browser and clicking the button. Server logs answer it too: if no request from a user's browser has reached the conversation endpoint in days, the interface is not working.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Stale knowledge.&lt;/strong&gt; If the knowledge base is read only at service start-up, anything added today stays invisible until the next restart. The test case is simple: publish something new and immediately ask about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separate server time from network time
&lt;/h2&gt;

&lt;p&gt;Measured from your own machine, response time is the sum of network and server time. Knowing which to improve takes two measurements:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A request for a static file on the same domain, giving the network floor&lt;/li&gt;
&lt;li&gt;The same answering request issued &lt;strong&gt;from inside the server&lt;/strong&gt; against its local address&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In our measurements, total time from outside was around 400 milliseconds and the same request on the server itself took 10 to 14. Almost all of it was network, and further code optimisation would have changed nothing for the user. Without the second measurement, days could have gone into optimising the wrong thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep a log of unanswered questions
&lt;/h2&gt;

&lt;p&gt;Every question the system could not answer should be appended to a file. It is the only reliable list of your knowledge gaps, because a user who gets no answer rarely complains — they simply leave.&lt;/p&gt;

&lt;p&gt;Practical advice: store only the question text, a timestamp and the language; cap the file size; review it monthly. Questions that recur are next month's work list.&lt;/p&gt;

&lt;p&gt;One caveat learned the hard way: whatever filter you use to drop keyboard-mash from that log, write it with character codes rather than a regex that has to survive several layers of shell escaping. A &lt;code&gt;\s&lt;/code&gt; that arrives as a bare &lt;code&gt;s&lt;/code&gt; will silently drop every question containing the letter s, and the log will look healthy while losing most of its content.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How many test cases are enough?
&lt;/h3&gt;

&lt;p&gt;There is no magic number, but three groups must be covered: ordinary questions for each service, every defect previously found, and nonsense input. Ours began at 42 cases and grew with each new defect.&lt;/p&gt;

&lt;h3&gt;
  
  
  How often should it run?
&lt;/h3&gt;

&lt;p&gt;Before every release, and after every change to the knowledge base. A full run takes a few minutes, and those minutes move defect discovery out of production and into testing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can this be automated?
&lt;/h3&gt;

&lt;p&gt;Yes, and it should be. A script that reads the question list, issues the requests and checks the assertions. The one technical caveat is to send Persian text from inside the program rather than from a command line — some shells corrupt the encoding, and the result looks like a system fault. A three-character word arriving as five bytes of nonsense makes every keyword lookup miss, which reads exactly like a broken retriever.&lt;/p&gt;

&lt;h3&gt;
  
  
  How is this different from testing an ordinary website?
&lt;/h3&gt;

&lt;p&gt;On an ordinary website the correct output is unique. In an answering system several answers can be correct, so the assertion belongs on the &lt;strong&gt;source&lt;/strong&gt; of the answer rather than its exact wording — "the answer must reference the chatbot page", not "the answer must be this sentence".&lt;/p&gt;

&lt;h3&gt;
  
  
  Does the same method work for a model-based system?
&lt;/h3&gt;

&lt;p&gt;Yes, with one addition: include a case that checks the system says plainly that it does not know when no reference document exists. The two architectures and their different risks are compared in &lt;a href="https://qatrehai.ir/blog/persian-chatbot-llm-or-retrieval-en" rel="noopener noreferrer"&gt;language model or retrieval from your own knowledge&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  What if we have no internal technical team?
&lt;/h3&gt;

&lt;p&gt;Build the question set yourselves — that takes business knowledge, not technical knowledge — and have a contractor automate the run. Ownership of the question list should stay with you, because it is what defines quality.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;From building and re-building a Persian-language assistant at &lt;a href="https://qatrehai.ir/chatbot-karaj?lang=en" rel="noopener noreferrer"&gt;Qatreh&lt;/a&gt; in Karaj, Iran. The 47-case suite started as six cases and a wrong assumption.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>testing</category>
      <category>nlp</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Why Your Persian Chatbot Answers the Wrong Question</title>
      <dc:creator>saman gh</dc:creator>
      <pubDate>Mon, 07 Sep 2026 11:18:24 +0000</pubDate>
      <link>https://dev.to/qatrehai/why-your-persian-chatbot-answers-the-wrong-question-469d</link>
      <guid>https://dev.to/qatrehai/why-your-persian-chatbot-answers-the-wrong-question-469d</guid>
      <description>&lt;p&gt;When a Persian-language assistant answers half its questions with "I don't have information about that," the first assumption is always that its knowledge base is too small. Usually it isn't. The program is seeing one word as several unrelated strings.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short answer
&lt;/h2&gt;

&lt;p&gt;In Persian — and in Arabic script generally — a single word can be written several ways that look identical to a reader and are completely different to a computer. &lt;code&gt;چت‌بات&lt;/code&gt; written with a zero-width non-joiner and &lt;code&gt;چت بات&lt;/code&gt; written with an ordinary space share no byte sequence. Until text is folded into one comparable form before any comparison, a large share of real questions will miss, and nothing in the logs will indicate a fault.&lt;/p&gt;

&lt;h2&gt;
  
  
  One word, several spellings
&lt;/h2&gt;

&lt;p&gt;None of these variants is a spelling mistake. All of them appear in formal published Persian, and all are encoded distinctly in Unicode:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The zero-width non-joiner&lt;/strong&gt; (U+200C) is a real character with no width. It separates parts of a compound without a space, so &lt;code&gt;می‌رود&lt;/code&gt;, &lt;code&gt;می رود&lt;/code&gt; and &lt;code&gt;میرود&lt;/code&gt; are three distinct strings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persian yeh (U+06CC) versus Arabic yeh (U+064A)&lt;/strong&gt; render nearly identically in most fonts, and different keyboard layouts produce different ones.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persian keheh (U+06A9) versus Arabic kaf (U+0643)&lt;/strong&gt; — the same situation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Heh versus teh marbuta&lt;/strong&gt; — a writer using an Arabic layout types &lt;code&gt;شرکة&lt;/code&gt; rather than &lt;code&gt;شرکه&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Three digit sets.&lt;/strong&gt; Persian digits (U+06F0–U+06F9), Arabic-Indic digits (U+0660–U+0669) and ASCII digits are three separate encodings of the same numbers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Diacritics and tatweel.&lt;/strong&gt; Optional vowel marks and the decorative elongation character survive in pasted text and break matching silently.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a term as ordinary as "chatbot", that yields at least four common written forms, all correct. A system that recognises one of them fails three users in four.&lt;/p&gt;

&lt;p&gt;Here is the problem in three lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;چت‌بات&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// with U+200C&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;چت بات&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// with a space&lt;/span&gt;

&lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;                  &lt;span class="c1"&gt;// false&lt;/span&gt;
&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;    &lt;span class="c1"&gt;// true  &amp;lt;-- the trap&lt;/span&gt;

&lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;codePointAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="c1"&gt;// [ '686', '62a', '200c', '628', '627', '62a' ]&lt;/span&gt;
&lt;span class="c1"&gt;//                  ^^^^ the character you cannot see&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why nothing appears in the logs
&lt;/h2&gt;

&lt;p&gt;This is the part that makes the problem persist. When matching fails the program does not crash — it falls through to a default reply. The web server records a 200, uptime monitoring stays green, and no dashboard turns red.&lt;/p&gt;

&lt;p&gt;From outside, the only symptom is that a user asks once, receives a generic answer, and closes the window. That pattern was present in our own system and stayed invisible until a fixed set of real questions was run against it. The method is in &lt;a href="https://qatrehai.ir/blog/persian-chatbot-testing-method-en" rel="noopener noreferrer"&gt;how to actually test a Persian chatbot&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second trap: substring matching
&lt;/h2&gt;

&lt;p&gt;Suppose the system detects intent by searching for keywords inside the incoming question. It is the simplest approach and it carries a serious flaw, because Persian attaches prefixes and suffixes freely.&lt;/p&gt;

&lt;p&gt;A real example from our own system: the keyword &lt;code&gt;زمان&lt;/code&gt; (time) was registered to detect questions about project schedules. A user asked &lt;em&gt;do you offer enterprise training?&lt;/em&gt; — which in Persian contains the word &lt;code&gt;سازمانی&lt;/code&gt; (organisational). The four-letter keyword was found as a substring inside it, and the system answered with a project timeline.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;آموزش سازمانی دارید؟&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;زمان&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// true — and wrong&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is to compare at whole-token level rather than by substring, and to allow a prefix match only for terms long enough that the shared opening cannot be coincidental — so &lt;code&gt;آموزش&lt;/code&gt; still matches &lt;code&gt;آموزشی&lt;/code&gt;, while &lt;code&gt;زمان&lt;/code&gt; no longer matches &lt;code&gt;سازمانی&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The order the folding has to happen in
&lt;/h2&gt;

&lt;p&gt;Before any comparison, the incoming text and the reference text must pass through the same pipeline:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Strip diacritics and tatweel&lt;/li&gt;
&lt;li&gt;Convert the zero-width non-joiner and directional marks to a plain space&lt;/li&gt;
&lt;li&gt;Convert Persian and Arabic-Indic digits to ASCII&lt;/li&gt;
&lt;li&gt;Unify letters: yeh, alef maqsura and yeh hamza to Persian yeh; Arabic kaf to keheh; teh marbuta to heh; the alef forms to plain alef&lt;/li&gt;
&lt;li&gt;Lowercase any Latin characters&lt;/li&gt;
&lt;li&gt;Replace every non-letter, non-digit character with a space and collapse runs of whitespace&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In full — every character written as an escape, so it survives copy and paste:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ARABIC_FORMS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ي&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ی&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Arabic yeh    -&amp;gt; Persian yeh&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ى&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ی&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// alef maqsura  -&amp;gt; Persian yeh&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ئ&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ی&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// yeh with hamza&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ك&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ک&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Arabic kaf    -&amp;gt; Persian keheh&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ة&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ه&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// teh marbuta   -&amp;gt; heh&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ۀ&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ه&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// heh with yeh above&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;أ&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ا&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// alef with hamza above&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;إ&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ا&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// alef with hamza below&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;آ&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ا&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// alef with madda&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ؤ&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;و&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// waw with hamza -&amp;gt; waw&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;
    &lt;span class="c1"&gt;// 1. harakat, dagger alef, tatweel — decorative, never semantic&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;ً-ْٰـ&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;// 2. ZWNJ and the bidi control marks become a SPACE, not nothing&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;​-‏‪-‮⁦-⁩﻿&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;// 3. Persian digits, then Arabic-Indic digits&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;۰-۹&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mh"&gt;0x06f0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;٠-٩&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mh"&gt;0x0660&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="c1"&gt;// 4. one letter per sound&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;يىئكةۀأإآؤ&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;ARABIC_FORMS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;// 5 and 6. fold case, then reduce punctuation to whitespace&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[^\p&lt;/span&gt;&lt;span class="sr"&gt;{L}&lt;/span&gt;&lt;span class="se"&gt;\p&lt;/span&gt;&lt;span class="sr"&gt;{N}&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+/gu&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It should pass all three of these:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;چت‌بات&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;چت بات&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// true — the ZWNJ case&lt;/span&gt;
&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;شرکة&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;شرکه&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;// true — teh marbuta&lt;/span&gt;
&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;۱۴۰۳&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1403&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;// true — Persian digits&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The subtlety is in step two. The zero-width non-joiner must become a &lt;strong&gt;space&lt;/strong&gt;, not be deleted. Delete it and &lt;code&gt;چت‌بات&lt;/code&gt; becomes &lt;code&gt;چتبات&lt;/code&gt;, which still does not match &lt;code&gt;چت بات&lt;/code&gt;. Convert it to a space and both spellings arrive at the same two tokens and find each other.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;چت‌بات&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/‌/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="c1"&gt;// "چتبات"  — still no match&lt;/span&gt;
&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;چت‌بات&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/‌/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// "چت بات" — matches&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One more thing that costs nothing and catches a whole class of bugs: run the &lt;em&gt;reference&lt;/em&gt; strings through &lt;code&gt;normalize&lt;/code&gt; too, at startup. A keyword list typed by a developer on one keyboard layout and a question typed by a user on another will otherwise never meet, no matter how good the folding is on the input side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters more for a local deployment
&lt;/h2&gt;

&lt;p&gt;Customers of a shop, a training centre or a factory write from a range of devices and keyboard layouts. Some produce standard Persian characters; others produce the Arabic forms. Both families are therefore present in genuine inbound messages.&lt;/p&gt;

&lt;p&gt;A system validated only against clean, uniform text performs flawlessly in a demonstration and stumbles on contact with real users. We run this evaluation against real message traffic rather than synthetic text, because the gap between those two is the gap between a successful demo and a service that holds up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What exactly is the zero-width non-joiner and why does it break things?
&lt;/h3&gt;

&lt;p&gt;It is Unicode character U+200C. It has no visual width but is stored in the string. &lt;code&gt;می‌رود&lt;/code&gt; and &lt;code&gt;می رود&lt;/code&gt; are therefore identical to the eye and different to any string comparison.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does this only affect chatbots?
&lt;/h3&gt;

&lt;p&gt;No. Anywhere Persian text is compared is affected: product search, customer-name lookup, address matching, deduplication. In a database, two spellings of one company name remain two separate records.&lt;/p&gt;

&lt;h3&gt;
  
  
  What share of questions fail because of this?
&lt;/h3&gt;

&lt;p&gt;There is no fixed figure; it depends on the vocabulary involved. In our own evaluation, before folding was introduced, three out of ten perfectly ordinary Persian questions fell through to the default reply. Afterwards, all ten reached the correct topic.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do large language models avoid the problem?
&lt;/h3&gt;

&lt;p&gt;They are more tolerant of spelling variation, but they do not remove it. Wherever string matching happens before or after the model — document retrieval, filtering, routing to a department — the same trap returns. I compare the two architectures in &lt;a href="https://qatrehai.ir/blog/persian-chatbot-llm-or-retrieval-en" rel="noopener noreferrer"&gt;language model or retrieval from your own knowledge&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  How expensive is the folding step?
&lt;/h3&gt;

&lt;p&gt;Negligible. It is six replacements over a short string. In our measurements the entire server-side processing time, including retrieving the answer, was 10 to 14 milliseconds. The cost of not doing it is far higher.&lt;/p&gt;

&lt;h3&gt;
  
  
  Isn't this what &lt;code&gt;String.prototype.normalize()&lt;/code&gt; is for?
&lt;/h3&gt;

&lt;p&gt;No — and this is a common misread. Unicode NFC and NFKC compose and decompose characters, but Persian yeh and Arabic yeh are separate letters with separate meanings, not two encodings of one character. Unicode will not merge them for you, and it should not. Script folding is an application-level decision, and only you know how aggressive it should be.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written while building a Persian-language assistant at &lt;a href="https://qatrehai.ir/chatbot-karaj?lang=en" rel="noopener noreferrer"&gt;Qatreh&lt;/a&gt; in Karaj, Iran. If you have hit a variant this pipeline misses, I would like to hear about it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>nlp</category>
      <category>webdev</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
