@folio-agent/handler 0.6.0 → 0.6.2

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,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
+ }
@@ -0,0 +1,7 @@
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;
@@ -0,0 +1,48 @@
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
+ }
@@ -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;
@@ -1,53 +1,6 @@
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
6
  const model = config.model ?? "gemini-3.1-flash-lite";
@@ -11,7 +11,7 @@ export declare function buildChatGraph(deps: ChatGraphDeps): import("@langchain/
11
11
  route?: ChatRoute | undefined;
12
12
  overLimit?: boolean | undefined;
13
13
  answer?: string | undefined;
14
- }, "__start__" | "generate" | "input_guard" | "log" | "route_message", {
14
+ }, "__start__" | "generate" | "input_guard" | "log" | "normalize_answer" | "route_message", {
15
15
  input: import("@langchain/langgraph").LastValue<string>;
16
16
  ip: import("@langchain/langgraph").LastValue<string>;
17
17
  route: import("@langchain/langgraph").LastValue<ChatRoute | undefined>;
@@ -35,6 +35,9 @@ export declare function buildChatGraph(deps: ChatGraphDeps): import("@langchain/
35
35
  overLimit: false;
36
36
  };
37
37
  log: {};
38
+ normalize_answer: {
39
+ answer: string;
40
+ };
38
41
  route_message: {
39
42
  route: "inquiry" | "thoughts" | "works";
40
43
  };
@@ -1,4 +1,5 @@
1
1
  import { Annotation, END, START, StateGraph } from "@langchain/langgraph";
2
+ import { normalizeAnswer } from "./answer/normalize.js";
2
3
  import { classifyRoute } from "./route.js";
3
4
  import { DEFAULT_LANGUAGE } from "./types.js";
4
5
  const OVER_LIMIT_MESSAGE = {
@@ -43,6 +44,7 @@ export function buildChatGraph(deps) {
43
44
  return { answer: GENERATION_FAILED_MESSAGE[language] };
44
45
  }
45
46
  })
47
+ .addNode("normalize_answer", async (state) => ({ answer: normalizeAnswer(state.answer ?? "", deps.answerLinks) }))
46
48
  .addNode("log", async (state) => {
47
49
  await deps.logChat({
48
50
  ip: state.ip,
@@ -59,7 +61,8 @@ export function buildChatGraph(deps) {
59
61
  route_message: "route_message",
60
62
  })
61
63
  .addEdge("route_message", "generate")
62
- .addEdge("generate", "log")
64
+ .addEdge("generate", "normalize_answer")
65
+ .addEdge("normalize_answer", "log")
63
66
  .addEdge("log", END);
64
67
  return graph.compile();
65
68
  }
@@ -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>;
@@ -11,6 +11,7 @@ export function createChatHandler(config) {
11
11
  logChat: (entry) => logChat(config.db, entry),
12
12
  rateLimitConfig,
13
13
  language: config.language,
14
+ answerLinks: config.answerLinks,
14
15
  });
15
16
  return async (request) => {
16
17
  let body;
@@ -0,0 +1,2 @@
1
+ import type { Language } from "../types.js";
2
+ export declare const ANSWER_FORMAT: Record<Language, string>;
@@ -0,0 +1,15 @@
1
+ // 回答の形式(handler と widget のあいだの決まり)。指示で頼む側で、守らせるのは answer/ の normalize。
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 this site's pages 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
+ };
@@ -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,13 @@
1
+ import { DEFAULT_LANGUAGE } from "../types.js";
2
+ import { ANSWER_FORMAT } 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
+ return [PERSONA[language], GROUNDING[language], ANSWER_FORMAT[language], routeInstruction, KNOWLEDGE_LABEL[language], knowledge].join("\n\n");
13
+ }
@@ -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,4 @@
1
+ import type { AnswerLink } from "./answer/links.js";
1
2
  export type ChatRoute = "thoughts" | "works" | "inquiry" | "rate_limited";
2
3
  export type Language = "ja" | "en";
3
4
  export declare const DEFAULT_LANGUAGE: Language;
@@ -26,4 +27,6 @@ export interface ChatGraphDeps {
26
27
  logChat: (entry: ChatLogEntry) => Promise<void>;
27
28
  rateLimitConfig: RateLimitConfig;
28
29
  language?: Language;
30
+ /** What answers may link to. Links to anything else are reduced to their text. */
31
+ answerLinks?: AnswerLink[];
29
32
  }
