@folio-agent/handler 0.6.2 → 0.7.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/dist/chat/answer/contract.d.ts +17 -0
- package/dist/chat/answer/contract.js +37 -0
- package/dist/chat/answer/fallback.d.ts +7 -0
- package/dist/chat/answer/fallback.js +43 -0
- package/dist/chat/gemini.d.ts +7 -1
- package/dist/chat/gemini.js +10 -3
- package/dist/chat/graph.d.ts +19 -4
- package/dist/chat/graph.js +36 -6
- package/dist/chat/handler.js +2 -1
- package/dist/chat/log.d.ts +2 -1
- package/dist/chat/log.js +7 -0
- package/dist/chat/prompt/answer-format.d.ts +1 -0
- package/dist/chat/prompt/answer-format.js +10 -3
- package/dist/chat/prompt/correction.d.ts +3 -0
- package/dist/chat/prompt/correction.js +25 -0
- package/dist/chat/prompt/index.js +5 -2
- package/dist/chat/types.d.ts +12 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/ingest/format.d.ts +6 -3
- package/dist/ingest/format.js +43 -4
- package/migrations/0002_answer_violations.sql +10 -0
- package/package.json +1 -1
- package/dist/chat/answer/normalize.d.ts +0 -7
- package/dist/chat/answer/normalize.js +0 -48
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { AnswerLink } from "./links.js";
|
|
2
|
+
export declare const LINK_SYNTAX: RegExp;
|
|
3
|
+
export declare const BARE_URL: RegExp;
|
|
4
|
+
export type AnswerViolationKind = "disallowed_link" | "bare_url" | "markdown";
|
|
5
|
+
export interface AnswerViolation {
|
|
6
|
+
kind: AnswerViolationKind;
|
|
7
|
+
/** The offending part of the answer, quoted back to the model when asking it to correct. */
|
|
8
|
+
excerpt: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function findAllowedLink(url: string, links: AnswerLink[]): AnswerLink | undefined;
|
|
11
|
+
/**
|
|
12
|
+
* Deterministic cleanups too trivial to send back to the model: surrounding whitespace, trailing
|
|
13
|
+
* spaces on lines, and runs of blank lines.
|
|
14
|
+
*/
|
|
15
|
+
export declare function tidyAnswer(answer: string): string;
|
|
16
|
+
/** Returns what in `answer` breaks the answer format; an empty list means it complies. */
|
|
17
|
+
export declare function findAnswerFormatViolations(answer: string, links: AnswerLink[]): AnswerViolation[];
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// 回答の形式(handler と widget のあいだの決まり)を、検査できる形で書いたもの。
|
|
2
|
+
// - プレーンテキスト。段落は空行1つで区切る
|
|
3
|
+
// - 記法はリンクの [表示する文字](URL) だけ。URL は answerLinks にあるものだけ
|
|
4
|
+
// - URL をそのまま見せない
|
|
5
|
+
// check_answer がこれで違反を探し、違反があれば LLM に指摘して作り直させる。
|
|
6
|
+
export const LINK_SYNTAX = /\[([^\]\n]+)\]\(([^)\n]*)\)/g;
|
|
7
|
+
export const BARE_URL = /https?:\/\/[^\s<>"'()「」『』【】、。]+/g;
|
|
8
|
+
function linkKey(url) {
|
|
9
|
+
return url.trim().replace(/\/+$/, "");
|
|
10
|
+
}
|
|
11
|
+
export function findAllowedLink(url, links) {
|
|
12
|
+
return links.find((link) => linkKey(link.url) === linkKey(url));
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Deterministic cleanups too trivial to send back to the model: surrounding whitespace, trailing
|
|
16
|
+
* spaces on lines, and runs of blank lines.
|
|
17
|
+
*/
|
|
18
|
+
export function tidyAnswer(answer) {
|
|
19
|
+
return answer.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
20
|
+
}
|
|
21
|
+
/** Returns what in `answer` breaks the answer format; an empty list means it complies. */
|
|
22
|
+
export function findAnswerFormatViolations(answer, links) {
|
|
23
|
+
const violations = [];
|
|
24
|
+
// リンクは表示する文字に置き換えてから残りを調べる(空にすると、後ろの文字が行頭にあるように見える)
|
|
25
|
+
const rest = answer.replace(LINK_SYNTAX, (whole, label, url) => {
|
|
26
|
+
if (!findAllowedLink(url, links))
|
|
27
|
+
violations.push({ kind: "disallowed_link", excerpt: whole });
|
|
28
|
+
return label;
|
|
29
|
+
});
|
|
30
|
+
for (const match of rest.matchAll(BARE_URL))
|
|
31
|
+
violations.push({ kind: "bare_url", excerpt: match[0] });
|
|
32
|
+
for (const line of rest.split("\n")) {
|
|
33
|
+
if (/\*\*|__|`|^[ \t]*#{1,6}[ \t]|^[ \t]*[-*][ \t]/.test(line))
|
|
34
|
+
violations.push({ kind: "markdown", excerpt: line.trim() });
|
|
35
|
+
}
|
|
36
|
+
return violations;
|
|
37
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { AnswerLink } from "./links.js";
|
|
2
|
+
/**
|
|
3
|
+
* The last resort when the model still breaks the answer format after being asked to correct it.
|
|
4
|
+
* Keeps only allowed links, reduces every other link to its text, drops bare URLs and Markdown
|
|
5
|
+
* markers. It never asks the model again, so its output always complies.
|
|
6
|
+
*/
|
|
7
|
+
export declare function toPlainAnswer(answer: string, links?: AnswerLink[]): string;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { BARE_URL, findAllowedLink, LINK_SYNTAX, tidyAnswer } from "./contract.js";
|
|
2
|
+
const TRAILING_PUNCTUATION = /[.,;:!?)\]]+$/;
|
|
3
|
+
const PLACEHOLDER = /\u0000(\d+)\u0000/g;
|
|
4
|
+
function stripInlineMarkdown(text) {
|
|
5
|
+
return text
|
|
6
|
+
.replace(/\*\*(.+?)\*\*/g, "$1")
|
|
7
|
+
.replace(/__(.+?)__/g, "$1")
|
|
8
|
+
.replace(/`([^`\n]+)`/g, "$1")
|
|
9
|
+
.replace(/\*\*|__|`/g, "");
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The last resort when the model still breaks the answer format after being asked to correct it.
|
|
13
|
+
* Keeps only allowed links, reduces every other link to its text, drops bare URLs and Markdown
|
|
14
|
+
* markers. It never asks the model again, so its output always complies.
|
|
15
|
+
*/
|
|
16
|
+
export function toPlainAnswer(answer, links = []) {
|
|
17
|
+
const kept = [];
|
|
18
|
+
const keep = (label, url) => {
|
|
19
|
+
kept.push(`[${stripInlineMarkdown(label)}](${url})`);
|
|
20
|
+
return `\u0000${kept.length - 1}\u0000`;
|
|
21
|
+
};
|
|
22
|
+
// リンク先の形は問わず、すべてのリンク記法を拾う。許可されたもの以外(相対パス・mailto: なども)は文字だけにする
|
|
23
|
+
let text = answer.replace(LINK_SYNTAX, (_, label, url) => {
|
|
24
|
+
const link = findAllowedLink(url.trim(), links);
|
|
25
|
+
return link ? keep(label, link.url) : label;
|
|
26
|
+
});
|
|
27
|
+
// URL をそのまま書かれた場合は、許可されたものだけタイトルのリンクに置き換え、それ以外は消す
|
|
28
|
+
text = text.replace(BARE_URL, (match) => {
|
|
29
|
+
const url = match.replace(TRAILING_PUNCTUATION, "");
|
|
30
|
+
const rest = match.slice(url.length);
|
|
31
|
+
const link = findAllowedLink(url, links);
|
|
32
|
+
return link ? keep(link.title, link.url) + rest : rest;
|
|
33
|
+
});
|
|
34
|
+
// 消した URL の跡の空括弧を先に取る。後に回すと、括弧の後ろにあった見出しやリストの記号が行頭に出てくる
|
|
35
|
+
text = stripInlineMarkdown(text.replace(/(\s*)|\(\s*\)/g, ""))
|
|
36
|
+
.replace(/^[ \t]*(?:#{1,6}[ \t]+)+/gm, "")
|
|
37
|
+
.replace(/^[ \t]*(?:[-*][ \t]+)+/gm, "・")
|
|
38
|
+
// この時点で残る "](" は、閉じていないリンク記法などの切れ端。残すと、戻したリンクとつながって
|
|
39
|
+
// 許可していないリンク記法ができてしまう
|
|
40
|
+
.replace(/\]\(/g, "] (")
|
|
41
|
+
.replace(PLACEHOLDER, (_, index) => kept[Number(index)]);
|
|
42
|
+
return tidyAnswer(text);
|
|
43
|
+
}
|
package/dist/chat/gemini.d.ts
CHANGED
|
@@ -6,5 +6,11 @@ export interface GeminiGeneratorConfig {
|
|
|
6
6
|
contactUrl?: string;
|
|
7
7
|
language?: Language;
|
|
8
8
|
}
|
|
9
|
-
|
|
9
|
+
/** Passed when check_answer found the previous answer breaking the answer format. */
|
|
10
|
+
export interface AnswerCorrection {
|
|
11
|
+
previousAnswer: string;
|
|
12
|
+
/** What to fix, written for the model (see prompt/correction.ts). */
|
|
13
|
+
request: string;
|
|
14
|
+
}
|
|
15
|
+
export type GenerateAnswerFn = (input: string, route: ChatRoute, correction?: AnswerCorrection) => Promise<string>;
|
|
10
16
|
export declare function createGeminiGenerator(config: GeminiGeneratorConfig): GenerateAnswerFn;
|
package/dist/chat/gemini.js
CHANGED
|
@@ -3,15 +3,22 @@ import { buildSystemPrompt } from "./prompt/index.js";
|
|
|
3
3
|
import { DEFAULT_LANGUAGE } from "./types.js";
|
|
4
4
|
export function createGeminiGenerator(config) {
|
|
5
5
|
const client = new GoogleGenAI({ apiKey: config.apiKey });
|
|
6
|
-
const model = config.model ?? "gemini-3.
|
|
6
|
+
const model = config.model ?? "gemini-3.5-flash-lite";
|
|
7
7
|
const language = config.language ?? DEFAULT_LANGUAGE;
|
|
8
|
-
return async (input, route) => {
|
|
8
|
+
return async (input, route, correction) => {
|
|
9
9
|
if (route === "rate_limited") {
|
|
10
10
|
throw new Error("generateAnswer should not be called for a rate-limited route");
|
|
11
11
|
}
|
|
12
12
|
const response = await client.models.generateContent({
|
|
13
13
|
model,
|
|
14
|
-
|
|
14
|
+
// 作り直しでは、元の質問・直前の回答・指摘を会話として渡し、同じ質問への答え直しであることを伝える
|
|
15
|
+
contents: correction
|
|
16
|
+
? [
|
|
17
|
+
{ role: "user", parts: [{ text: input }] },
|
|
18
|
+
{ role: "model", parts: [{ text: correction.previousAnswer }] },
|
|
19
|
+
{ role: "user", parts: [{ text: correction.request }] },
|
|
20
|
+
]
|
|
21
|
+
: input,
|
|
15
22
|
config: { systemInstruction: buildSystemPrompt(config.knowledge, route, config.contactUrl, language) },
|
|
16
23
|
});
|
|
17
24
|
return response.text ?? "";
|
package/dist/chat/graph.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AnswerViolation } from "./answer/contract.js";
|
|
1
2
|
import type { ChatGraphDeps, ChatRoute } from "./types.js";
|
|
2
3
|
export declare function buildChatGraph(deps: ChatGraphDeps): import("@langchain/langgraph").CompiledStateGraph<{
|
|
3
4
|
input: string;
|
|
@@ -5,26 +6,43 @@ export declare function buildChatGraph(deps: ChatGraphDeps): import("@langchain/
|
|
|
5
6
|
route: ChatRoute | undefined;
|
|
6
7
|
overLimit: boolean;
|
|
7
8
|
answer: string | undefined;
|
|
9
|
+
attempt: number;
|
|
10
|
+
violations: AnswerViolation[];
|
|
8
11
|
}, {
|
|
9
12
|
input?: string | undefined;
|
|
10
13
|
ip?: string | undefined;
|
|
11
14
|
route?: ChatRoute | undefined;
|
|
12
15
|
overLimit?: boolean | undefined;
|
|
13
16
|
answer?: string | undefined;
|
|
14
|
-
|
|
17
|
+
attempt?: number | undefined;
|
|
18
|
+
violations?: AnswerViolation[] | undefined;
|
|
19
|
+
}, "__start__" | "check_answer" | "fallback" | "generate" | "input_guard" | "log" | "route_message", {
|
|
15
20
|
input: import("@langchain/langgraph").LastValue<string>;
|
|
16
21
|
ip: import("@langchain/langgraph").LastValue<string>;
|
|
17
22
|
route: import("@langchain/langgraph").LastValue<ChatRoute | undefined>;
|
|
18
23
|
overLimit: import("@langchain/langgraph").LastValue<boolean>;
|
|
19
24
|
answer: import("@langchain/langgraph").LastValue<string | undefined>;
|
|
25
|
+
attempt: import("@langchain/langgraph").LastValue<number>;
|
|
26
|
+
violations: import("@langchain/langgraph").LastValue<AnswerViolation[]>;
|
|
20
27
|
}, {
|
|
21
28
|
input: import("@langchain/langgraph").LastValue<string>;
|
|
22
29
|
ip: import("@langchain/langgraph").LastValue<string>;
|
|
23
30
|
route: import("@langchain/langgraph").LastValue<ChatRoute | undefined>;
|
|
24
31
|
overLimit: import("@langchain/langgraph").LastValue<boolean>;
|
|
25
32
|
answer: import("@langchain/langgraph").LastValue<string | undefined>;
|
|
33
|
+
attempt: import("@langchain/langgraph").LastValue<number>;
|
|
34
|
+
violations: import("@langchain/langgraph").LastValue<AnswerViolation[]>;
|
|
26
35
|
}, import("@langchain/langgraph").StateDefinition, {
|
|
36
|
+
check_answer: {
|
|
37
|
+
answer: string;
|
|
38
|
+
violations: AnswerViolation[];
|
|
39
|
+
};
|
|
40
|
+
fallback: {
|
|
41
|
+
answer: string;
|
|
42
|
+
violations: never[];
|
|
43
|
+
};
|
|
27
44
|
generate: {
|
|
45
|
+
attempt: number;
|
|
28
46
|
answer: string;
|
|
29
47
|
};
|
|
30
48
|
input_guard: {
|
|
@@ -35,9 +53,6 @@ export declare function buildChatGraph(deps: ChatGraphDeps): import("@langchain/
|
|
|
35
53
|
overLimit: false;
|
|
36
54
|
};
|
|
37
55
|
log: {};
|
|
38
|
-
normalize_answer: {
|
|
39
|
-
answer: string;
|
|
40
|
-
};
|
|
41
56
|
route_message: {
|
|
42
57
|
route: "inquiry" | "thoughts" | "works";
|
|
43
58
|
};
|
package/dist/chat/graph.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Annotation, END, START, StateGraph } from "@langchain/langgraph";
|
|
2
|
-
import {
|
|
2
|
+
import { findAnswerFormatViolations, tidyAnswer } from "./answer/contract.js";
|
|
3
|
+
import { toPlainAnswer } from "./answer/fallback.js";
|
|
4
|
+
import { buildCorrectionRequest } from "./prompt/correction.js";
|
|
3
5
|
import { classifyRoute } from "./route.js";
|
|
4
6
|
import { DEFAULT_LANGUAGE } from "./types.js";
|
|
5
7
|
const OVER_LIMIT_MESSAGE = {
|
|
@@ -14,12 +16,16 @@ const GENERATION_FAILED_MESSAGE = {
|
|
|
14
16
|
ja: "本日の受付上限に達したか、一時的な不具合が発生しています。恐れ入りますがContactからお問い合わせください。",
|
|
15
17
|
en: "You may have reached today's limit, or a temporary issue has occurred. Please contact us via the Contact page.",
|
|
16
18
|
};
|
|
19
|
+
// 1回目の回答と、指摘して作り直させた回答の2回まで。無料枠の回数の上限があるので、それ以上は呼ばない
|
|
20
|
+
const MAX_ATTEMPTS = 2;
|
|
17
21
|
const ChatState = Annotation.Root({
|
|
18
22
|
input: Annotation(),
|
|
19
23
|
ip: Annotation(),
|
|
20
24
|
route: Annotation(),
|
|
21
25
|
overLimit: Annotation(),
|
|
22
26
|
answer: Annotation(),
|
|
27
|
+
attempt: Annotation(),
|
|
28
|
+
violations: Annotation(),
|
|
23
29
|
});
|
|
24
30
|
export function buildChatGraph(deps) {
|
|
25
31
|
const language = deps.language ?? DEFAULT_LANGUAGE;
|
|
@@ -36,15 +42,38 @@ export function buildChatGraph(deps) {
|
|
|
36
42
|
})
|
|
37
43
|
.addNode("route_message", async (state) => ({ route: classifyRoute(state.input, language) }))
|
|
38
44
|
.addNode("generate", async (state) => {
|
|
45
|
+
const attempt = (state.attempt ?? 0) + 1;
|
|
46
|
+
const correction = state.violations?.length && state.answer !== undefined
|
|
47
|
+
? { previousAnswer: state.answer, request: buildCorrectionRequest(state.violations, language) }
|
|
48
|
+
: undefined;
|
|
39
49
|
try {
|
|
40
|
-
return { answer: await deps.generateAnswer(state.input, state.route) };
|
|
50
|
+
return { attempt, answer: await deps.generateAnswer(state.input, state.route, correction) };
|
|
41
51
|
}
|
|
42
52
|
catch (error) {
|
|
43
53
|
console.error("generateAnswer failed", error);
|
|
44
|
-
return { answer: GENERATION_FAILED_MESSAGE[language] };
|
|
54
|
+
return { attempt, answer: GENERATION_FAILED_MESSAGE[language] };
|
|
45
55
|
}
|
|
46
56
|
})
|
|
47
|
-
.addNode("
|
|
57
|
+
.addNode("check_answer", async (state) => {
|
|
58
|
+
const answer = tidyAnswer(state.answer ?? "");
|
|
59
|
+
const violations = findAnswerFormatViolations(answer, deps.answerLinks ?? []);
|
|
60
|
+
if (violations.length > 0 && deps.logAnswerViolation) {
|
|
61
|
+
try {
|
|
62
|
+
await deps.logAnswerViolation({
|
|
63
|
+
route: state.route,
|
|
64
|
+
attempt: state.attempt,
|
|
65
|
+
kinds: [...new Set(violations.map((violation) => violation.kind))],
|
|
66
|
+
answer,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
// 記録に失敗しても(migration 未適用など)訪問者への回答は止めない
|
|
71
|
+
console.error("logAnswerViolation failed", error);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return { answer, violations };
|
|
75
|
+
})
|
|
76
|
+
.addNode("fallback", async (state) => ({ answer: toPlainAnswer(state.answer ?? "", deps.answerLinks), violations: [] }))
|
|
48
77
|
.addNode("log", async (state) => {
|
|
49
78
|
await deps.logChat({
|
|
50
79
|
ip: state.ip,
|
|
@@ -61,8 +90,9 @@ export function buildChatGraph(deps) {
|
|
|
61
90
|
route_message: "route_message",
|
|
62
91
|
})
|
|
63
92
|
.addEdge("route_message", "generate")
|
|
64
|
-
.addEdge("generate", "
|
|
65
|
-
.
|
|
93
|
+
.addEdge("generate", "check_answer")
|
|
94
|
+
.addConditionalEdges("check_answer", (state) => (state.violations.length === 0 ? "log" : state.attempt < MAX_ATTEMPTS ? "generate" : "fallback"), { log: "log", generate: "generate", fallback: "fallback" })
|
|
95
|
+
.addEdge("fallback", "log")
|
|
66
96
|
.addEdge("log", END);
|
|
67
97
|
return graph.compile();
|
|
68
98
|
}
|
package/dist/chat/handler.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { buildChatGraph } from "./graph.js";
|
|
2
|
-
import { logChat } from "./log.js";
|
|
2
|
+
import { logAnswerViolation, logChat } from "./log.js";
|
|
3
3
|
import { checkRateLimit } from "./rate-limit.js";
|
|
4
4
|
import { DEFAULT_RATE_LIMIT_CONFIG } from "./types.js";
|
|
5
5
|
const MAX_INPUT_LENGTH = 1000;
|
|
@@ -12,6 +12,7 @@ export function createChatHandler(config) {
|
|
|
12
12
|
rateLimitConfig,
|
|
13
13
|
language: config.language,
|
|
14
14
|
answerLinks: config.answerLinks,
|
|
15
|
+
logAnswerViolation: (entry) => logAnswerViolation(config.db, entry),
|
|
15
16
|
});
|
|
16
17
|
return async (request) => {
|
|
17
18
|
let body;
|
package/dist/chat/log.d.ts
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
|
-
import type { ChatLogEntry } from "./types.js";
|
|
1
|
+
import type { AnswerViolationLogEntry, ChatLogEntry } from "./types.js";
|
|
2
2
|
export declare function logChat(db: D1Database, entry: ChatLogEntry, now?: Date): Promise<void>;
|
|
3
|
+
export declare function logAnswerViolation(db: D1Database, entry: AnswerViolationLogEntry, now?: Date): Promise<void>;
|
package/dist/chat/log.js
CHANGED
|
@@ -4,3 +4,10 @@ export async function logChat(db, entry, now = new Date()) {
|
|
|
4
4
|
.bind(now.toISOString(), entry.ip, entry.route, entry.message, entry.response, entry.overLimit ? 1 : 0)
|
|
5
5
|
.run();
|
|
6
6
|
}
|
|
7
|
+
// 訪問者の入力は残さない。集計したいのは LLM がどう間違えたかで、誰が何を聞いたかではない
|
|
8
|
+
export async function logAnswerViolation(db, entry, now = new Date()) {
|
|
9
|
+
await db
|
|
10
|
+
.prepare("INSERT INTO answer_violations (created_at, route, attempt, kinds, answer) VALUES (?, ?, ?, ?, ?)")
|
|
11
|
+
.bind(now.toISOString(), entry.route, entry.attempt, JSON.stringify(entry.kinds), entry.answer)
|
|
12
|
+
.run();
|
|
13
|
+
}
|
|
@@ -1,15 +1,22 @@
|
|
|
1
|
-
// 回答の形式(handler と widget のあいだの決まり)。指示で頼む側で、守らせるのは answer/
|
|
1
|
+
// 回答の形式(handler と widget のあいだの決まり)。指示で頼む側で、守らせるのは answer/contract.ts と check_answer。
|
|
2
2
|
export const ANSWER_FORMAT = {
|
|
3
3
|
ja: "Markdown記法(`**`・`#`・`-`のリストなど)は使わず、プレーンテキストで答えてください。" +
|
|
4
4
|
"回答が長くなる場合は、2〜4文ごとに空行を挟んで段落を分けてください。\n\n" +
|
|
5
5
|
"リンクを示すときだけは [表示する文字](URL) の形で書いてください。URLをそのまま書かず、" +
|
|
6
6
|
"記事ならそのタイトル、Contactページなら「Contactページ」を表示する文字にします。" +
|
|
7
|
-
"
|
|
7
|
+
"訪問者はいまこのサイトを見ているので、知識で「(このサイトのページ)」と付いたページはリンクにせず、「Aboutページ」のようにページ名で案内してください。" +
|
|
8
8
|
"リンクにしてよいのは、Contactページと、知識に「URL: https://…」として書かれたこのサイトの外のページだけです。",
|
|
9
9
|
en: "Do not use Markdown formatting (such as `**`, `#`, or `-` lists) — answer in plain text. " +
|
|
10
10
|
"If the answer runs long, insert a blank line every 2-4 sentences to break it into paragraphs.\n\n" +
|
|
11
11
|
"The one exception is links: write them as [text to show](URL). Never show a bare URL; " +
|
|
12
12
|
'use the article title for an article, and "Contact page" for the Contact page. ' +
|
|
13
|
-
"The visitor is already on this site, so refer to this site
|
|
13
|
+
"The visitor is already on this site, so refer to pages marked \"(a page on this site)\" in the knowledge by name (e.g. \"the About page\") instead of linking them. " +
|
|
14
14
|
'Only link the Contact page and pages outside this site that appear in the knowledge as "URL: https://…".',
|
|
15
15
|
};
|
|
16
|
+
// Contact は経路に関係なく案内されうる。URL を渡さないと LLM は /contact のような URL を作ってしまうので、
|
|
17
|
+
// 書き方の見本をそのまま渡す
|
|
18
|
+
export function buildContactLinkExample(language, contactUrl) {
|
|
19
|
+
return language === "en"
|
|
20
|
+
? `When pointing to the Contact page, write it exactly like this: [Contact page](${contactUrl})`
|
|
21
|
+
: `Contactページを案内するときは、次の書き方をそのまま使ってください: [Contactページ](${contactUrl})`;
|
|
22
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// check_answer が違反を見つけたとき、LLM に作り直させるための指摘文。
|
|
2
|
+
const KIND_TEXT = {
|
|
3
|
+
ja: {
|
|
4
|
+
disallowed_link: "リンクにしてはいけない先へのリンクがあります。このサイト内のページはリンクにせずページ名で案内し、" +
|
|
5
|
+
"リンクはContactページと知識に「URL: https://…」として書かれたページだけにしてください",
|
|
6
|
+
bare_url: "URLがそのまま書かれています。[表示する文字](URL) の形にし、記事ならタイトルを表示する文字にしてください",
|
|
7
|
+
markdown: "リンク以外のMarkdown記法(**・#・- のリスト・`)が使われています。プレーンテキストにしてください",
|
|
8
|
+
},
|
|
9
|
+
en: {
|
|
10
|
+
disallowed_link: "It links to a destination that must not be linked. Refer to this site's pages by name, and only link the " +
|
|
11
|
+
'Contact page and pages that appear in the knowledge as "URL: https://…"',
|
|
12
|
+
bare_url: "It shows a bare URL. Write it as [text to show](URL), using the title for an article",
|
|
13
|
+
markdown: "It uses Markdown other than links (**, #, - lists, `). Use plain text",
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
const INTRO = {
|
|
17
|
+
ja: "直前の回答は、回答の形式の決まりに次の点で合っていません。内容は変えずに、決まりに合わせて回答だけを書き直してください。" +
|
|
18
|
+
"書き直したことは訪問者には見えないので、謝ったり直したことに触れたりせず、訪問者への回答だけを書いてください。",
|
|
19
|
+
en: "Your previous answer breaks the answer format in the following ways. Keep the content and rewrite only the answer so it complies. " +
|
|
20
|
+
"The visitor never sees this correction, so do not apologize or mention it; write only the answer to the visitor.",
|
|
21
|
+
};
|
|
22
|
+
export function buildCorrectionRequest(violations, language) {
|
|
23
|
+
const lines = violations.map((violation) => `- ${KIND_TEXT[language][violation.kind]}: ${violation.excerpt}`);
|
|
24
|
+
return [INTRO[language], ...lines].join("\n");
|
|
25
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { DEFAULT_LANGUAGE } from "../types.js";
|
|
2
|
-
import { ANSWER_FORMAT } from "./answer-format.js";
|
|
2
|
+
import { ANSWER_FORMAT, buildContactLinkExample } from "./answer-format.js";
|
|
3
3
|
import { GROUNDING } from "./grounding.js";
|
|
4
4
|
import { PERSONA } from "./persona.js";
|
|
5
5
|
import { buildInquiryInstruction, ROUTE_INSTRUCTIONS } from "./routes.js";
|
|
@@ -9,5 +9,8 @@ const KNOWLEDGE_LABEL = {
|
|
|
9
9
|
};
|
|
10
10
|
export function buildSystemPrompt(knowledge, route, contactUrl, language = DEFAULT_LANGUAGE) {
|
|
11
11
|
const routeInstruction = route === "inquiry" ? buildInquiryInstruction(language, contactUrl) : ROUTE_INSTRUCTIONS[language][route];
|
|
12
|
-
|
|
12
|
+
const answerFormat = contactUrl
|
|
13
|
+
? `${ANSWER_FORMAT[language]}\n${buildContactLinkExample(language, contactUrl)}`
|
|
14
|
+
: ANSWER_FORMAT[language];
|
|
15
|
+
return [PERSONA[language], GROUNDING[language], answerFormat, routeInstruction, KNOWLEDGE_LABEL[language], knowledge].join("\n\n");
|
|
13
16
|
}
|
package/dist/chat/types.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import type { AnswerViolationKind } from "./answer/contract.js";
|
|
1
2
|
import type { AnswerLink } from "./answer/links.js";
|
|
3
|
+
import type { AnswerCorrection } from "./gemini.js";
|
|
2
4
|
export type ChatRoute = "thoughts" | "works" | "inquiry" | "rate_limited";
|
|
3
5
|
export type Language = "ja" | "en";
|
|
4
6
|
export declare const DEFAULT_LANGUAGE: Language;
|
|
@@ -23,10 +25,19 @@ export interface ChatLogEntry {
|
|
|
23
25
|
}
|
|
24
26
|
export interface ChatGraphDeps {
|
|
25
27
|
checkRateLimit: (ip: string) => Promise<RateLimitResult>;
|
|
26
|
-
generateAnswer: (input: string, route: ChatRoute) => Promise<string>;
|
|
28
|
+
generateAnswer: (input: string, route: ChatRoute, correction?: AnswerCorrection) => Promise<string>;
|
|
27
29
|
logChat: (entry: ChatLogEntry) => Promise<void>;
|
|
28
30
|
rateLimitConfig: RateLimitConfig;
|
|
29
31
|
language?: Language;
|
|
30
32
|
/** What answers may link to. Links to anything else are reduced to their text. */
|
|
31
33
|
answerLinks?: AnswerLink[];
|
|
34
|
+
/** Records answers that broke the answer format, to see which mistakes are common. */
|
|
35
|
+
logAnswerViolation?: (entry: AnswerViolationLogEntry) => Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
export interface AnswerViolationLogEntry {
|
|
38
|
+
route: ChatRoute;
|
|
39
|
+
/** 1 for the first answer, 2 for the answer after one correction. */
|
|
40
|
+
attempt: number;
|
|
41
|
+
kinds: AnswerViolationKind[];
|
|
42
|
+
answer: string;
|
|
32
43
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -11,8 +11,8 @@ export { createGeminiGenerator } from "./chat/gemini.js";
|
|
|
11
11
|
export { buildSystemPrompt } from "./chat/prompt/index.js";
|
|
12
12
|
export { collectAnswerLinks } from "./chat/answer/links.js";
|
|
13
13
|
export type { AnswerLink } from "./chat/answer/links.js";
|
|
14
|
-
export type { GenerateAnswerFn, GeminiGeneratorConfig } from "./chat/gemini.js";
|
|
14
|
+
export type { AnswerCorrection, GenerateAnswerFn, GeminiGeneratorConfig } from "./chat/gemini.js";
|
|
15
15
|
export { classifyRoute } from "./chat/route.js";
|
|
16
|
-
export { logChat } from "./chat/log.js";
|
|
17
|
-
export type { ChatRoute, ChatGraphDeps, ChatLogEntry, RateLimitConfig, RateLimitResult, RateLimitReason, } from "./chat/types.js";
|
|
16
|
+
export { logAnswerViolation, logChat } from "./chat/log.js";
|
|
17
|
+
export type { ChatRoute, ChatGraphDeps, AnswerViolationLogEntry, ChatLogEntry, RateLimitConfig, RateLimitResult, RateLimitReason, } from "./chat/types.js";
|
|
18
18
|
export { DEFAULT_RATE_LIMIT_CONFIG } from "./chat/types.js";
|
package/dist/index.js
CHANGED
|
@@ -9,5 +9,5 @@ export { createGeminiGenerator } from "./chat/gemini.js";
|
|
|
9
9
|
export { buildSystemPrompt } from "./chat/prompt/index.js";
|
|
10
10
|
export { collectAnswerLinks } from "./chat/answer/links.js";
|
|
11
11
|
export { classifyRoute } from "./chat/route.js";
|
|
12
|
-
export { logChat } from "./chat/log.js";
|
|
12
|
+
export { logAnswerViolation, logChat } from "./chat/log.js";
|
|
13
13
|
export { DEFAULT_RATE_LIMIT_CONFIG } from "./chat/types.js";
|
package/dist/ingest/format.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import type { Language } from "../chat/types.js";
|
|
1
2
|
import type { KnowledgePagesLike } from "./types.js";
|
|
2
3
|
/**
|
|
3
|
-
* Turns a knowledge document into the text handed to the system prompt.
|
|
4
|
-
* title and URL so answers can
|
|
4
|
+
* Turns a knowledge document into the text handed to the system prompt. Pages outside this site
|
|
5
|
+
* (such as Zenn articles) carry their title and URL, so answers can link them by title. This
|
|
6
|
+
* site's pages carry only their name, without the site-name suffix and without a URL: the visitor
|
|
7
|
+
* is already on the site, and handing the model a URL invites it to link the page anyway.
|
|
5
8
|
*/
|
|
6
|
-
export declare function formatKnowledge(doc: KnowledgePagesLike): string;
|
|
9
|
+
export declare function formatKnowledge(doc: KnowledgePagesLike, language?: Language): string;
|
package/dist/ingest/format.js
CHANGED
|
@@ -1,7 +1,46 @@
|
|
|
1
|
+
import { DEFAULT_LANGUAGE } from "../chat/types.js";
|
|
2
|
+
const SITE_PAGE_LABEL = {
|
|
3
|
+
ja: "(このサイトのページ)",
|
|
4
|
+
en: " (a page on this site)",
|
|
5
|
+
};
|
|
6
|
+
const TITLE_WITH_SUFFIX = /^(.+?)\s+[•||\-–—:]\s+(.+)$/;
|
|
7
|
+
function isExternal(url) {
|
|
8
|
+
return /^https?:\/\//i.test(url);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Finds the site-name suffix that this site's page titles share ("Works • ykts.net" and
|
|
12
|
+
* "About • ykts.net" share " • ykts.net"). A suffix seen on only one page isn't treated as the
|
|
13
|
+
* site name, since it may be part of that page's own title.
|
|
14
|
+
*/
|
|
15
|
+
function sharedTitleSuffix(titles) {
|
|
16
|
+
const counts = new Map();
|
|
17
|
+
for (const title of titles) {
|
|
18
|
+
const suffix = TITLE_WITH_SUFFIX.exec(title)?.[2];
|
|
19
|
+
if (suffix)
|
|
20
|
+
counts.set(suffix, (counts.get(suffix) ?? 0) + 1);
|
|
21
|
+
}
|
|
22
|
+
let best;
|
|
23
|
+
for (const [suffix, count] of counts) {
|
|
24
|
+
if (count >= 2 && count > (best ? (counts.get(best) ?? 0) : 0))
|
|
25
|
+
best = suffix;
|
|
26
|
+
}
|
|
27
|
+
return best;
|
|
28
|
+
}
|
|
1
29
|
/**
|
|
2
|
-
* Turns a knowledge document into the text handed to the system prompt.
|
|
3
|
-
* title and URL so answers can
|
|
30
|
+
* Turns a knowledge document into the text handed to the system prompt. Pages outside this site
|
|
31
|
+
* (such as Zenn articles) carry their title and URL, so answers can link them by title. This
|
|
32
|
+
* site's pages carry only their name, without the site-name suffix and without a URL: the visitor
|
|
33
|
+
* is already on the site, and handing the model a URL invites it to link the page anyway.
|
|
4
34
|
*/
|
|
5
|
-
export function formatKnowledge(doc) {
|
|
6
|
-
|
|
35
|
+
export function formatKnowledge(doc, language = DEFAULT_LANGUAGE) {
|
|
36
|
+
const suffix = sharedTitleSuffix(doc.pages.filter((page) => !isExternal(page.url)).map((page) => page.title));
|
|
37
|
+
return doc.pages
|
|
38
|
+
.map((page) => {
|
|
39
|
+
if (isExternal(page.url))
|
|
40
|
+
return `# ${page.title}\nURL: ${page.url}\n\n${page.text}`;
|
|
41
|
+
const match = TITLE_WITH_SUFFIX.exec(page.title);
|
|
42
|
+
const name = suffix && match?.[2] === suffix ? match[1] : page.title;
|
|
43
|
+
return `# ${name}${SITE_PAGE_LABEL[language]}\n\n${page.text}`;
|
|
44
|
+
})
|
|
45
|
+
.join("\n\n");
|
|
7
46
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
-- LLM の回答が回答の形式の決まりを破った記録。どの間違いが多いかを集計し、
|
|
2
|
+
-- 多いものだけをプロンプトやコードで手当てするために使う。訪問者の入力は持たない。
|
|
3
|
+
CREATE TABLE IF NOT EXISTS answer_violations (
|
|
4
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
5
|
+
created_at TEXT NOT NULL,
|
|
6
|
+
route TEXT NOT NULL,
|
|
7
|
+
attempt INTEGER NOT NULL,
|
|
8
|
+
kinds TEXT NOT NULL,
|
|
9
|
+
answer TEXT NOT NULL
|
|
10
|
+
);
|
package/package.json
CHANGED
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
import type { AnswerLink } from "./links.js";
|
|
2
|
-
/**
|
|
3
|
-
* Brings a generated answer into the answer format, whatever the model actually produced. The
|
|
4
|
-
* prompt only asks for the format (prompt/answer-format.ts); this is where it is enforced, so the
|
|
5
|
-
* widget can rely on it.
|
|
6
|
-
*/
|
|
7
|
-
export declare function normalizeAnswer(answer: string, links?: AnswerLink[]): string;
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
// 回答の形式(handler と widget のあいだの決まり):
|
|
2
|
-
// プレーンテキスト。段落は空行で区切る。記法はリンクの [表示する文字](http(s)://…) だけ。
|
|
3
|
-
// リンク先は answerLinks に含まれる URL だけ。
|
|
4
|
-
const MARKDOWN_LINK = /\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g;
|
|
5
|
-
const BARE_URL = /https?:\/\/[^\s<>"'()「」『』【】、。]+/g;
|
|
6
|
-
const TRAILING_PUNCTUATION = /[.,;:!?)\]]+$/;
|
|
7
|
-
const PLACEHOLDER = /\u0000(\d+)\u0000/g;
|
|
8
|
-
function linkKey(url) {
|
|
9
|
-
return url.replace(/\/+$/, "");
|
|
10
|
-
}
|
|
11
|
-
function stripInlineMarkdown(text) {
|
|
12
|
-
return text
|
|
13
|
-
.replace(/\*\*(.+?)\*\*/g, "$1")
|
|
14
|
-
.replace(/__(.+?)__/g, "$1")
|
|
15
|
-
.replace(/`([^`\n]+)`/g, "$1");
|
|
16
|
-
}
|
|
17
|
-
/**
|
|
18
|
-
* Brings a generated answer into the answer format, whatever the model actually produced. The
|
|
19
|
-
* prompt only asks for the format (prompt/answer-format.ts); this is where it is enforced, so the
|
|
20
|
-
* widget can rely on it.
|
|
21
|
-
*/
|
|
22
|
-
export function normalizeAnswer(answer, links = []) {
|
|
23
|
-
const allowed = new Map(links.map((link) => [linkKey(link.url), link]));
|
|
24
|
-
const kept = [];
|
|
25
|
-
const keep = (label, url) => {
|
|
26
|
-
kept.push(`[${stripInlineMarkdown(label)}](${url})`);
|
|
27
|
-
return `\u0000${kept.length - 1}\u0000`;
|
|
28
|
-
};
|
|
29
|
-
let text = answer.replace(MARKDOWN_LINK, (_, label, url) => {
|
|
30
|
-
const link = allowed.get(linkKey(url));
|
|
31
|
-
return link ? keep(label, link.url) : label;
|
|
32
|
-
});
|
|
33
|
-
// URL をそのまま書かれた場合は、許可されたものだけタイトルのリンクに置き換え、それ以外は消す
|
|
34
|
-
text = text.replace(BARE_URL, (match) => {
|
|
35
|
-
const url = match.replace(TRAILING_PUNCTUATION, "");
|
|
36
|
-
const rest = match.slice(url.length);
|
|
37
|
-
const link = allowed.get(linkKey(url));
|
|
38
|
-
return link ? keep(link.title, link.url) + rest : rest;
|
|
39
|
-
});
|
|
40
|
-
text = stripInlineMarkdown(text)
|
|
41
|
-
.replace(/^#{1,6}[ \t]+/gm, "")
|
|
42
|
-
.replace(/^[ \t]*[-*][ \t]+/gm, "・")
|
|
43
|
-
.replace(/(\s*)|\(\s*\)/g, "")
|
|
44
|
-
.replace(PLACEHOLDER, (_, index) => kept[Number(index)])
|
|
45
|
-
.replace(/[ \t]+\n/g, "\n")
|
|
46
|
-
.replace(/\n{3,}/g, "\n\n");
|
|
47
|
-
return text.trim();
|
|
48
|
-
}
|