@folio-agent/handler 0.6.1 → 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/README.md CHANGED
@@ -17,15 +17,17 @@ npx folio-agent-ingest folio-agent.config.json knowledge.json
17
17
  ```
18
18
 
19
19
  ```ts
20
- import { createChatHandler, createGeminiGenerator } from "@folio-agent/handler";
20
+ import { collectAnswerLinks, createChatHandler, createGeminiGenerator, formatKnowledge } from "@folio-agent/handler";
21
21
  import knowledgeDoc from "../knowledge.json";
22
22
 
23
- const knowledge = knowledgeDoc.pages.map((p) => `# ${p.url}\n\n${p.text}`).join("\n\n");
23
+ const knowledge = formatKnowledge(knowledgeDoc);
24
+ const answerLinks = collectAnswerLinks(knowledgeDoc);
24
25
 
25
26
  export default {
26
27
  fetch: (request: Request, env: { DB: D1Database; GEMINI_API_KEY: string }) =>
27
28
  createChatHandler({
28
29
  db: env.DB,
30
+ answerLinks,
29
31
  generateAnswer: createGeminiGenerator({ apiKey: env.GEMINI_API_KEY, knowledge }),
30
32
  })(request),
31
33
  };
@@ -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
+ }
@@ -0,0 +1,13 @@
1
+ import type { KnowledgePagesLike } from "../../ingest/types.js";
2
+ import type { Language } from "../types.js";
3
+ /** A destination an answer may link to, and the text to show when the model wrote the bare URL. */
4
+ export interface AnswerLink {
5
+ url: string;
6
+ title: string;
7
+ }
8
+ /**
9
+ * Lists what an answer may link to: pages outside this site that are in the knowledge (such as
10
+ * Zenn articles) and the Contact page. This site's own pages are left out on purpose; the visitor
11
+ * is already on the site, so answers name them instead of linking them.
12
+ */
13
+ export declare function collectAnswerLinks(doc: KnowledgePagesLike, contactUrl?: string, language?: Language): AnswerLink[];
@@ -0,0 +1,18 @@
1
+ import { DEFAULT_LANGUAGE } from "../types.js";
2
+ const CONTACT_TITLE = {
3
+ ja: "Contactページ",
4
+ en: "Contact page",
5
+ };
6
+ /**
7
+ * Lists what an answer may link to: pages outside this site that are in the knowledge (such as
8
+ * Zenn articles) and the Contact page. This site's own pages are left out on purpose; the visitor
9
+ * is already on the site, so answers name them instead of linking them.
10
+ */
11
+ export function collectAnswerLinks(doc, contactUrl, language = DEFAULT_LANGUAGE) {
12
+ const links = doc.pages
13
+ .filter((page) => /^https?:\/\//i.test(page.url))
14
+ .map((page) => ({ url: page.url, title: page.title }));
15
+ if (contactUrl)
16
+ links.push({ url: contactUrl, title: CONTACT_TITLE[language] });
17
+ return links;
18
+ }
@@ -1,5 +1,4 @@
1
1
  import type { ChatRoute, Language } from "./types.js";
2
- export declare function buildSystemPrompt(knowledge: string, route: Exclude<ChatRoute, "rate_limited">, contactUrl?: string, language?: Language): string;
3
2
  export interface GeminiGeneratorConfig {
4
3
  apiKey: string;
5
4
  knowledge: string;
@@ -7,5 +6,11 @@ export interface GeminiGeneratorConfig {
7
6
  contactUrl?: string;
8
7
  language?: Language;
9
8
  }
10
- export type GenerateAnswerFn = (input: string, route: ChatRoute) => Promise<string>;
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>;
11
16
  export declare function createGeminiGenerator(config: GeminiGeneratorConfig): GenerateAnswerFn;
@@ -1,64 +1,24 @@
1
1
  import { GoogleGenAI } from "@google/genai";
2
+ import { buildSystemPrompt } from "./prompt/index.js";
2
3
  import { DEFAULT_LANGUAGE } from "./types.js";
3
- const COMMON_PREAMBLE = {
4
- ja: "あなたはポートフォリオサイト作者の代理として応対する受付エージェントです。" +
5
- "丁寧だが簡潔で自然な口語で話し、「お問い合わせいただきありがとうございます」のような定型の前置き挨拶はしません。" +
6
- "回答は原則3〜4文以内にまとめてください。\n\n" +
7
- "以下の知識に書かれていることだけから答えてください。書かれていないことは推測・創作せず、" +
8
- "「その点はサイトに記載がない」と伝えたうえでContactページを案内してください。" +
9
- "用語の正式名称・展開形も、知識に書かれていなければ創作しないでください。\n\n" +
10
- "Markdown記法(`**`・`#`・`-`のリストなど)は使わず、プレーンテキストで答えてください。" +
11
- "回答が長くなる場合は、2〜4文ごとに空行を挟んで段落を分けてください。",
12
- en: "You are a receptionist agent responding on behalf of the portfolio site's author. " +
13
- 'Speak politely but concisely and naturally, without formulaic openers such as "Thank you for reaching out." ' +
14
- "Keep your answers to about 3-4 sentences.\n\n" +
15
- "Only answer based on the knowledge provided below. Do not guess or invent anything that isn't written there — " +
16
- "say that the site doesn't cover that point, then guide the visitor to the Contact page. " +
17
- "Do not invent official names or expansions of terms that aren't in the knowledge either.\n\n" +
18
- "Do not use Markdown formatting (such as `**`, `#`, or `-` lists) — answer in plain text. " +
19
- "If the answer runs long, insert a blank line every 2-4 sentences to break it into paragraphs.",
20
- };
21
- const ROUTE_INSTRUCTIONS = {
22
- ja: {
23
- thoughts: "訪問者は作者の考え方について質問しています。知識の内容に沿って考え方を説明してください。",
24
- works: "訪問者はWorks(作品)について質問しています。知識に書かれた内容から解説し、" +
25
- "対応するZenn記事へのリンクが知識中にあれば案内してください。",
26
- inquiry: "訪問者は仕事の依頼・相談をしようとしています。簡潔に応じたうえで、Contactページへの問い合わせを案内してください。",
27
- },
28
- en: {
29
- thoughts: "The visitor is asking about the author's thinking. Explain it based on the knowledge provided.",
30
- works: "The visitor is asking about Works (projects). Explain based on the knowledge, and if a link to the " +
31
- "corresponding Zenn article is included in the knowledge, share it.",
32
- inquiry: "The visitor wants to discuss hiring or a project request. Respond briefly, then guide them to contact via the Contact page.",
33
- },
34
- };
35
- const KNOWLEDGE_LABEL = {
36
- ja: "---- 知識 ----",
37
- en: "---- Knowledge ----",
38
- };
39
- function buildInquiryInstruction(language, contactUrl) {
40
- if (contactUrl) {
41
- return language === "en"
42
- ? `The visitor wants to discuss hiring or a project request. Respond briefly, then guide them to contact via the Contact page (${contactUrl}).`
43
- : `訪問者は仕事の依頼・相談をしようとしています。簡潔に応じたうえで、Contactページ(${contactUrl})への問い合わせを案内してください。`;
44
- }
45
- return ROUTE_INSTRUCTIONS[language].inquiry;
46
- }
47
- export function buildSystemPrompt(knowledge, route, contactUrl, language = DEFAULT_LANGUAGE) {
48
- const routeInstruction = route === "inquiry" ? buildInquiryInstruction(language, contactUrl) : ROUTE_INSTRUCTIONS[language][route];
49
- return [COMMON_PREAMBLE[language], routeInstruction, KNOWLEDGE_LABEL[language], knowledge].join("\n\n");
50
- }
51
4
  export function createGeminiGenerator(config) {
52
5
  const client = new GoogleGenAI({ apiKey: config.apiKey });
53
- const model = config.model ?? "gemini-3.1-flash-lite";
6
+ const model = config.model ?? "gemini-3.5-flash-lite";
54
7
  const language = config.language ?? DEFAULT_LANGUAGE;
55
- return async (input, route) => {
8
+ return async (input, route, correction) => {
56
9
  if (route === "rate_limited") {
57
10
  throw new Error("generateAnswer should not be called for a rate-limited route");
58
11
  }
59
12
  const response = await client.models.generateContent({
60
13
  model,
61
- contents: input,
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,
62
22
  config: { systemInstruction: buildSystemPrompt(config.knowledge, route, config.contactUrl, language) },
63
23
  });
64
24
  return response.text ?? "";
@@ -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
- }, "__start__" | "generate" | "input_guard" | "log" | "route_message", {
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: {
@@ -1,4 +1,7 @@
1
1
  import { Annotation, END, START, StateGraph } from "@langchain/langgraph";
2
+ import { findAnswerFormatViolations, tidyAnswer } from "./answer/contract.js";
3
+ import { toPlainAnswer } from "./answer/fallback.js";
4
+ import { buildCorrectionRequest } from "./prompt/correction.js";
2
5
  import { classifyRoute } from "./route.js";
3
6
  import { DEFAULT_LANGUAGE } from "./types.js";
4
7
  const OVER_LIMIT_MESSAGE = {
@@ -13,12 +16,16 @@ const GENERATION_FAILED_MESSAGE = {
13
16
  ja: "本日の受付上限に達したか、一時的な不具合が発生しています。恐れ入りますがContactからお問い合わせください。",
14
17
  en: "You may have reached today's limit, or a temporary issue has occurred. Please contact us via the Contact page.",
15
18
  };
19
+ // 1回目の回答と、指摘して作り直させた回答の2回まで。無料枠の回数の上限があるので、それ以上は呼ばない
20
+ const MAX_ATTEMPTS = 2;
16
21
  const ChatState = Annotation.Root({
17
22
  input: Annotation(),
18
23
  ip: Annotation(),
19
24
  route: Annotation(),
20
25
  overLimit: Annotation(),
21
26
  answer: Annotation(),
27
+ attempt: Annotation(),
28
+ violations: Annotation(),
22
29
  });
23
30
  export function buildChatGraph(deps) {
24
31
  const language = deps.language ?? DEFAULT_LANGUAGE;
@@ -35,14 +42,38 @@ export function buildChatGraph(deps) {
35
42
  })
36
43
  .addNode("route_message", async (state) => ({ route: classifyRoute(state.input, language) }))
37
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;
38
49
  try {
39
- return { answer: await deps.generateAnswer(state.input, state.route) };
50
+ return { attempt, answer: await deps.generateAnswer(state.input, state.route, correction) };
40
51
  }
41
52
  catch (error) {
42
53
  console.error("generateAnswer failed", error);
43
- return { answer: GENERATION_FAILED_MESSAGE[language] };
54
+ return { attempt, answer: GENERATION_FAILED_MESSAGE[language] };
44
55
  }
45
56
  })
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: [] }))
46
77
  .addNode("log", async (state) => {
47
78
  await deps.logChat({
48
79
  ip: state.ip,
@@ -59,7 +90,9 @@ export function buildChatGraph(deps) {
59
90
  route_message: "route_message",
60
91
  })
61
92
  .addEdge("route_message", "generate")
62
- .addEdge("generate", "log")
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")
63
96
  .addEdge("log", END);
64
97
  return graph.compile();
65
98
  }
@@ -1,3 +1,4 @@
1
+ import type { AnswerLink } from "./answer/links.js";
1
2
  import type { GenerateAnswerFn } from "./gemini.js";
2
3
  import type { Language, RateLimitConfig } from "./types.js";
3
4
  export interface ChatHandlerConfig {
@@ -5,5 +6,10 @@ export interface ChatHandlerConfig {
5
6
  generateAnswer: GenerateAnswerFn;
6
7
  rateLimitConfig?: RateLimitConfig;
7
8
  language?: Language;
9
+ /**
10
+ * What answers may link to, usually `collectAnswerLinks(knowledgeDoc, contactUrl)`. When omitted,
11
+ * every link in an answer is reduced to its text.
12
+ */
13
+ answerLinks?: AnswerLink[];
8
14
  }
9
15
  export declare function createChatHandler(config: ChatHandlerConfig): (request: Request) => Promise<Response>;
@@ -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;
@@ -11,6 +11,8 @@ export function createChatHandler(config) {
11
11
  logChat: (entry) => logChat(config.db, entry),
12
12
  rateLimitConfig,
13
13
  language: config.language,
14
+ answerLinks: config.answerLinks,
15
+ logAnswerViolation: (entry) => logAnswerViolation(config.db, entry),
14
16
  });
15
17
  return async (request) => {
16
18
  let body;
@@ -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
+ }
@@ -0,0 +1,3 @@
1
+ import type { Language } from "../types.js";
2
+ export declare const ANSWER_FORMAT: Record<Language, string>;
3
+ export declare function buildContactLinkExample(language: Language, contactUrl: string): string;
@@ -0,0 +1,22 @@
1
+ // 回答の形式(handler と widget のあいだの決まり)。指示で頼む側で、守らせるのは answer/contract.ts と check_answer。
2
+ export const ANSWER_FORMAT = {
3
+ ja: "Markdown記法(`**`・`#`・`-`のリストなど)は使わず、プレーンテキストで答えてください。" +
4
+ "回答が長くなる場合は、2〜4文ごとに空行を挟んで段落を分けてください。\n\n" +
5
+ "リンクを示すときだけは [表示する文字](URL) の形で書いてください。URLをそのまま書かず、" +
6
+ "記事ならそのタイトル、Contactページなら「Contactページ」を表示する文字にします。" +
7
+ "訪問者はいまこのサイトを見ているので、知識で「(このサイトのページ)」と付いたページはリンクにせず、「Aboutページ」のようにページ名で案内してください。" +
8
+ "リンクにしてよいのは、Contactページと、知識に「URL: https://…」として書かれたこのサイトの外のページだけです。",
9
+ en: "Do not use Markdown formatting (such as `**`, `#`, or `-` lists) — answer in plain text. " +
10
+ "If the answer runs long, insert a blank line every 2-4 sentences to break it into paragraphs.\n\n" +
11
+ "The one exception is links: write them as [text to show](URL). Never show a bare URL; " +
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 pages marked \"(a page on this site)\" in the knowledge by name (e.g. \"the About page\") instead of linking them. " +
14
+ 'Only link the Contact page and pages outside this site that appear in the knowledge as "URL: https://…".',
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,3 @@
1
+ import type { AnswerViolation } from "../answer/contract.js";
2
+ import type { Language } from "../types.js";
3
+ export declare function buildCorrectionRequest(violations: AnswerViolation[], language: Language): string;
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ import type { Language } from "../types.js";
2
+ export declare const GROUNDING: Record<Language, string>;
@@ -0,0 +1,9 @@
1
+ // 知識に書いてあることだけで答え、創作しない。
2
+ export const GROUNDING = {
3
+ ja: "以下の知識に書かれていることだけから答えてください。書かれていないことは推測・創作せず、" +
4
+ "「その点はサイトに記載がない」と伝えたうえでContactページを案内してください。" +
5
+ "用語の正式名称・展開形も、知識に書かれていなければ創作しないでください。",
6
+ en: "Only answer based on the knowledge provided below. Do not guess or invent anything that isn't written there — " +
7
+ "say that the site doesn't cover that point, then guide the visitor to the Contact page. " +
8
+ "Do not invent official names or expansions of terms that aren't in the knowledge either.",
9
+ };
@@ -0,0 +1,2 @@
1
+ import type { ChatRoute, Language } from "../types.js";
2
+ export declare function buildSystemPrompt(knowledge: string, route: Exclude<ChatRoute, "rate_limited">, contactUrl?: string, language?: Language): string;
@@ -0,0 +1,16 @@
1
+ import { DEFAULT_LANGUAGE } from "../types.js";
2
+ import { ANSWER_FORMAT, buildContactLinkExample } from "./answer-format.js";
3
+ import { GROUNDING } from "./grounding.js";
4
+ import { PERSONA } from "./persona.js";
5
+ import { buildInquiryInstruction, ROUTE_INSTRUCTIONS } from "./routes.js";
6
+ const KNOWLEDGE_LABEL = {
7
+ ja: "---- 知識 ----",
8
+ en: "---- Knowledge ----",
9
+ };
10
+ export function buildSystemPrompt(knowledge, route, contactUrl, language = DEFAULT_LANGUAGE) {
11
+ const routeInstruction = route === "inquiry" ? buildInquiryInstruction(language, contactUrl) : ROUTE_INSTRUCTIONS[language][route];
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");
16
+ }
@@ -0,0 +1,2 @@
1
+ import type { Language } from "../types.js";
2
+ export declare const PERSONA: Record<Language, string>;
@@ -0,0 +1,9 @@
1
+ // 役割・口調・長さ。
2
+ export const PERSONA = {
3
+ ja: "あなたはポートフォリオサイト作者の代理として応対する受付エージェントです。" +
4
+ "丁寧だが簡潔で自然な口語で話し、「お問い合わせいただきありがとうございます」のような定型の前置き挨拶はしません。" +
5
+ "回答は原則3〜4文以内にまとめてください。",
6
+ en: "You are a receptionist agent responding on behalf of the portfolio site's author. " +
7
+ 'Speak politely but concisely and naturally, without formulaic openers such as "Thank you for reaching out." ' +
8
+ "Keep your answers to about 3-4 sentences.",
9
+ };
@@ -0,0 +1,3 @@
1
+ import type { ChatRoute, Language } from "../types.js";
2
+ export declare const ROUTE_INSTRUCTIONS: Record<Language, Record<Exclude<ChatRoute, "rate_limited">, string>>;
3
+ export declare function buildInquiryInstruction(language: Language, contactUrl?: string): string;
@@ -0,0 +1,22 @@
1
+ export const ROUTE_INSTRUCTIONS = {
2
+ ja: {
3
+ thoughts: "訪問者は作者の考え方について質問しています。知識の内容に沿って考え方を説明してください。",
4
+ works: "訪問者はWorks(作品)について質問しています。知識に書かれた内容から解説し、" +
5
+ "対応するZenn記事へのリンクが知識中にあれば案内してください。",
6
+ inquiry: "訪問者は仕事の依頼・相談をしようとしています。簡潔に応じたうえで、Contactページへの問い合わせを案内してください。",
7
+ },
8
+ en: {
9
+ thoughts: "The visitor is asking about the author's thinking. Explain it based on the knowledge provided.",
10
+ works: "The visitor is asking about Works (projects). Explain based on the knowledge, and if a link to the " +
11
+ "corresponding Zenn article is included in the knowledge, share it.",
12
+ inquiry: "The visitor wants to discuss hiring or a project request. Respond briefly, then guide them to contact via the Contact page.",
13
+ },
14
+ };
15
+ export function buildInquiryInstruction(language, contactUrl) {
16
+ if (contactUrl) {
17
+ return language === "en"
18
+ ? `The visitor wants to discuss hiring or a project request. Respond briefly, then guide them to contact via the Contact page (${contactUrl}).`
19
+ : `訪問者は仕事の依頼・相談をしようとしています。簡潔に応じたうえで、Contactページ(${contactUrl})への問い合わせを案内してください。`;
20
+ }
21
+ return ROUTE_INSTRUCTIONS[language].inquiry;
22
+ }
@@ -1,3 +1,6 @@
1
+ import type { AnswerViolationKind } from "./answer/contract.js";
2
+ import type { AnswerLink } from "./answer/links.js";
3
+ import type { AnswerCorrection } from "./gemini.js";
1
4
  export type ChatRoute = "thoughts" | "works" | "inquiry" | "rate_limited";
2
5
  export type Language = "ja" | "en";
3
6
  export declare const DEFAULT_LANGUAGE: Language;
@@ -22,8 +25,19 @@ export interface ChatLogEntry {
22
25
  }
23
26
  export interface ChatGraphDeps {
24
27
  checkRateLimit: (ip: string) => Promise<RateLimitResult>;
25
- generateAnswer: (input: string, route: ChatRoute) => Promise<string>;
28
+ generateAnswer: (input: string, route: ChatRoute, correction?: AnswerCorrection) => Promise<string>;
26
29
  logChat: (entry: ChatLogEntry) => Promise<void>;
27
30
  rateLimitConfig: RateLimitConfig;
28
31
  language?: Language;
32
+ /** What answers may link to. Links to anything else are reduced to their text. */
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;
29
43
  }
package/dist/index.d.ts CHANGED
@@ -1,14 +1,18 @@
1
1
  export { generateKnowledge } from "./ingest/generate.js";
2
2
  export { createUrlMatcher } from "./ingest/glob.js";
3
3
  export { htmlToText } from "./ingest/html-to-text.js";
4
- export type { IngestConfig, KnowledgeDocument, KnowledgePage, KnowledgeSource, ZennIngestConfig, } from "./ingest/types.js";
4
+ export { formatKnowledge } from "./ingest/format.js";
5
+ export type { IngestConfig, KnowledgeDocument, KnowledgePage, KnowledgePagesLike, KnowledgeSource, ZennIngestConfig, } from "./ingest/types.js";
5
6
  export { createChatHandler } from "./chat/handler.js";
6
7
  export type { ChatHandlerConfig } from "./chat/handler.js";
7
8
  export { buildChatGraph } from "./chat/graph.js";
8
9
  export { checkRateLimit } from "./chat/rate-limit.js";
9
- export { createGeminiGenerator, buildSystemPrompt } from "./chat/gemini.js";
10
- export type { GenerateAnswerFn, GeminiGeneratorConfig } from "./chat/gemini.js";
10
+ export { createGeminiGenerator } from "./chat/gemini.js";
11
+ export { buildSystemPrompt } from "./chat/prompt/index.js";
12
+ export { collectAnswerLinks } from "./chat/answer/links.js";
13
+ export type { AnswerLink } from "./chat/answer/links.js";
14
+ export type { AnswerCorrection, GenerateAnswerFn, GeminiGeneratorConfig } from "./chat/gemini.js";
11
15
  export { classifyRoute } from "./chat/route.js";
12
- export { logChat } from "./chat/log.js";
13
- 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";
14
18
  export { DEFAULT_RATE_LIMIT_CONFIG } from "./chat/types.js";
package/dist/index.js CHANGED
@@ -1,10 +1,13 @@
1
1
  export { generateKnowledge } from "./ingest/generate.js";
2
2
  export { createUrlMatcher } from "./ingest/glob.js";
3
3
  export { htmlToText } from "./ingest/html-to-text.js";
4
+ export { formatKnowledge } from "./ingest/format.js";
4
5
  export { createChatHandler } from "./chat/handler.js";
5
6
  export { buildChatGraph } from "./chat/graph.js";
6
7
  export { checkRateLimit } from "./chat/rate-limit.js";
7
- export { createGeminiGenerator, buildSystemPrompt } from "./chat/gemini.js";
8
+ export { createGeminiGenerator } from "./chat/gemini.js";
9
+ export { buildSystemPrompt } from "./chat/prompt/index.js";
10
+ export { collectAnswerLinks } from "./chat/answer/links.js";
8
11
  export { classifyRoute } from "./chat/route.js";
9
- export { logChat } from "./chat/log.js";
12
+ export { logAnswerViolation, logChat } from "./chat/log.js";
10
13
  export { DEFAULT_RATE_LIMIT_CONFIG } from "./chat/types.js";
@@ -0,0 +1,9 @@
1
+ import type { Language } from "../chat/types.js";
2
+ import type { KnowledgePagesLike } from "./types.js";
3
+ /**
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.
8
+ */
9
+ export declare function formatKnowledge(doc: KnowledgePagesLike, language?: Language): string;
@@ -0,0 +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
+ }
29
+ /**
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.
34
+ */
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");
46
+ }
@@ -45,6 +45,13 @@ export interface KnowledgePage {
45
45
  title: string;
46
46
  text: string;
47
47
  }
48
+ /**
49
+ * The part of a knowledge document that answering needs. A `knowledge.json` imported as JSON
50
+ * types `source` as a plain string, so helpers take this rather than `KnowledgeDocument`.
51
+ */
52
+ export interface KnowledgePagesLike {
53
+ pages: ReadonlyArray<Pick<KnowledgePage, "url" | "title" | "text">>;
54
+ }
48
55
  export interface KnowledgeDocument {
49
56
  generatedAt: string;
50
57
  pages: KnowledgePage[];
@@ -64,13 +64,23 @@ export function deriveEndpointPath(apiRoutePath) {
64
64
  /** Builds a minimal Cloudflare Pages Function that wires up `createChatHandler`. */
65
65
  export function buildApiRouteTemplate(answers) {
66
66
  const knowledgeImportPath = buildKnowledgeImportPath(answers.apiRoutePath, answers.distDir);
67
- const languageLine = answers.language === DEFAULT_LANGUAGE ? "" : `\n language: ${JSON.stringify(answers.language)},`;
68
- const contactUrlLine = answers.contactUrl ? `\n contactUrl: ${JSON.stringify(answers.contactUrl)},` : "";
67
+ const nonDefaultLanguage = answers.language === DEFAULT_LANGUAGE ? undefined : JSON.stringify(answers.language);
68
+ const languageLine = nonDefaultLanguage ? `\n language: ${nonDefaultLanguage},` : "";
69
+ const generatorLanguageLine = nonDefaultLanguage ? `\n language: ${nonDefaultLanguage},` : "";
70
+ const contactUrlConst = answers.contactUrl ? [`const CONTACT_URL = ${JSON.stringify(answers.contactUrl)};`, ""] : [];
71
+ const contactUrlLine = answers.contactUrl ? "\n contactUrl: CONTACT_URL," : "";
72
+ const linkArgs = [
73
+ "knowledgeDoc",
74
+ ...(answers.contactUrl || nonDefaultLanguage ? [answers.contactUrl ? "CONTACT_URL" : "undefined"] : []),
75
+ ...(nonDefaultLanguage ? [nonDefaultLanguage] : []),
76
+ ].join(", ");
69
77
  return [
70
- 'import { createChatHandler, createGeminiGenerator } from "@folio-agent/handler";',
78
+ 'import { collectAnswerLinks, createChatHandler, createGeminiGenerator, formatKnowledge } from "@folio-agent/handler";',
71
79
  `import knowledgeDoc from "${knowledgeImportPath}";`,
72
80
  "",
73
- 'const knowledge = knowledgeDoc.pages.map((page) => `# ${page.url}\\n\\n${page.text}`).join("\\n\\n");',
81
+ ...contactUrlConst,
82
+ "const knowledge = formatKnowledge(knowledgeDoc);",
83
+ `const answerLinks = collectAnswerLinks(${linkArgs});`,
74
84
  "",
75
85
  "interface Env {",
76
86
  " DB: D1Database;",
@@ -80,9 +90,10 @@ export function buildApiRouteTemplate(answers) {
80
90
  "export const onRequestPost: PagesFunction<Env> = async (context) => {",
81
91
  " const handle = createChatHandler({",
82
92
  ` db: context.env.DB,${languageLine}`,
93
+ " answerLinks,",
83
94
  " generateAnswer: createGeminiGenerator({",
84
95
  " apiKey: context.env.GEMINI_API_KEY,",
85
- ` knowledge,${contactUrlLine}`,
96
+ ` knowledge,${contactUrlLine}${generatorLanguageLine}`,
86
97
  " }),",
87
98
  " });",
88
99
  " return handle(context.request);",
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "@folio-agent/handler",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "Chat handler (LangGraph.js on Cloudflare Workers) + knowledge ingestion CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",