@ykurilov/agent-chat-reply-check 0.0.0-stage → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,111 @@
1
+ # @ykurilov/agent-chat-reply-check
2
+
3
+ ## 0.56.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [24c313e]
8
+ - @ykurilov/agent-chat-core@0.56.0
9
+
10
+ ## 0.55.0
11
+
12
+ ### Patch Changes
13
+
14
+ - Updated dependencies [51f7b38]
15
+ - Updated dependencies [cdc7dd7]
16
+ - @ykurilov/agent-chat-core@0.55.0
17
+
18
+ ## 0.54.0
19
+
20
+ ### Patch Changes
21
+
22
+ - Updated dependencies [fa90219]
23
+ - @ykurilov/agent-chat-core@0.54.0
24
+
25
+ ## 0.53.0
26
+
27
+ ### Patch Changes
28
+
29
+ - Updated dependencies [b6a7786]
30
+ - @ykurilov/agent-chat-core@0.53.0
31
+
32
+ ## 0.52.0
33
+
34
+ ### Patch Changes
35
+
36
+ - @ykurilov/agent-chat-core@0.52.0
37
+
38
+ ## 0.51.0
39
+
40
+ ### Minor Changes
41
+
42
+ - 41b1726: Проверка ответа: **тексты приложения.** В `replyChecks` появилось `knownTexts: ({ ownerId, chatId }) => string[] | Promise<string[]>` — первое сообщение, прощание, названия роликов: слова и ссылки оттуда больше не вырезаются и не считаются чужими. Если `replyChecks` передан в `flow.withChat(…)`, сценарий сам добавляет свои тексты (общая часть, ноды, первые сообщения, прощание, рамка дня, попутные задачи). **Проверка текста вне хода:** `service.checkReply({ ownerId, chatId, text })` → `{ text, notes }` — те же проверки и тот же набор «виденного», что у хода. **Исправление:** служебные сообщения приложения (`app`, «напиши первым») и реплики сотрудника больше не делают ссылку законной — «виденное от человека» берётся только из сообщений человека (итоги фоновых задач остаются виденными). Если агент должен повторять ссылки из своих служебных сообщений, назовите их в `knownTexts`. Подробно — `docs/reply-check.md`.
43
+
44
+ ### Patch Changes
45
+
46
+ - Updated dependencies [41b1726]
47
+ - @ykurilov/agent-chat-core@0.51.0
48
+
49
+ ## 0.50.0
50
+
51
+ ### Patch Changes
52
+
53
+ - @ykurilov/agent-chat-core@0.50.0
54
+
55
+ ## 0.49.0
56
+
57
+ ### Minor Changes
58
+
59
+ - 084cef7: **Ломает приложения (типы и своё хранилище сценариев):** у типа поля сценария `FlowFieldType` появилось значение `"boolean"`, у источника
60
+ расхода `UsageSource` — `"first_message"`, у `SteerResult.status` — `"held"`. Исчерпывающий `switch` по ним перестанет
61
+ собираться. Своё хранилище сценариев (`FlowStore`) должно реализовать новый `renewLease()` и третий аргумент `setDue`.
62
+
63
+ Сценарии для приложений: программа по дням (ночь, касания, рамки дня, «срок вышел»), своя служба чата и доставка с
64
+ исходами, пауза сотрудника, конец разговора с итогом, карточка и пояс человека в базе приложения, попутные задачи.
65
+ Проверка готового ответа: «чужие слова» и «выдуманные ссылки». Подробно — `docs/flows.md`, `docs/reply-check.md`,
66
+ раздел 0.49.0 в `CHANGES.md`.
67
+
68
+ - **flows**: формат сценария версии 2 (`schedule`, `nudges`, таймер `state_days`, поле `boolean`, `sideTasks`,
69
+ `afterEnd`; версия 1 читается сама); `attach({ portFor, deliver })`, `admit`, `pause`/`resume`/`noteStaffReply`,
70
+ `reportDelivery`, `onFinish`, `clientCard`, `people`, `settled`, `now`; причина перехода `reason`; перенос чата
71
+ через `loadFlow` (`stateId`, `startedAt`, `enteredAt`); заголовки судьи `judge.openrouter.headers`; встроенная задача
72
+ «узнать город»; `checkFlow(flow, catalog, { checks })` и `FlowAppChecks` из `/format`; в редакторе панели дней,
73
+ касаний, рамок и попутных задач, `catalog.fields`, кнопка «К началу следующего дня» (`advanceToNextDay`). Новые
74
+ таблицы `flow_people`, `flow_side_tasks` и колонки `flow_chats` создаются сами.
75
+ - **core**: `replyChecks` (список или `{ checks, web: "replace" | "hold" }`), `now`, `markUndelivered`, ответы
76
+ `beforeTurn` `{ hold: true }` и `{ reply: { text } }`, `deliverNotices({ timeZone, awaitAnswer, usageSource })`,
77
+ источник расхода `first_message`.
78
+ - **flows, надёжность**: обход таймеров до 5 ходов одновременно; копия продлевает аренду чата перед ходом и во время
79
+ него. В `FlowStore` новые `renewLease()` и `setDue(…, { unlessDelivery })`. Недоставленное касание (`failed`,
80
+ `window_closed`) не засчитывается — тот же шаг через свой срок от отказа; в `FlowRun` новое поле `touch`. «Окно
81
+ закрыто» снимает любое сообщение человека; «не завершил» считает тишину и от конца паузы сотрудника; пока ждёт
82
+ повтор доставки, срок не затирается и новых ходов нет.
83
+ - **server**: `createChatServer` передаёт службе `replyChecks` и `now`.
84
+ - **messengers**: `onAnswerDelivery` — исход доставки каждого ответа; закрытое окно ВК (950) —
85
+ `MessengerWindowClosedError` и исход `window_closed`; с `replyChecks` уходит только проверенный текст.
86
+ - **web-tools**: `createPersonLinkTool` — инструмент `person_link_view`, агент смотрит ссылку, которую прислал человек
87
+ (адрес из почты ссылкой не считается).
88
+ - **reply-check** (новый пакет): `foreignWords` и `unknownLinks`; ответ из одной выдуманной ссылки заменяется запасной
89
+ фразой `labels.linkOnlyReply` (`russianReplyCheckLabels`, `englishReplyCheckLabels`, `ReplyCheckLabels`).
90
+ - **react**: договор скина версии 24 — необязательное `undelivered` у ответа агента и надпись `answerUndelivered`
91
+ («Не доставлено»). Скин версии 23 работает без правок.
92
+
93
+ ### Как обновить приложение
94
+
95
+ 1. Поднимите все пакеты `@ykurilov/agent-chat-*` до этой версии вместе.
96
+ 2. Найдите в коде приложения исчерпывающие `switch` и словари по этим типам и добавьте ветки:
97
+ - по `FlowFieldType` (поля карточки сценария) — `"boolean"`, «да/нет», значения `true`/`false`;
98
+ - по `UsageSource` (отчёты расходов) — `"first_message"`, «агент написал первым»;
99
+ - по `SteerResult.status` — `"held"`, «сообщение записано, хода нет».
100
+ 3. Своё хранилище сценариев (свой `FlowStore`) — добавьте `renewLease({ chatId, owner, now, leaseMs }): Promise<boolean>`
101
+ (продлить аренду до `now + leaseMs`; `false` — чат уже не ваш) и третий аргумент `setDue(chatId, due, { unlessDelivery })`
102
+ (при `unlessDelivery` не писать срок, пока чат ждёт повтора доставки). Готовые хранилища SDK это уже умеют.
103
+ 4. Сценарии сохраняются версией 2, а 0.48 её не откроет. Перед откатом на 0.48 держите копии сценариев версии 1.
104
+ 5. Новое по желанию: каналы SDK и сценарий — `createMessengers({ onAnswerDelivery: (report) => flow.reportDelivery(report) })`;
105
+ проверка ответа — `pnpm add @ykurilov/agent-chat-reply-check` и `replyChecks` в `createChatServer`.
106
+ 6. Проверка: `pnpm typecheck` проходит, чат отвечает, сценарий версии 1 открывается в редакторе.
107
+
108
+ ### Patch Changes
109
+
110
+ - Updated dependencies [084cef7]
111
+ - @ykurilov/agent-chat-core@0.49.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yury Kurilov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,50 @@
1
- # Temporary Holding Version
1
+ # @ykurilov/agent-chat-reply-check
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Проверки готового ответа агента для `replyChecks` службы чата. Работают в любом приложении, сценарий не нужен.
4
+
5
+ ```ts
6
+ import { foreignWords, unknownLinks } from "@ykurilov/agent-chat-reply-check";
7
+
8
+ createChatService({
9
+ replyChecks: [
10
+ foreignWords({ script: "cyrillic", knownNames: ["Excel", "Telegram"] }),
11
+ unknownLinks({ appDomains: ["ykapp.ru"] }),
12
+ ],
13
+ });
14
+ ```
15
+
16
+ ## `foreignWords({ script, language?, knownNames?, maxLengthRatio? })`
17
+
18
+ Ищет чужие слова: иероглифы и другие письменности (всегда), слово из соседней письменности вне названий
19
+ (латиница в русском ответе, кириллица в английском), латинскую букву внутри русского слова.
20
+ Законны слова из `seenTexts` (инструкция, сценарий, сообщения человека, `knownTexts` службы), `knownNames`, сокращения из заглавных и названия
21
+ с большой буквы посреди фразы.
22
+
23
+ - `script` — письменность ответа: `"cyrillic"` или `"latin"`. Язык «русский» не зашит.
24
+ - `language` — как назвать язык в инструкции переписки («по-русски», «по-немецки»). По умолчанию: кириллица — «по-русски»,
25
+ латиница — «по-английски».
26
+ - `maxLengthRatio` — во сколько раз переписанный ответ может быть длиннее исходного (1.5). Короче 0.6 исходного не принимается.
27
+
28
+ Одна переписка моделью. Не вышло — уходит вариант с меньшим числом чужих слов (при равенстве — исходный).
29
+
30
+ ## `unknownLinks({ appDomains?, labels? })`
31
+
32
+ Находит адреса (`http(s)://`, `www.`, домен с путём), которых нет в `seenTexts`. Законны и голые адреса из них, и короткое
33
+ имя на домене приложений, если человек назвал это имя (`sveta-nails` → `sveta-nails.ykapp.ru`). Домен приложений
34
+ задаёт `appDomains` или шаблон в знакомых текстах («адрес.ykapp.ru»). Одна переписка, остаток вырезается кодом.
35
+
36
+ Ответ был целиком выдуманной ссылкой — уходит запасная фраза `labels.linkOnlyReply`, в `note` — «запасная фраза».
37
+ Слова: `russianReplyCheckLabels` (по умолчанию), `englishReplyCheckLabels` или свои (`Partial<ReplyCheckLabels>`).
38
+
39
+ ## Тексты приложения и проверка вне хода
40
+
41
+ Первое сообщение, прощание, тексты нод, названия роликов в инструкции не стоят. Назовите их в `replyChecks.knownTexts`
42
+ службы: слова и ссылки оттуда законны для обеих проверок (со сценарием — `flow.withChat({ replyChecks })` добавит тексты
43
+ сценария сам). Реплики сотрудника и служебные сообщения приложения «виденным» не считаются. Свой текст вне хода
44
+ проверяется тем же набором: `service.checkReply({ ownerId, chatId, text })` → `{ text, notes }`.
45
+ Подробно — [docs/reply-check.md](https://github.com/ykurilov/agent-chat-sdk/blob/main/docs/reply-check.md).
46
+
47
+ ## Кэш и журнал
48
+
49
+ Инструкции переписки постоянные: меняется только текст запроса после них. Итог каждой проверки возвращается в `note`
50
+ (`чужие слова: итог=… было=… осталось=…`); расход переписки ядро пишет как `answer_correction`.
package/dist/index.js ADDED
@@ -0,0 +1,167 @@
1
+ // src/rewrite-notes.ts
2
+ var FOREIGN_WORDS_NOTE = "\u0427\u0443\u0436\u0438\u0435 \u0441\u043B\u043E\u0432\u0430:";
3
+ var UNKNOWN_LINKS_NOTE = "\u0421\u0441\u044B\u043B\u043A\u0438 \u043D\u0435 \u0438\u0437 \u0438\u043D\u0441\u0442\u0440\u0443\u043A\u0446\u0438\u0438:";
4
+
5
+ // src/accepted-rewrite.ts
6
+ var MIN_LENGTH_RATIO = 0.6;
7
+ var DEFAULT_MAX_LENGTH_RATIO = 1.5;
8
+ function acceptedRewrite(original, rewritten, options = {}) {
9
+ const text = rewritten.trim();
10
+ if (!text || text.includes(FOREIGN_WORDS_NOTE) || text.includes(UNKNOWN_LINKS_NOTE)) return "";
11
+ if (options.sameLength) {
12
+ const ratio = text.length / Math.max(original.trim().length, 1);
13
+ if (ratio < MIN_LENGTH_RATIO || ratio > options.sameLength.maxRatio) return "";
14
+ }
15
+ return text;
16
+ }
17
+
18
+ // src/found-foreign-words.ts
19
+ var UNTOUCHABLE = /(?:https?:\/\/|www\.)\S+|[\w.+-]+@[\w-]+(?:\.[\w-]+)+|@[\w.]+|[\p{L}\d-]+(?:\.[\p{L}\d-]+)*\.(?:[a-z]{2,6}|рф)(?!\p{L})(?:\/\S*)?/gu;
20
+ var MIXED_WORD = /\p{L}*(?:\p{Script=Cyrillic}\p{L}*\p{Script=Latin}|\p{Script=Latin}\p{L}*\p{Script=Cyrillic})\p{L}*/gu;
21
+ var OTHER_SCRIPT_WORD = /(?:(?![\p{Script=Latin}\p{Script=Cyrillic}])\p{L}[\p{M}]*)+/gu;
22
+ var LATIN_WORD = /\p{Script=Latin}+(?:['’-]\p{Script=Latin}+)*/gu;
23
+ var CYRILLIC_WORD = /\p{Script=Cyrillic}+(?:['’-]\p{Script=Cyrillic}+)*/gu;
24
+ var COMMON_SERVICES = /* @__PURE__ */ new Set(["zoom", "telegram", "instagram", "youtube", "whatsapp", "google", "skype", "excel", "notion", "viber", "facebook", "tiktok"]);
25
+ var ROMAN_NUMERAL = /^[IVXLCDM]+$/;
26
+ function neighborWordsOf(text, script) {
27
+ const words = text.match(script === "cyrillic" ? LATIN_WORD : CYRILLIC_WORD) ?? [];
28
+ return words.filter((word) => word.length > 1 && !(script === "cyrillic" && ROMAN_NUMERAL.test(word)));
29
+ }
30
+ function isName(word, before, script) {
31
+ if (script === "cyrillic" && COMMON_SERVICES.has(word.toLowerCase())) return true;
32
+ if (/^\p{Lu}+$/u.test(word) || /\p{Lu}/u.test(word.slice(1))) return true;
33
+ return /^\p{Lu}/u.test(word) && !/(?:^|[.!?…:\n])[\s«"„“(—-]*$/u.test(before);
34
+ }
35
+ function foreignWordsIn(text, known, script, knownNames = []) {
36
+ const knownWords = new Set(
37
+ [...known, ...knownNames].flatMap((source) => neighborWordsOf(source.replace(UNTOUCHABLE, " "), script).map((word) => word.toLowerCase()))
38
+ );
39
+ const found = [];
40
+ const clean = text.replace(UNTOUCHABLE, (link) => " ".repeat(link.length)).replace(MIXED_WORD, (word, at) => {
41
+ found.push({ at, word });
42
+ return " ".repeat(word.length);
43
+ });
44
+ for (const match of clean.matchAll(OTHER_SCRIPT_WORD)) found.push({ at: match.index, word: match[0] });
45
+ for (const match of clean.matchAll(script === "cyrillic" ? LATIN_WORD : CYRILLIC_WORD)) {
46
+ const [word] = neighborWordsOf(match[0], script);
47
+ if (word && !knownWords.has(word.toLowerCase()) && !isName(word, clean.slice(0, match.index), script)) found.push({ at: match.index, word });
48
+ }
49
+ return found.sort((a, b) => a.at - b.at).map((item) => item.word);
50
+ }
51
+
52
+ // src/foreign-words.ts
53
+ var DEFAULT_LANGUAGE = { cyrillic: "\u043F\u043E-\u0440\u0443\u0441\u0441\u043A\u0438", latin: "\u043F\u043E-\u0430\u043D\u0433\u043B\u0438\u0439\u0441\u043A\u0438" };
54
+ function foreignWordsInstruction(language) {
55
+ return `\u0422\u044B \u0440\u0435\u0434\u0430\u043A\u0442\u043E\u0440. \u041F\u0435\u0440\u0435\u043F\u0438\u0448\u0438 \u044D\u0442\u043E\u0442 \u043E\u0442\u0432\u0435\u0442 ${language}, \u0441\u043C\u044B\u0441\u043B \u043D\u0435 \u043C\u0435\u043D\u044F\u0439. \u0418\u043D\u043E\u0441\u0442\u0440\u0430\u043D\u043D\u044B\u0435 \u0441\u043B\u043E\u0432\u0430 \u0438 \u0438\u0435\u0440\u043E\u0433\u043B\u0438\u0444\u044B \u0437\u0430\u043C\u0435\u043D\u0438 \u0441\u043B\u043E\u0432\u0430\u043C\u0438 \u044F\u0437\u044B\u043A\u0430 \u043E\u0442\u0432\u0435\u0442\u0430. \u0421\u0441\u044B\u043B\u043A\u0438, \u043F\u043E\u0447\u0442\u0443, @\u0438\u043C\u0435\u043D\u0430 \u0438 \u043D\u0430\u0437\u0432\u0430\u043D\u0438\u044F \u043E\u0441\u0442\u0430\u0432\u044C \u043A\u0430\u043A \u0435\u0441\u0442\u044C. \u041D\u0438\u0447\u0435\u0433\u043E \u043D\u0435 \u0434\u043E\u0431\u0430\u0432\u043B\u044F\u0439 \u0438 \u043D\u0435 \u0443\u0431\u0438\u0440\u0430\u0439. \u0412 \u043E\u0442\u0432\u0435\u0442\u0435 \u0442\u043E\u043B\u044C\u043A\u043E \u043F\u0435\u0440\u0435\u043F\u0438\u0441\u0430\u043D\u043D\u044B\u0439 \u0442\u0435\u043A\u0441\u0442.`;
56
+ }
57
+ var FOREIGN_WORDS_INSTRUCTION_CYRILLIC = foreignWordsInstruction(DEFAULT_LANGUAGE.cyrillic);
58
+ function noteOf(outcome, foreign, left) {
59
+ const rest = left.length > 0 ? ` \u043E\u0441\u0442\u0430\u043B\u043E\u0441\u044C=${left.join(",")}` : "";
60
+ return `\u0447\u0443\u0436\u0438\u0435 \u0441\u043B\u043E\u0432\u0430: \u0438\u0442\u043E\u0433=${outcome} \u0431\u044B\u043B\u043E=${foreign.join(",")}${rest}`;
61
+ }
62
+ function foreignWords(options) {
63
+ const { script, knownNames = [], maxLengthRatio = DEFAULT_MAX_LENGTH_RATIO } = options;
64
+ const system = foreignWordsInstruction(options.language ?? DEFAULT_LANGUAGE[script]);
65
+ return async ({ text, seenTexts, rewrite }) => {
66
+ const foreign = foreignWordsIn(text, seenTexts, script, knownNames);
67
+ if (foreign.length === 0) return;
68
+ let rewritten = "";
69
+ try {
70
+ rewritten = acceptedRewrite(text, await rewrite(system, `${text}
71
+
72
+ ${FOREIGN_WORDS_NOTE} ${foreign.join(", ")}`), {
73
+ sameLength: { maxRatio: maxLengthRatio }
74
+ });
75
+ } catch {
76
+ }
77
+ if (!rewritten) return { text, note: noteOf("\u043D\u0435 \u043F\u0435\u0440\u0435\u043F\u0438\u0441\u0430\u043D", foreign, foreign) };
78
+ const left = foreignWordsIn(rewritten, seenTexts, script, knownNames);
79
+ if (left.length === 0) return { text: rewritten, note: noteOf("\u043F\u0435\u0440\u0435\u043F\u0438\u0441\u0430\u043D", foreign, left) };
80
+ return left.length < foreign.length ? { text: rewritten, note: noteOf("\u0443\u0448\u0451\u043B \u043B\u0443\u0447\u0448\u0438\u0439 \u0438\u0437 \u0434\u0432\u0443\u0445", foreign, left) } : { text, note: noteOf("\u0443\u0448\u0451\u043B \u043B\u0443\u0447\u0448\u0438\u0439 \u0438\u0437 \u0434\u0432\u0443\u0445", foreign, foreign) };
81
+ };
82
+ }
83
+
84
+ // src/app-domains.ts
85
+ var APP_DOMAIN_TEMPLATE = /(?<![\p{L}\p{N}.-])[а-яё]+\.((?:[a-z0-9-]+\.)+[a-z]{2,})/giu;
86
+ function appDomainsOf(texts, configured = []) {
87
+ const domains = new Set(configured.map((domain) => domain.toLowerCase()));
88
+ for (const text of texts) for (const found of text.matchAll(APP_DOMAIN_TEMPLATE)) domains.add(found[1].toLowerCase());
89
+ return [...domains];
90
+ }
91
+ function isAppNameHost(host, appDomains, texts) {
92
+ const domain = appDomains.find((candidate) => host.endsWith(`.${candidate}`));
93
+ if (!domain) return false;
94
+ const name = host.slice(0, -domain.length - 1);
95
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) return false;
96
+ const word = new RegExp(`(?<![\\p{L}\\p{N}./@-])${name}(?![\\p{L}\\p{N}-]|\\.[\\p{L}\\p{N}])`, "iu");
97
+ return texts.some((text) => word.test(text));
98
+ }
99
+
100
+ // src/links-in-text.ts
101
+ var LINK = /(?:https?:\/\/|(?<![\p{L}\d@.\/-])www\.)[^\s<>«»"'()]+|(?<![\p{L}\d@.\/-])(?:[\p{L}\d-]+\.)+(?:[a-z]{2,6}|рф)\/[^\s<>«»"'()]*/giu;
102
+ var TRAILING = /[.,!?;:…]+$/u;
103
+ function linksIn(text) {
104
+ return (text.match(LINK) ?? []).map((link) => link.replace(TRAILING, "")).filter(Boolean);
105
+ }
106
+ function sameAddress(link) {
107
+ const bare = link.replace(/^https?:\/\//i, "").replace(/^www\./i, "").replace(/\/+$/, "");
108
+ const slash = bare.indexOf("/");
109
+ return slash < 0 ? bare.toLowerCase() : bare.slice(0, slash).toLowerCase() + bare.slice(slash);
110
+ }
111
+ var BARE_HOST = /(?<![\p{L}\p{N}@./-])(?:[\p{L}\p{N}-]+\.)+[\p{L}]{2,}(?![\p{L}\p{N}/-])/giu;
112
+ function unknownLinksIn(text, known, appDomains = []) {
113
+ const allowed = new Set([...known.flatMap(linksIn), ...known.flatMap((one) => one.match(BARE_HOST) ?? [])].map(sameAddress));
114
+ const domains = appDomainsOf(known, appDomains);
115
+ return linksIn(text).filter((link) => {
116
+ const address = sameAddress(link);
117
+ return !allowed.has(address) && !(!address.includes("/") && isAppNameHost(address, domains, known));
118
+ });
119
+ }
120
+ function withoutLinks(text, links) {
121
+ let result = text;
122
+ for (const link of links) result = result.split(link).join("\0");
123
+ return result.replace(/\s*:?\s*\u0000/g, "").replace(/[ \t]{2,}/g, " ").replace(/ +([.,!?;:…])/g, "$1").trim();
124
+ }
125
+
126
+ // src/labels.ts
127
+ var russianReplyCheckLabels = {
128
+ linkOnlyReply: "\u0421\u0441\u044B\u043B\u043A\u0443 \u0437\u0434\u0435\u0441\u044C \u0434\u0430\u0442\u044C \u043D\u0435 \u043C\u043E\u0433\u0443. \u0420\u0430\u0441\u0441\u043A\u0430\u0436\u0438\u0442\u0435, \u0447\u0442\u043E \u0432\u044B \u0438\u0449\u0435\u0442\u0435, \u0438 \u044F \u043F\u043E\u0434\u0441\u043A\u0430\u0436\u0443."
129
+ };
130
+ var englishReplyCheckLabels = {
131
+ linkOnlyReply: "I can't share a link here. Tell me what you're looking for and I'll help."
132
+ };
133
+
134
+ // src/unknown-links.ts
135
+ var LINK_REWRITE_INSTRUCTION = "\u0422\u044B \u0440\u0435\u0434\u0430\u043A\u0442\u043E\u0440. \u0421\u0441\u044B\u043B\u043A\u0438, \u043F\u0435\u0440\u0435\u0447\u0438\u0441\u043B\u0435\u043D\u043D\u044B\u0435 \u043D\u0438\u0436\u0435, \u2014 \u043D\u0435 \u0438\u0437 \u0438\u043D\u0441\u0442\u0440\u0443\u043A\u0446\u0438\u0438: \u0443\u0431\u0435\u0440\u0438 \u0438\u0445 \u0438\u0437 \u043E\u0442\u0432\u0435\u0442\u0430. \u0415\u0441\u043B\u0438 \u0431\u0435\u0437 \u0441\u0441\u044B\u043B\u043A\u0438 \u0444\u0440\u0430\u0437\u0430 \u0442\u0435\u0440\u044F\u0435\u0442 \u0441\u043C\u044B\u0441\u043B, \u043F\u0435\u0440\u0435\u0441\u0442\u0440\u043E\u0439 \u0435\u0451, \u043D\u0438\u0447\u0435\u0433\u043E \u043D\u0435 \u043E\u0431\u0435\u0449\u0430\u044F. \u0414\u0440\u0443\u0433\u0438\u0445 \u0441\u0441\u044B\u043B\u043E\u043A \u043D\u0435 \u0434\u043E\u0431\u0430\u0432\u043B\u044F\u0439, \u043E\u0441\u0442\u0430\u043B\u044C\u043D\u043E\u0435 \u043D\u0435 \u043C\u0435\u043D\u044F\u0439. \u0412 \u043E\u0442\u0432\u0435\u0442\u0435 \u0442\u043E\u043B\u044C\u043A\u043E \u043F\u0435\u0440\u0435\u043F\u0438\u0441\u0430\u043D\u043D\u044B\u0439 \u0442\u0435\u043A\u0441\u0442.";
136
+ function noteOf2(outcome, links, left) {
137
+ const rest = left.length > 0 ? ` \u0432\u044B\u0440\u0435\u0437\u0430\u043D\u043E=${left.join(",")}` : "";
138
+ return `\u0441\u0441\u044B\u043B\u043A\u0438 \u043D\u0435 \u0438\u0437 \u0438\u043D\u0441\u0442\u0440\u0443\u043A\u0446\u0438\u0438: \u0438\u0442\u043E\u0433=${outcome} \u0431\u044B\u043B\u043E=${links.join(",")}${rest}`;
139
+ }
140
+ function unknownLinks(options = {}) {
141
+ const { appDomains = [] } = options;
142
+ const labels = { ...russianReplyCheckLabels, ...options.labels };
143
+ return async ({ text, seenTexts, rewrite }) => {
144
+ const links = unknownLinksIn(text, seenTexts, appDomains);
145
+ if (links.length === 0) return;
146
+ let rewritten = "";
147
+ try {
148
+ rewritten = acceptedRewrite(text, await rewrite(LINK_REWRITE_INSTRUCTION, `${text}
149
+
150
+ ${UNKNOWN_LINKS_NOTE} ${links.join(", ")}`));
151
+ } catch {
152
+ }
153
+ const base = rewritten || text;
154
+ const left = unknownLinksIn(base, seenTexts, appDomains);
155
+ if (rewritten && left.length === 0) return { text: rewritten, note: noteOf2("\u043F\u0435\u0440\u0435\u043F\u0438\u0441\u0430\u043D", links, left) };
156
+ const cut = withoutLinks(base, left);
157
+ if (!/[\p{L}\p{N}]/u.test(cut)) return { text: labels.linkOnlyReply, note: noteOf2("\u0437\u0430\u043F\u0430\u0441\u043D\u0430\u044F \u0444\u0440\u0430\u0437\u0430", links, left) };
158
+ return { text: cut, note: noteOf2("\u0441\u0441\u044B\u043B\u043A\u0438 \u0432\u044B\u0440\u0435\u0437\u0430\u043D\u044B", links, left) };
159
+ };
160
+ }
161
+ export {
162
+ englishReplyCheckLabels,
163
+ foreignWords,
164
+ russianReplyCheckLabels,
165
+ unknownLinks
166
+ };
167
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/rewrite-notes.ts","../src/accepted-rewrite.ts","../src/found-foreign-words.ts","../src/foreign-words.ts","../src/app-domains.ts","../src/links-in-text.ts","../src/labels.ts","../src/unknown-links.ts"],"sourcesContent":["// Служебные строки запроса на переписывание: человеку их показывать нельзя, а модель иногда повторяет их в ответе.\n\n/** Строка перед списком чужих слов. */\nexport const FOREIGN_WORDS_NOTE = \"Чужие слова:\";\n\n/** Строка перед списком ссылок не из знакомых текстов. */\nexport const UNKNOWN_LINKS_NOTE = \"Ссылки не из инструкции:\";\n","import { FOREIGN_WORDS_NOTE, UNKNOWN_LINKS_NOTE } from \"./rewrite-notes\";\n\n/** Переписанный ответ короче этой доли исходного — смысл мог уйти. */\nconst MIN_LENGTH_RATIO = 0.6;\n\n/** Во сколько раз переписанный ответ может быть длиннее исходного, если приложение не задало своё. */\nexport const DEFAULT_MAX_LENGTH_RATIO = 1.5;\n\n/**\n * Переписанный текст, который можно отдать человеку; иначе пусто — и уходит исходный. Отбрасывается ответ со служебной\n * строкой запроса (модель повторила её) и, если длина важна, ответ, который сильно поменял длину.\n */\nexport function acceptedRewrite(original: string, rewritten: string, options: { sameLength?: { maxRatio: number } } = {}): string {\n const text = rewritten.trim();\n if (!text || text.includes(FOREIGN_WORDS_NOTE) || text.includes(UNKNOWN_LINKS_NOTE)) return \"\";\n if (options.sameLength) {\n const ratio = text.length / Math.max(original.trim().length, 1);\n if (ratio < MIN_LENGTH_RATIO || ratio > options.sameLength.maxRatio) return \"\";\n }\n return text;\n}\n","// Чужие слова в ответе. Слова не латиницы и не кириллицы — чужие всегда. Слово «соседней» письменности (латиница в\n// русском ответе, кириллица в английском) — чужое, только если его нет в знакомых текстах и это не название.\n// Так у преподавателя английского его термины законны, а «Actually» посреди русской речи — нет.\n\nexport type ReplyScript = \"cyrillic\" | \"latin\";\n\n/** Ссылки, почта, @имена, домены вида слово.слово/путь: их не трогаем. */\nconst UNTOUCHABLE =\n /(?:https?:\\/\\/|www\\.)\\S+|[\\w.+-]+@[\\w-]+(?:\\.[\\w-]+)+|@[\\w.]+|[\\p{L}\\d-]+(?:\\.[\\p{L}\\d-]+)*\\.(?:[a-z]{2,6}|рф)(?!\\p{L})(?:\\/\\S*)?/gu;\n\n/** Слово, где кириллица и латиница вперемешку: латинская буква вместо русской. */\nconst MIXED_WORD = /\\p{L}*(?:\\p{Script=Cyrillic}\\p{L}*\\p{Script=Latin}|\\p{Script=Latin}\\p{L}*\\p{Script=Cyrillic})\\p{L}*/gu;\n\n/** Слово не латиницы и не кириллицы: иероглифы, кана, хангыль, арабское, греческое письмо… */\nconst OTHER_SCRIPT_WORD = /(?:(?![\\p{Script=Latin}\\p{Script=Cyrillic}])\\p{L}[\\p{M}]*)+/gu;\n\nconst LATIN_WORD = /\\p{Script=Latin}+(?:['’-]\\p{Script=Latin}+)*/gu;\nconst CYRILLIC_WORD = /\\p{Script=Cyrillic}+(?:['’-]\\p{Script=Cyrillic}+)*/gu;\n\n/** Сервисы, которые в русской речи пишут латиницей и с маленькой буквы, и в начале фразы. */\nconst COMMON_SERVICES = new Set([\"zoom\", \"telegram\", \"instagram\", \"youtube\", \"whatsapp\", \"google\", \"skype\", \"excel\", \"notion\", \"viber\", \"facebook\", \"tiktok\"]);\n\nconst ROMAN_NUMERAL = /^[IVXLCDM]+$/;\n\n/** Слова соседней письменности: у русского ответа это латиница, у английского — кириллица. */\nfunction neighborWordsOf(text: string, script: ReplyScript): string[] {\n const words = text.match(script === \"cyrillic\" ? LATIN_WORD : CYRILLIC_WORD) ?? [];\n return words.filter((word) => word.length > 1 && !(script === \"cyrillic\" && ROMAN_NUMERAL.test(word)));\n}\n\n/** Сокращение из заглавных (PDF), большая буква внутри (WhatsApp), большая буква посреди фразы (в Calendly) — название. */\nfunction isName(word: string, before: string, script: ReplyScript): boolean {\n if (script === \"cyrillic\" && COMMON_SERVICES.has(word.toLowerCase())) return true;\n if (/^\\p{Lu}+$/u.test(word) || /\\p{Lu}/u.test(word.slice(1))) return true;\n return /^\\p{Lu}/u.test(word) && !/(?:^|[.!?…:\\n])[\\s«\"„“(—-]*$/u.test(before);\n}\n\n/**\n * Чужие слова ответа по порядку появления. `known` — тексты, слова соседней письменности из которых законны;\n * `knownNames` — названия, которые пишутся другой письменностью, чем ответ.\n */\nexport function foreignWordsIn(text: string, known: readonly string[], script: ReplyScript, knownNames: readonly string[] = []): string[] {\n const knownWords = new Set(\n [...known, ...knownNames].flatMap((source) => neighborWordsOf(source.replace(UNTOUCHABLE, \" \"), script).map((word) => word.toLowerCase())),\n );\n const found: Array<{ at: number; word: string }> = [];\n const clean = text\n .replace(UNTOUCHABLE, (link) => \" \".repeat(link.length))\n .replace(MIXED_WORD, (word, at: number) => {\n found.push({ at, word });\n return \" \".repeat(word.length);\n });\n for (const match of clean.matchAll(OTHER_SCRIPT_WORD)) found.push({ at: match.index, word: match[0] });\n for (const match of clean.matchAll(script === \"cyrillic\" ? LATIN_WORD : CYRILLIC_WORD)) {\n const [word] = neighborWordsOf(match[0], script);\n if (word && !knownWords.has(word.toLowerCase()) && !isName(word, clean.slice(0, match.index), script)) found.push({ at: match.index, word });\n }\n return found.sort((a, b) => a.at - b.at).map((item) => item.word);\n}\n","import type { ReplyCheck } from \"@ykurilov/agent-chat-core\";\nimport { DEFAULT_MAX_LENGTH_RATIO, acceptedRewrite } from \"./accepted-rewrite\";\nimport { foreignWordsIn, type ReplyScript } from \"./found-foreign-words\";\nimport { FOREIGN_WORDS_NOTE } from \"./rewrite-notes\";\n\nexport { FOREIGN_WORDS_NOTE };\n\nexport interface ForeignWordsOptions {\n /** Письменность ответа: слова другой письменности — чужие. */\n script: ReplyScript;\n /** Язык ответа для инструкции переписки, в виде «по-русски». По умолчанию: кириллица — «по-русски», латиница — «по-английски». */\n language?: string;\n /** Имена, которые пишутся другой письменностью и чужими не считаются (Excel, Telegram). */\n knownNames?: readonly string[];\n /** Насколько длиннее исходного может быть переписанный ответ (во сколько раз). По умолчанию 1.5. */\n maxLengthRatio?: number;\n}\n\nconst DEFAULT_LANGUAGE: Record<ReplyScript, string> = { cyrillic: \"по-русски\", latin: \"по-английски\" };\n\n/** Неизменное начало запроса: одно на все переписки одного языка, из кэша модели. */\nexport function foreignWordsInstruction(language: string): string {\n return (\n `Ты редактор. Перепиши этот ответ ${language}, смысл не меняй. ` +\n \"Иностранные слова и иероглифы замени словами языка ответа. Ссылки, почту, @имена и названия оставь как есть. \" +\n \"Ничего не добавляй и не убирай. В ответе только переписанный текст.\"\n );\n}\n\nexport const FOREIGN_WORDS_INSTRUCTION_CYRILLIC = foreignWordsInstruction(DEFAULT_LANGUAGE.cyrillic);\n\ntype Outcome = \"переписан\" | \"ушёл лучший из двух\" | \"не переписан\";\n\n/** Строка журнала: по ней считается, как часто срабатывает проверка. */\nfunction noteOf(outcome: Outcome, foreign: readonly string[], left: readonly string[]): string {\n const rest = left.length > 0 ? ` осталось=${left.join(\",\")}` : \"\";\n return `чужие слова: итог=${outcome} было=${foreign.join(\",\")}${rest}`;\n}\n\n/** Чужие слова в ответе: одна переписка моделью, иначе уходит вариант с меньшим числом чужих слов. */\nexport function foreignWords(options: ForeignWordsOptions): ReplyCheck {\n const { script, knownNames = [], maxLengthRatio = DEFAULT_MAX_LENGTH_RATIO } = options;\n const system = foreignWordsInstruction(options.language ?? DEFAULT_LANGUAGE[script]);\n\n return async ({ text, seenTexts, rewrite }) => {\n const foreign = foreignWordsIn(text, seenTexts, script, knownNames);\n if (foreign.length === 0) return;\n\n let rewritten = \"\";\n try {\n rewritten = acceptedRewrite(text, await rewrite(system, `${text}\\n\\n${FOREIGN_WORDS_NOTE} ${foreign.join(\", \")}`), {\n sameLength: { maxRatio: maxLengthRatio },\n });\n } catch {\n // Модель не ответила — уходит исходный ответ\n }\n if (!rewritten) return { text, note: noteOf(\"не переписан\", foreign, foreign) };\n\n const left = foreignWordsIn(rewritten, seenTexts, script, knownNames);\n if (left.length === 0) return { text: rewritten, note: noteOf(\"переписан\", foreign, left) };\n return left.length < foreign.length\n ? { text: rewritten, note: noteOf(\"ушёл лучший из двух\", foreign, left) }\n : { text, note: noteOf(\"ушёл лучший из двух\", foreign, foreign) };\n };\n}\n","// Короткий адрес приложения человека. Когда у приложений общий домен, человек часто пишет только имя — «sveta-nails»,\n// и адрес «sveta-nails.ykapp.ru» однозначен. Домен задаёт настройка или шаблон в знакомых текстах — «адрес.ykapp.ru».\n\n// «адрес.ykapp.ru», «имя.site.ru»: русское слово-заготовка перед доменом\nconst APP_DOMAIN_TEMPLATE = /(?<![\\p{L}\\p{N}.-])[а-яё]+\\.((?:[a-z0-9-]+\\.)+[a-z]{2,})/giu;\n\n/** Домены приложений: заданные настройкой и описанные шаблоном в знакомых текстах. */\nexport function appDomainsOf(texts: readonly string[], configured: readonly string[] = []): string[] {\n const domains = new Set(configured.map((domain) => domain.toLowerCase()));\n for (const text of texts) for (const found of text.matchAll(APP_DOMAIN_TEMPLATE)) domains.add(found[1]!.toLowerCase());\n return [...domains];\n}\n\n/**\n * Хост — «имя.домен приложений», а имя человек написал отдельным словом: «адрес sveta-nails» → sveta-nails.ykapp.ru.\n * Имя — одно латинское слово с цифрами и дефисами; с точкой после него это уже другой адрес.\n */\nexport function isAppNameHost(host: string, appDomains: readonly string[], texts: readonly string[]): boolean {\n const domain = appDomains.find((candidate) => host.endsWith(`.${candidate}`));\n if (!domain) return false;\n const name = host.slice(0, -domain.length - 1);\n // Только латиница, цифры и дефис: имя вставляется в выражение как есть\n if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) return false;\n const word = new RegExp(`(?<![\\\\p{L}\\\\p{N}./@-])${name}(?![\\\\p{L}\\\\p{N}-]|\\\\.[\\\\p{L}\\\\p{N}])`, \"iu\");\n return texts.some((text) => word.test(text));\n}\n","import { appDomainsOf, isAppNameHost } from \"./app-domains\";\n\n// Ссылки в ответе. Давать можно только те, что есть в знакомых текстах (инструкция, сценарий, реплики человека).\n// Выдуманный или изменённый адрес уводит человека в никуда.\n\n/** http(s)://, www.…, домен с путём (t.me/…, bit.ly/…). Почта и @имена — не ссылки: перед доменом нет «@». */\nconst LINK = /(?:https?:\\/\\/|(?<![\\p{L}\\d@.\\/-])www\\.)[^\\s<>«»\"'()]+|(?<![\\p{L}\\d@.\\/-])(?:[\\p{L}\\d-]+\\.)+(?:[a-z]{2,6}|рф)\\/[^\\s<>«»\"'()]*/giu;\n\n/** Знаки препинания в конце — от фразы, а не от ссылки. */\nconst TRAILING = /[.,!?;:…]+$/u;\n\n/** Ссылки текста по порядку появления. */\nexport function linksIn(text: string): string[] {\n return (text.match(LINK) ?? []).map((link) => link.replace(TRAILING, \"\")).filter(Boolean);\n}\n\n/** Адрес без схемы, «www.» и косой черты в конце; домен — без учёта регистра: «https://A.ru/x» и «a.ru/x» — одна ссылка. */\nfunction sameAddress(link: string): string {\n const bare = link.replace(/^https?:\\/\\//i, \"\").replace(/^www\\./i, \"\").replace(/\\/+$/, \"\");\n const slash = bare.indexOf(\"/\");\n return slash < 0 ? bare.toLowerCase() : bare.slice(0, slash).toLowerCase() + bare.slice(slash);\n}\n\n// Голый адрес в знакомом тексте: человек пишет «sveta-nails.ykapp.ru» без https:// и без пути\nconst BARE_HOST = /(?<![\\p{L}\\p{N}@./-])(?:[\\p{L}\\p{N}-]+\\.)+[\\p{L}]{2,}(?![\\p{L}\\p{N}/-])/giu;\n\n/**\n * Ссылки ответа, которых нет в знакомых текстах. Законны и голые адреса из них, и короткий адрес приложения:\n * имя из знакомых текстов на домене приложений (заданном настройкой или описанном шаблоном «адрес.ykapp.ru»).\n */\nexport function unknownLinksIn(text: string, known: readonly string[], appDomains: readonly string[] = []): string[] {\n const allowed = new Set([...known.flatMap(linksIn), ...known.flatMap((one) => one.match(BARE_HOST) ?? [])].map(sameAddress));\n const domains = appDomainsOf(known, appDomains);\n return linksIn(text).filter((link) => {\n const address = sameAddress(link);\n return !allowed.has(address) && !(!address.includes(\"/\") && isAppNameHost(address, domains, known));\n });\n}\n\n/** Ссылки вырезаются вместе с подводкой «: » перед ними; лишние пробелы перед знаками убираются. */\nexport function withoutLinks(text: string, links: readonly string[]): string {\n let result = text;\n for (const link of links) result = result.split(link).join(\"\\u0000\");\n return result\n .replace(/\\s*:?\\s*\\u0000/g, \"\")\n .replace(/[ \\t]{2,}/g, \" \")\n .replace(/ +([.,!?;:…])/g, \"$1\")\n .trim();\n}\n","/** Слова, которые проверки ответа сами вставляют в ответ агента. */\nexport interface ReplyCheckLabels {\n /** Ответ был целиком из выдуманной ссылки: после вырезания ничего не осталось — уходит эта фраза. */\n linkOnlyReply: string;\n}\n\nexport const russianReplyCheckLabels: ReplyCheckLabels = {\n linkOnlyReply: \"Ссылку здесь дать не могу. Расскажите, что вы ищете, и я подскажу.\",\n};\n\nexport const englishReplyCheckLabels: ReplyCheckLabels = {\n linkOnlyReply: \"I can't share a link here. Tell me what you're looking for and I'll help.\",\n};\n","import type { ReplyCheck } from \"@ykurilov/agent-chat-core\";\nimport { acceptedRewrite } from \"./accepted-rewrite\";\nimport { UNKNOWN_LINKS_NOTE } from \"./rewrite-notes\";\nimport { unknownLinksIn, withoutLinks } from \"./links-in-text\";\nimport { russianReplyCheckLabels, type ReplyCheckLabels } from \"./labels\";\n\nexport { UNKNOWN_LINKS_NOTE };\n\nexport interface UnknownLinksOptions {\n /** Домены приложений: короткие имена на них разрешены, если человек назвал имя (`ivan` → `ivan.ykapp.ru`). */\n appDomains?: readonly string[];\n /** Свои слова вместо русских по умолчанию (`englishReplyCheckLabels` или часть). */\n labels?: Partial<ReplyCheckLabels>;\n}\n\n/** Неизменное начало запроса: одно на все переписки, из кэша модели. */\nexport const LINK_REWRITE_INSTRUCTION =\n \"Ты редактор. Ссылки, перечисленные ниже, — не из инструкции: убери их из ответа. \" +\n \"Если без ссылки фраза теряет смысл, перестрой её, ничего не обещая. Других ссылок не добавляй, остальное не меняй. \" +\n \"В ответе только переписанный текст.\";\n\ntype Outcome = \"переписан\" | \"ссылки вырезаны\" | \"запасная фраза\";\n\n/** Строка журнала: по ней считается, как часто срабатывает проверка. */\nfunction noteOf(outcome: Outcome, links: readonly string[], left: readonly string[]): string {\n const rest = left.length > 0 ? ` вырезано=${left.join(\",\")}` : \"\";\n return `ссылки не из инструкции: итог=${outcome} было=${links.join(\",\")}${rest}`;\n}\n\n/** Выдуманные ссылки: разрешены адреса из `seenTexts`; лишние — одна переписка, остаток вырезается кодом. */\nexport function unknownLinks(options: UnknownLinksOptions = {}): ReplyCheck {\n const { appDomains = [] } = options;\n const labels = { ...russianReplyCheckLabels, ...options.labels };\n\n return async ({ text, seenTexts, rewrite }) => {\n const links = unknownLinksIn(text, seenTexts, appDomains);\n if (links.length === 0) return;\n\n let rewritten = \"\";\n try {\n rewritten = acceptedRewrite(text, await rewrite(LINK_REWRITE_INSTRUCTION, `${text}\\n\\n${UNKNOWN_LINKS_NOTE} ${links.join(\", \")}`));\n } catch {\n // Модель не ответила — ссылки вырежет код\n }\n const base = rewritten || text;\n const left = unknownLinksIn(base, seenTexts, appDomains);\n if (rewritten && left.length === 0) return { text: rewritten, note: noteOf(\"переписан\", links, left) };\n const cut = withoutLinks(base, left);\n // Ответ был одной ссылкой: пустой текст служба заменила бы прежним — со ссылкой. Уходит запасная фраза.\n if (!/[\\p{L}\\p{N}]/u.test(cut)) return { text: labels.linkOnlyReply, note: noteOf(\"запасная фраза\", links, left) };\n return { text: cut, note: noteOf(\"ссылки вырезаны\", links, left) };\n };\n}\n"],"mappings":";AAGO,IAAM,qBAAqB;AAG3B,IAAM,qBAAqB;;;ACHlC,IAAM,mBAAmB;AAGlB,IAAM,2BAA2B;AAMjC,SAAS,gBAAgB,UAAkB,WAAmB,UAAiD,CAAC,GAAW;AAChI,QAAM,OAAO,UAAU,KAAK;AAC5B,MAAI,CAAC,QAAQ,KAAK,SAAS,kBAAkB,KAAK,KAAK,SAAS,kBAAkB,EAAG,QAAO;AAC5F,MAAI,QAAQ,YAAY;AACtB,UAAM,QAAQ,KAAK,SAAS,KAAK,IAAI,SAAS,KAAK,EAAE,QAAQ,CAAC;AAC9D,QAAI,QAAQ,oBAAoB,QAAQ,QAAQ,WAAW,SAAU,QAAO;AAAA,EAC9E;AACA,SAAO;AACT;;;ACbA,IAAM,cACJ;AAGF,IAAM,aAAa;AAGnB,IAAM,oBAAoB;AAE1B,IAAM,aAAa;AACnB,IAAM,gBAAgB;AAGtB,IAAM,kBAAkB,oBAAI,IAAI,CAAC,QAAQ,YAAY,aAAa,WAAW,YAAY,UAAU,SAAS,SAAS,UAAU,SAAS,YAAY,QAAQ,CAAC;AAE7J,IAAM,gBAAgB;AAGtB,SAAS,gBAAgB,MAAc,QAA+B;AACpE,QAAM,QAAQ,KAAK,MAAM,WAAW,aAAa,aAAa,aAAa,KAAK,CAAC;AACjF,SAAO,MAAM,OAAO,CAAC,SAAS,KAAK,SAAS,KAAK,EAAE,WAAW,cAAc,cAAc,KAAK,IAAI,EAAE;AACvG;AAGA,SAAS,OAAO,MAAc,QAAgB,QAA8B;AAC1E,MAAI,WAAW,cAAc,gBAAgB,IAAI,KAAK,YAAY,CAAC,EAAG,QAAO;AAC7E,MAAI,aAAa,KAAK,IAAI,KAAK,UAAU,KAAK,KAAK,MAAM,CAAC,CAAC,EAAG,QAAO;AACrE,SAAO,WAAW,KAAK,IAAI,KAAK,CAAC,gCAAgC,KAAK,MAAM;AAC9E;AAMO,SAAS,eAAe,MAAc,OAA0B,QAAqB,aAAgC,CAAC,GAAa;AACxI,QAAM,aAAa,IAAI;AAAA,IACrB,CAAC,GAAG,OAAO,GAAG,UAAU,EAAE,QAAQ,CAAC,WAAW,gBAAgB,OAAO,QAAQ,aAAa,GAAG,GAAG,MAAM,EAAE,IAAI,CAAC,SAAS,KAAK,YAAY,CAAC,CAAC;AAAA,EAC3I;AACA,QAAM,QAA6C,CAAC;AACpD,QAAM,QAAQ,KACX,QAAQ,aAAa,CAAC,SAAS,IAAI,OAAO,KAAK,MAAM,CAAC,EACtD,QAAQ,YAAY,CAAC,MAAM,OAAe;AACzC,UAAM,KAAK,EAAE,IAAI,KAAK,CAAC;AACvB,WAAO,IAAI,OAAO,KAAK,MAAM;AAAA,EAC/B,CAAC;AACH,aAAW,SAAS,MAAM,SAAS,iBAAiB,EAAG,OAAM,KAAK,EAAE,IAAI,MAAM,OAAO,MAAM,MAAM,CAAC,EAAE,CAAC;AACrG,aAAW,SAAS,MAAM,SAAS,WAAW,aAAa,aAAa,aAAa,GAAG;AACtF,UAAM,CAAC,IAAI,IAAI,gBAAgB,MAAM,CAAC,GAAG,MAAM;AAC/C,QAAI,QAAQ,CAAC,WAAW,IAAI,KAAK,YAAY,CAAC,KAAK,CAAC,OAAO,MAAM,MAAM,MAAM,GAAG,MAAM,KAAK,GAAG,MAAM,EAAG,OAAM,KAAK,EAAE,IAAI,MAAM,OAAO,KAAK,CAAC;AAAA,EAC7I;AACA,SAAO,MAAM,KAAK,CAAC,GAAG,MAAM,EAAE,KAAK,EAAE,EAAE,EAAE,IAAI,CAAC,SAAS,KAAK,IAAI;AAClE;;;ACxCA,IAAM,mBAAgD,EAAE,UAAU,qDAAa,OAAO,sEAAe;AAG9F,SAAS,wBAAwB,UAA0B;AAChE,SACE,2KAAoC,QAAQ;AAIhD;AAEO,IAAM,qCAAqC,wBAAwB,iBAAiB,QAAQ;AAKnG,SAAS,OAAO,SAAkB,SAA4B,MAAiC;AAC7F,QAAM,OAAO,KAAK,SAAS,IAAI,qDAAa,KAAK,KAAK,GAAG,CAAC,KAAK;AAC/D,SAAO,2FAAqB,OAAO,6BAAS,QAAQ,KAAK,GAAG,CAAC,GAAG,IAAI;AACtE;AAGO,SAAS,aAAa,SAA0C;AACrE,QAAM,EAAE,QAAQ,aAAa,CAAC,GAAG,iBAAiB,yBAAyB,IAAI;AAC/E,QAAM,SAAS,wBAAwB,QAAQ,YAAY,iBAAiB,MAAM,CAAC;AAEnF,SAAO,OAAO,EAAE,MAAM,WAAW,QAAQ,MAAM;AAC7C,UAAM,UAAU,eAAe,MAAM,WAAW,QAAQ,UAAU;AAClE,QAAI,QAAQ,WAAW,EAAG;AAE1B,QAAI,YAAY;AAChB,QAAI;AACF,kBAAY,gBAAgB,MAAM,MAAM,QAAQ,QAAQ,GAAG,IAAI;AAAA;AAAA,EAAO,kBAAkB,IAAI,QAAQ,KAAK,IAAI,CAAC,EAAE,GAAG;AAAA,QACjH,YAAY,EAAE,UAAU,eAAe;AAAA,MACzC,CAAC;AAAA,IACH,QAAQ;AAAA,IAER;AACA,QAAI,CAAC,UAAW,QAAO,EAAE,MAAM,MAAM,OAAO,uEAAgB,SAAS,OAAO,EAAE;AAE9E,UAAM,OAAO,eAAe,WAAW,WAAW,QAAQ,UAAU;AACpE,QAAI,KAAK,WAAW,EAAG,QAAO,EAAE,MAAM,WAAW,MAAM,OAAO,0DAAa,SAAS,IAAI,EAAE;AAC1F,WAAO,KAAK,SAAS,QAAQ,SACzB,EAAE,MAAM,WAAW,MAAM,OAAO,uGAAuB,SAAS,IAAI,EAAE,IACtE,EAAE,MAAM,MAAM,OAAO,uGAAuB,SAAS,OAAO,EAAE;AAAA,EACpE;AACF;;;AC5DA,IAAM,sBAAsB;AAGrB,SAAS,aAAa,OAA0B,aAAgC,CAAC,GAAa;AACnG,QAAM,UAAU,IAAI,IAAI,WAAW,IAAI,CAAC,WAAW,OAAO,YAAY,CAAC,CAAC;AACxE,aAAW,QAAQ,MAAO,YAAW,SAAS,KAAK,SAAS,mBAAmB,EAAG,SAAQ,IAAI,MAAM,CAAC,EAAG,YAAY,CAAC;AACrH,SAAO,CAAC,GAAG,OAAO;AACpB;AAMO,SAAS,cAAc,MAAc,YAA+B,OAAmC;AAC5G,QAAM,SAAS,WAAW,KAAK,CAAC,cAAc,KAAK,SAAS,IAAI,SAAS,EAAE,CAAC;AAC5E,MAAI,CAAC,OAAQ,QAAO;AACpB,QAAM,OAAO,KAAK,MAAM,GAAG,CAAC,OAAO,SAAS,CAAC;AAE7C,MAAI,CAAC,uBAAuB,KAAK,IAAI,EAAG,QAAO;AAC/C,QAAM,OAAO,IAAI,OAAO,0BAA0B,IAAI,yCAAyC,IAAI;AACnG,SAAO,MAAM,KAAK,CAAC,SAAS,KAAK,KAAK,IAAI,CAAC;AAC7C;;;ACnBA,IAAM,OAAO;AAGb,IAAM,WAAW;AAGV,SAAS,QAAQ,MAAwB;AAC9C,UAAQ,KAAK,MAAM,IAAI,KAAK,CAAC,GAAG,IAAI,CAAC,SAAS,KAAK,QAAQ,UAAU,EAAE,CAAC,EAAE,OAAO,OAAO;AAC1F;AAGA,SAAS,YAAY,MAAsB;AACzC,QAAM,OAAO,KAAK,QAAQ,iBAAiB,EAAE,EAAE,QAAQ,WAAW,EAAE,EAAE,QAAQ,QAAQ,EAAE;AACxF,QAAM,QAAQ,KAAK,QAAQ,GAAG;AAC9B,SAAO,QAAQ,IAAI,KAAK,YAAY,IAAI,KAAK,MAAM,GAAG,KAAK,EAAE,YAAY,IAAI,KAAK,MAAM,KAAK;AAC/F;AAGA,IAAM,YAAY;AAMX,SAAS,eAAe,MAAc,OAA0B,aAAgC,CAAC,GAAa;AACnH,QAAM,UAAU,IAAI,IAAI,CAAC,GAAG,MAAM,QAAQ,OAAO,GAAG,GAAG,MAAM,QAAQ,CAAC,QAAQ,IAAI,MAAM,SAAS,KAAK,CAAC,CAAC,CAAC,EAAE,IAAI,WAAW,CAAC;AAC3H,QAAM,UAAU,aAAa,OAAO,UAAU;AAC9C,SAAO,QAAQ,IAAI,EAAE,OAAO,CAAC,SAAS;AACpC,UAAM,UAAU,YAAY,IAAI;AAChC,WAAO,CAAC,QAAQ,IAAI,OAAO,KAAK,EAAE,CAAC,QAAQ,SAAS,GAAG,KAAK,cAAc,SAAS,SAAS,KAAK;AAAA,EACnG,CAAC;AACH;AAGO,SAAS,aAAa,MAAc,OAAkC;AAC3E,MAAI,SAAS;AACb,aAAW,QAAQ,MAAO,UAAS,OAAO,MAAM,IAAI,EAAE,KAAK,IAAQ;AACnE,SAAO,OACJ,QAAQ,mBAAmB,EAAE,EAC7B,QAAQ,cAAc,GAAG,EACzB,QAAQ,kBAAkB,IAAI,EAC9B,KAAK;AACV;;;AC1CO,IAAM,0BAA4C;AAAA,EACvD,eAAe;AACjB;AAEO,IAAM,0BAA4C;AAAA,EACvD,eAAe;AACjB;;;ACIO,IAAM,2BACX;AAOF,SAASA,QAAO,SAAkB,OAA0B,MAAiC;AAC3F,QAAM,OAAO,KAAK,SAAS,IAAI,qDAAa,KAAK,KAAK,GAAG,CAAC,KAAK;AAC/D,SAAO,yJAAiC,OAAO,6BAAS,MAAM,KAAK,GAAG,CAAC,GAAG,IAAI;AAChF;AAGO,SAAS,aAAa,UAA+B,CAAC,GAAe;AAC1E,QAAM,EAAE,aAAa,CAAC,EAAE,IAAI;AAC5B,QAAM,SAAS,EAAE,GAAG,yBAAyB,GAAG,QAAQ,OAAO;AAE/D,SAAO,OAAO,EAAE,MAAM,WAAW,QAAQ,MAAM;AAC7C,UAAM,QAAQ,eAAe,MAAM,WAAW,UAAU;AACxD,QAAI,MAAM,WAAW,EAAG;AAExB,QAAI,YAAY;AAChB,QAAI;AACF,kBAAY,gBAAgB,MAAM,MAAM,QAAQ,0BAA0B,GAAG,IAAI;AAAA;AAAA,EAAO,kBAAkB,IAAI,MAAM,KAAK,IAAI,CAAC,EAAE,CAAC;AAAA,IACnI,QAAQ;AAAA,IAER;AACA,UAAM,OAAO,aAAa;AAC1B,UAAM,OAAO,eAAe,MAAM,WAAW,UAAU;AACvD,QAAI,aAAa,KAAK,WAAW,EAAG,QAAO,EAAE,MAAM,WAAW,MAAMA,QAAO,0DAAa,OAAO,IAAI,EAAE;AACrG,UAAM,MAAM,aAAa,MAAM,IAAI;AAEnC,QAAI,CAAC,gBAAgB,KAAK,GAAG,EAAG,QAAO,EAAE,MAAM,OAAO,eAAe,MAAMA,QAAO,mFAAkB,OAAO,IAAI,EAAE;AACjH,WAAO,EAAE,MAAM,KAAK,MAAMA,QAAO,yFAAmB,OAAO,IAAI,EAAE;AAAA,EACnE;AACF;","names":["noteOf"]}
@@ -0,0 +1,11 @@
1
+ /** Во сколько раз переписанный ответ может быть длиннее исходного, если приложение не задало своё. */
2
+ export declare const DEFAULT_MAX_LENGTH_RATIO = 1.5;
3
+ /**
4
+ * Переписанный текст, который можно отдать человеку; иначе пусто — и уходит исходный. Отбрасывается ответ со служебной
5
+ * строкой запроса (модель повторила её) и, если длина важна, ответ, который сильно поменял длину.
6
+ */
7
+ export declare function acceptedRewrite(original: string, rewritten: string, options?: {
8
+ sameLength?: {
9
+ maxRatio: number;
10
+ };
11
+ }): string;
@@ -0,0 +1,7 @@
1
+ /** Домены приложений: заданные настройкой и описанные шаблоном в знакомых текстах. */
2
+ export declare function appDomainsOf(texts: readonly string[], configured?: readonly string[]): string[];
3
+ /**
4
+ * Хост — «имя.домен приложений», а имя человек написал отдельным словом: «адрес sveta-nails» → sveta-nails.ykapp.ru.
5
+ * Имя — одно латинское слово с цифрами и дефисами; с точкой после него это уже другой адрес.
6
+ */
7
+ export declare function isAppNameHost(host: string, appDomains: readonly string[], texts: readonly string[]): boolean;
@@ -0,0 +1,19 @@
1
+ import type { ReplyCheck } from "@ykurilov/agent-chat-core";
2
+ import { type ReplyScript } from "./found-foreign-words.js";
3
+ import { FOREIGN_WORDS_NOTE } from "./rewrite-notes.js";
4
+ export { FOREIGN_WORDS_NOTE };
5
+ export interface ForeignWordsOptions {
6
+ /** Письменность ответа: слова другой письменности — чужие. */
7
+ script: ReplyScript;
8
+ /** Язык ответа для инструкции переписки, в виде «по-русски». По умолчанию: кириллица — «по-русски», латиница — «по-английски». */
9
+ language?: string;
10
+ /** Имена, которые пишутся другой письменностью и чужими не считаются (Excel, Telegram). */
11
+ knownNames?: readonly string[];
12
+ /** Насколько длиннее исходного может быть переписанный ответ (во сколько раз). По умолчанию 1.5. */
13
+ maxLengthRatio?: number;
14
+ }
15
+ /** Неизменное начало запроса: одно на все переписки одного языка, из кэша модели. */
16
+ export declare function foreignWordsInstruction(language: string): string;
17
+ export declare const FOREIGN_WORDS_INSTRUCTION_CYRILLIC: string;
18
+ /** Чужие слова в ответе: одна переписка моделью, иначе уходит вариант с меньшим числом чужих слов. */
19
+ export declare function foreignWords(options: ForeignWordsOptions): ReplyCheck;
@@ -0,0 +1,6 @@
1
+ export type ReplyScript = "cyrillic" | "latin";
2
+ /**
3
+ * Чужие слова ответа по порядку появления. `known` — тексты, слова соседней письменности из которых законны;
4
+ * `knownNames` — названия, которые пишутся другой письменностью, чем ответ.
5
+ */
6
+ export declare function foreignWordsIn(text: string, known: readonly string[], script: ReplyScript, knownNames?: readonly string[]): string[];
@@ -0,0 +1,4 @@
1
+ export { foreignWords, type ForeignWordsOptions } from "./foreign-words.js";
2
+ export { unknownLinks, type UnknownLinksOptions } from "./unknown-links.js";
3
+ export { englishReplyCheckLabels, russianReplyCheckLabels, type ReplyCheckLabels } from "./labels.js";
4
+ export type { ReplyCheck, ReplyCheckInput, ReplyCheckResult } from "@ykurilov/agent-chat-core";
@@ -0,0 +1,7 @@
1
+ /** Слова, которые проверки ответа сами вставляют в ответ агента. */
2
+ export interface ReplyCheckLabels {
3
+ /** Ответ был целиком из выдуманной ссылки: после вырезания ничего не осталось — уходит эта фраза. */
4
+ linkOnlyReply: string;
5
+ }
6
+ export declare const russianReplyCheckLabels: ReplyCheckLabels;
7
+ export declare const englishReplyCheckLabels: ReplyCheckLabels;
@@ -0,0 +1,9 @@
1
+ /** Ссылки текста по порядку появления. */
2
+ export declare function linksIn(text: string): string[];
3
+ /**
4
+ * Ссылки ответа, которых нет в знакомых текстах. Законны и голые адреса из них, и короткий адрес приложения:
5
+ * имя из знакомых текстов на домене приложений (заданном настройкой или описанном шаблоном «адрес.ykapp.ru»).
6
+ */
7
+ export declare function unknownLinksIn(text: string, known: readonly string[], appDomains?: readonly string[]): string[];
8
+ /** Ссылки вырезаются вместе с подводкой «: » перед ними; лишние пробелы перед знаками убираются. */
9
+ export declare function withoutLinks(text: string, links: readonly string[]): string;
@@ -0,0 +1,4 @@
1
+ /** Строка перед списком чужих слов. */
2
+ export declare const FOREIGN_WORDS_NOTE = "\u0427\u0443\u0436\u0438\u0435 \u0441\u043B\u043E\u0432\u0430:";
3
+ /** Строка перед списком ссылок не из знакомых текстов. */
4
+ export declare const UNKNOWN_LINKS_NOTE = "\u0421\u0441\u044B\u043B\u043A\u0438 \u043D\u0435 \u0438\u0437 \u0438\u043D\u0441\u0442\u0440\u0443\u043A\u0446\u0438\u0438:";
@@ -0,0 +1,14 @@
1
+ import type { ReplyCheck } from "@ykurilov/agent-chat-core";
2
+ import { UNKNOWN_LINKS_NOTE } from "./rewrite-notes.js";
3
+ import { type ReplyCheckLabels } from "./labels.js";
4
+ export { UNKNOWN_LINKS_NOTE };
5
+ export interface UnknownLinksOptions {
6
+ /** Домены приложений: короткие имена на них разрешены, если человек назвал имя (`ivan` → `ivan.ykapp.ru`). */
7
+ appDomains?: readonly string[];
8
+ /** Свои слова вместо русских по умолчанию (`englishReplyCheckLabels` или часть). */
9
+ labels?: Partial<ReplyCheckLabels>;
10
+ }
11
+ /** Неизменное начало запроса: одно на все переписки, из кэша модели. */
12
+ export declare const LINK_REWRITE_INSTRUCTION: string;
13
+ /** Выдуманные ссылки: разрешены адреса из `seenTexts`; лишние — одна переписка, остаток вырезается кодом. */
14
+ export declare function unknownLinks(options?: UnknownLinksOptions): ReplyCheck;
package/package.json CHANGED
@@ -1,6 +1,39 @@
1
1
  {
2
2
  "name": "@ykurilov/agent-chat-reply-check",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.56.0",
4
+ "license": "MIT",
5
+ "description": "Проверки ответа агента для replyChecks службы чата: чужие слова и выдуманные ссылки.",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "files": [
9
+ "dist",
10
+ "README.md",
11
+ "CHANGELOG.md"
12
+ ],
13
+ "exports": {
14
+ ".": {
15
+ "@ykurilov/agent-chat-source": "./src/index.ts",
16
+ "types": "./dist/types/index.d.ts",
17
+ "import": "./dist/index.js"
18
+ }
19
+ },
20
+ "engines": {
21
+ "node": ">=22"
22
+ },
23
+ "publishConfig": {
24
+ "registry": "https://registry.npmjs.org",
25
+ "access": "public"
26
+ },
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/ykurilov/agent-chat-sdk.git",
30
+ "directory": "packages/reply-check"
31
+ },
32
+ "dependencies": {
33
+ "@ykurilov/agent-chat-core": "0.56.0"
34
+ },
35
+ "scripts": {
36
+ "build": "node ../../scripts/build-package.mjs",
37
+ "typecheck": "tsc -p tsconfig.json"
38
+ }
6
39
  }