package/dist/index.d.ts CHANGED
@@ -1,12 +1,16 @@
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 { 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";
10
14
  export type { GenerateAnswerFn, GeminiGeneratorConfig } from "./chat/gemini.js";
11
15
  export { classifyRoute } from "./chat/route.js";
12
16
  export { logChat } from "./chat/log.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
12
  export { logChat } from "./chat/log.js";
10
13
  export { DEFAULT_RATE_LIMIT_CONFIG } from "./chat/types.js";
@@ -0,0 +1,6 @@
1
+ import type { KnowledgePagesLike } from "./types.js";
2
+ /**
3
+ * Turns a knowledge document into the text handed to the system prompt. Each page carries its
4
+ * title and URL so answers can point to an article by its title instead of a bare URL.
5
+ */
6
+ export declare function formatKnowledge(doc: KnowledgePagesLike): string;
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Turns a knowledge document into the text handed to the system prompt. Each page carries its
3
+ * title and URL so answers can point to an article by its title instead of a bare URL.
4
+ */
5
+ export function formatKnowledge(doc) {
6
+ return doc.pages.map((page) => `# ${page.title}\nURL: ${page.url}\n\n${page.text}`).join("\n\n");
7
+ }
@@ -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[];
package/dist/init/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { existsSync, realpathSync, statSync } from "node:fs";
3
3
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
- import { dirname } from "node:path";
4
+ import { dirname, posix } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { DEFAULT_API_ROUTE_PATH, InitArgsError, resolveInitRun, USAGE } from "./options.js";
7
7
  import { appendIngestToBuildScript, buildApiRouteTemplate, buildConfigJson, buildThemeCss, deriveEndpointPath, ensureGitignoreEntry, PUBLIC_DIR_NAME, resolveThemeCssPath, THEME_CSS_FILENAME, upsertDevVar, } from "./writers.js";
@@ -9,7 +9,6 @@ const CONFIG_PATH = "folio-agent.config.json";
9
9
  const PACKAGE_JSON_PATH = "package.json";
10
10
  const DEV_VARS_PATH = ".dev.vars";
11
11
  const GITIGNORE_PATH = ".gitignore";
12
- const KNOWLEDGE_OUTPUT_PATH = "dist/knowledge.json";
13
12
  async function readJson(path) {
14
13
  if (!existsSync(path))
15
14
  return undefined;
@@ -69,7 +68,9 @@ export async function main(argv = process.argv.slice(2), env = process.env) {
69
68
  : undefined;
70
69
  const pkg = (await readJson(PACKAGE_JSON_PATH)) ?? {};
71
70
  const scripts = pkg.scripts ?? {};
72
- const nextBuildScript = appendIngestToBuildScript(scripts.build, CONFIG_PATH, KNOWLEDGE_OUTPUT_PATH);
71
+ // API ルート雛形は <distDir>/knowledge.json を import するので、出力先もそこに揃える
72
+ const knowledgeOutputPath = posix.join(answers.distDir, "knowledge.json");
73
+ const nextBuildScript = appendIngestToBuildScript(scripts.build, CONFIG_PATH, knowledgeOutputPath);
73
74
  const buildScriptChanged = nextBuildScript !== (scripts.build ?? "");
74
75
  const { nextDevVars, gitignoreResult } = planDevVarsAndGitignore(answers.geminiApiKey, await readTextOrEmpty(DEV_VARS_PATH), await readTextOrEmpty(GITIGNORE_PATH));
75
76
  const plan = [
@@ -29,7 +29,11 @@ export interface ApiRouteAnswers {
29
29
  }
30
30
  /** Builds a minimal Cloudflare Pages Function that wires up `createChatHandler`. */
31
31
  export declare function buildApiRouteTemplate(answers: ApiRouteAnswers): string;
32
- /** Appends the ingest command to a `build` script if it isn't already present; otherwise returns it unchanged. */
32
+ /**
33
+ * Appends the ingest command to a `build` script unless it already runs ingest. Any existing
34
+ * `folio-agent-ingest` counts, whatever its arguments: sites often write to a different output
35
+ * path than the default, and appending a second run would ingest twice.
36
+ */
33
37
  export declare function appendIngestToBuildScript(buildScript: string | undefined, configPath: string, knowledgeOutputPath: string): string;
34
38
  /** Adds or replaces a single `KEY=value` line in `.dev.vars` content, leaving other lines untouched. */
35
39
  export declare function upsertDevVar(content: string, key: string, value: string): string;
@@ -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);",
@@ -90,11 +101,15 @@ export function buildApiRouteTemplate(answers) {
90
101
  "",
91
102
  ].join("\n");
92
103
  }
93
- /** Appends the ingest command to a `build` script if it isn't already present; otherwise returns it unchanged. */
104
+ /**
105
+ * Appends the ingest command to a `build` script unless it already runs ingest. Any existing
106
+ * `folio-agent-ingest` counts, whatever its arguments: sites often write to a different output
107
+ * path than the default, and appending a second run would ingest twice.
108
+ */
94
109
  export function appendIngestToBuildScript(buildScript, configPath, knowledgeOutputPath) {
95
110
  const ingestCommand = `${INGEST_COMMAND_PREFIX} ${configPath} ${knowledgeOutputPath}`;
96
111
  const base = (buildScript ?? "").trim();
97
- if (base.includes(ingestCommand)) {
112
+ if (base.includes(INGEST_COMMAND_PREFIX)) {
98
113
  return base;
99
114
  }
100
115
  return base.length > 0 ? `${base} && ${ingestCommand}` : ingestCommand;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@folio-agent/handler",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "Chat handler (LangGraph.js on Cloudflare Workers) + knowledge ingestion CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",