@folio-agent/handler 0.1.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 +31 -0
- package/dist/chat/gemini.d.ts +10 -0
- package/dist/chat/gemini.js +39 -0
- package/dist/chat/graph.d.ts +41 -0
- package/dist/chat/graph.js +53 -0
- package/dist/chat/handler.d.ts +8 -0
- package/dist/chat/handler.js +31 -0
- package/dist/chat/log.d.ts +2 -0
- package/dist/chat/log.js +6 -0
- package/dist/chat/rate-limit.d.ts +2 -0
- package/dist/chat/rate-limit.js +23 -0
- package/dist/chat/route.d.ts +2 -0
- package/dist/chat/route.js +12 -0
- package/dist/chat/types.d.ts +24 -0
- package/dist/chat/types.js +5 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +10 -0
- package/dist/ingest/cli.d.ts +2 -0
- package/dist/ingest/cli.js +23 -0
- package/dist/ingest/file-to-url.d.ts +6 -0
- package/dist/ingest/file-to-url.js +13 -0
- package/dist/ingest/generate.d.ts +2 -0
- package/dist/ingest/generate.js +39 -0
- package/dist/ingest/glob.d.ts +2 -0
- package/dist/ingest/glob.js +6 -0
- package/dist/ingest/html-to-text.d.ts +5 -0
- package/dist/ingest/html-to-text.js +19 -0
- package/dist/ingest/scan-dist.d.ts +7 -0
- package/dist/ingest/scan-dist.js +38 -0
- package/dist/ingest/token-count.d.ts +3 -0
- package/dist/ingest/token-count.js +6 -0
- package/dist/ingest/types.d.ts +33 -0
- package/dist/ingest/types.js +1 -0
- package/dist/ingest/zenn.d.ts +7 -0
- package/dist/ingest/zenn.js +39 -0
- package/migrations/0001_init.sql +11 -0
- package/package.json +49 -0
package/README.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# @folio-agent/handler
|
|
2
|
+
|
|
3
|
+
静的サイト向けポートフォリオ受付チャットボットのチャットハンドラ(Cloudflare Workers 上の LangGraph.js StateGraph)と、ビルド時に知識ファイルを生成する ingest CLI を提供する。知識源はビルド時にシステムプロンプトへ全量同梱する CAG 方式で、検索基盤を持たない。
|
|
4
|
+
|
|
5
|
+
`@folio-agent/widget` とセットで使う。詳細(アーキテクチャ・設計判断・利用手順全体)はリポジトリの [README](https://github.com/yktsnet/folio-agent#readme) を参照。
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @folio-agent/handler
|
|
11
|
+
|
|
12
|
+
# ビルド時: 知識ファイルの生成
|
|
13
|
+
npx folio-agent-ingest folio-agent.config.json knowledge.json
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { createChatHandler, createGeminiGenerator } from "@folio-agent/handler";
|
|
18
|
+
import knowledgeDoc from "../knowledge.json";
|
|
19
|
+
|
|
20
|
+
const knowledge = knowledgeDoc.pages.map((p) => `# ${p.url}\n\n${p.text}`).join("\n\n");
|
|
21
|
+
|
|
22
|
+
export default {
|
|
23
|
+
fetch: (request: Request, env: { DB: D1Database; GEMINI_API_KEY: string }) =>
|
|
24
|
+
createChatHandler({
|
|
25
|
+
db: env.DB,
|
|
26
|
+
generateAnswer: createGeminiGenerator({ apiKey: env.GEMINI_API_KEY, knowledge }),
|
|
27
|
+
})(request),
|
|
28
|
+
};
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
D1 スキーマは `migrations/0001_init.sql` を `wrangler d1 migrations apply` で適用する。
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ChatRoute } from "./types.js";
|
|
2
|
+
export declare function buildSystemPrompt(knowledge: string, route: Exclude<ChatRoute, "rate_limited">, contactUrl?: string): string;
|
|
3
|
+
export interface GeminiGeneratorConfig {
|
|
4
|
+
apiKey: string;
|
|
5
|
+
knowledge: string;
|
|
6
|
+
model?: string;
|
|
7
|
+
contactUrl?: string;
|
|
8
|
+
}
|
|
9
|
+
export type GenerateAnswerFn = (input: string, route: ChatRoute) => Promise<string>;
|
|
10
|
+
export declare function createGeminiGenerator(config: GeminiGeneratorConfig): GenerateAnswerFn;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { GoogleGenAI } from "@google/genai";
|
|
2
|
+
const COMMON_PREAMBLE = "あなたはポートフォリオサイト作者の代理として応対する受付エージェントです。" +
|
|
3
|
+
"丁寧だが簡潔で自然な口語で話し、「お問い合わせいただきありがとうございます」のような定型の前置き挨拶はしません。" +
|
|
4
|
+
"回答は原則3〜4文以内にまとめてください。\n\n" +
|
|
5
|
+
"以下の知識に書かれていることだけから答えてください。書かれていないことは推測・創作せず、" +
|
|
6
|
+
"「その点はサイトに記載がない」と伝えたうえでContactページを案内してください。" +
|
|
7
|
+
"用語の正式名称・展開形も、知識に書かれていなければ創作しないでください。\n\n" +
|
|
8
|
+
"Markdown記法(`**`・`#`・`-`のリストなど)は使わず、プレーンテキストで答えてください。";
|
|
9
|
+
const ROUTE_INSTRUCTIONS = {
|
|
10
|
+
thoughts: "訪問者は作者の考え方について質問しています。知識の内容に沿って考え方を説明してください。",
|
|
11
|
+
works: "訪問者はWorks(作品)について質問しています。知識に書かれた内容から解説し、" +
|
|
12
|
+
"対応するZenn記事へのリンクが知識中にあれば案内してください。",
|
|
13
|
+
inquiry: "訪問者は仕事の依頼・相談をしようとしています。簡潔に応じたうえで、Contactページへの問い合わせを案内してください。",
|
|
14
|
+
};
|
|
15
|
+
function buildInquiryInstruction(contactUrl) {
|
|
16
|
+
if (contactUrl) {
|
|
17
|
+
return `訪問者は仕事の依頼・相談をしようとしています。簡潔に応じたうえで、Contactページ(${contactUrl})への問い合わせを案内してください。`;
|
|
18
|
+
}
|
|
19
|
+
return ROUTE_INSTRUCTIONS.inquiry;
|
|
20
|
+
}
|
|
21
|
+
export function buildSystemPrompt(knowledge, route, contactUrl) {
|
|
22
|
+
const routeInstruction = route === "inquiry" ? buildInquiryInstruction(contactUrl) : ROUTE_INSTRUCTIONS[route];
|
|
23
|
+
return [COMMON_PREAMBLE, routeInstruction, "---- 知識 ----", knowledge].join("\n\n");
|
|
24
|
+
}
|
|
25
|
+
export function createGeminiGenerator(config) {
|
|
26
|
+
const client = new GoogleGenAI({ apiKey: config.apiKey });
|
|
27
|
+
const model = config.model ?? "gemini-3.1-flash-lite";
|
|
28
|
+
return async (input, route) => {
|
|
29
|
+
if (route === "rate_limited") {
|
|
30
|
+
throw new Error("generateAnswer should not be called for a rate-limited route");
|
|
31
|
+
}
|
|
32
|
+
const response = await client.models.generateContent({
|
|
33
|
+
model,
|
|
34
|
+
contents: input,
|
|
35
|
+
config: { systemInstruction: buildSystemPrompt(config.knowledge, route, config.contactUrl) },
|
|
36
|
+
});
|
|
37
|
+
return response.text ?? "";
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ChatGraphDeps, ChatRoute } from "./types.js";
|
|
2
|
+
export declare function buildChatGraph(deps: ChatGraphDeps): import("@langchain/langgraph").CompiledStateGraph<{
|
|
3
|
+
input: string;
|
|
4
|
+
ip: string;
|
|
5
|
+
route: ChatRoute | undefined;
|
|
6
|
+
overLimit: boolean;
|
|
7
|
+
answer: string | undefined;
|
|
8
|
+
}, {
|
|
9
|
+
input?: string | undefined;
|
|
10
|
+
ip?: string | undefined;
|
|
11
|
+
route?: ChatRoute | undefined;
|
|
12
|
+
overLimit?: boolean | undefined;
|
|
13
|
+
answer?: string | undefined;
|
|
14
|
+
}, "__start__" | "input_guard" | "route_message" | "generate" | "log", {
|
|
15
|
+
input: import("@langchain/langgraph").LastValue<string>;
|
|
16
|
+
ip: import("@langchain/langgraph").LastValue<string>;
|
|
17
|
+
route: import("@langchain/langgraph").LastValue<ChatRoute | undefined>;
|
|
18
|
+
overLimit: import("@langchain/langgraph").LastValue<boolean>;
|
|
19
|
+
answer: import("@langchain/langgraph").LastValue<string | undefined>;
|
|
20
|
+
}, {
|
|
21
|
+
input: import("@langchain/langgraph").LastValue<string>;
|
|
22
|
+
ip: import("@langchain/langgraph").LastValue<string>;
|
|
23
|
+
route: import("@langchain/langgraph").LastValue<ChatRoute | undefined>;
|
|
24
|
+
overLimit: import("@langchain/langgraph").LastValue<boolean>;
|
|
25
|
+
answer: import("@langchain/langgraph").LastValue<string | undefined>;
|
|
26
|
+
}, import("@langchain/langgraph").StateDefinition, {
|
|
27
|
+
input_guard: {
|
|
28
|
+
overLimit: true;
|
|
29
|
+
answer: string;
|
|
30
|
+
} | {
|
|
31
|
+
overLimit: false;
|
|
32
|
+
answer?: undefined;
|
|
33
|
+
};
|
|
34
|
+
route_message: {
|
|
35
|
+
route: "thoughts" | "works" | "inquiry";
|
|
36
|
+
};
|
|
37
|
+
generate: {
|
|
38
|
+
answer: string;
|
|
39
|
+
};
|
|
40
|
+
log: {};
|
|
41
|
+
}, unknown, unknown, []>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Annotation, END, START, StateGraph } from "@langchain/langgraph";
|
|
2
|
+
import { classifyRoute } from "./route.js";
|
|
3
|
+
const GENERATION_FAILED_MESSAGE = "本日の受付上限に達したか、一時的な不具合が発生しています。恐れ入りますがContactからお問い合わせください。";
|
|
4
|
+
const ChatState = Annotation.Root({
|
|
5
|
+
input: Annotation(),
|
|
6
|
+
ip: Annotation(),
|
|
7
|
+
route: Annotation(),
|
|
8
|
+
overLimit: Annotation(),
|
|
9
|
+
answer: Annotation(),
|
|
10
|
+
});
|
|
11
|
+
export function buildChatGraph(deps) {
|
|
12
|
+
const graph = new StateGraph(ChatState)
|
|
13
|
+
.addNode("input_guard", async (state) => {
|
|
14
|
+
const result = await deps.checkRateLimit(state.ip);
|
|
15
|
+
if (!result.allowed) {
|
|
16
|
+
const reasonText = result.reason === "daily" ? "本日の上限" : "直近の質問数の上限";
|
|
17
|
+
return {
|
|
18
|
+
overLimit: true,
|
|
19
|
+
answer: `${reasonText}に達しました。お急ぎの場合はContactからお問い合わせください。`,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
return { overLimit: false };
|
|
23
|
+
})
|
|
24
|
+
.addNode("route_message", async (state) => ({ route: classifyRoute(state.input) }))
|
|
25
|
+
.addNode("generate", async (state) => {
|
|
26
|
+
try {
|
|
27
|
+
return { answer: await deps.generateAnswer(state.input, state.route) };
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
console.error("generateAnswer failed", error);
|
|
31
|
+
return { answer: GENERATION_FAILED_MESSAGE };
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
.addNode("log", async (state) => {
|
|
35
|
+
await deps.logChat({
|
|
36
|
+
ip: state.ip,
|
|
37
|
+
route: state.overLimit ? "rate_limited" : state.route,
|
|
38
|
+
message: state.input,
|
|
39
|
+
response: state.answer ?? "",
|
|
40
|
+
overLimit: state.overLimit,
|
|
41
|
+
});
|
|
42
|
+
return {};
|
|
43
|
+
})
|
|
44
|
+
.addEdge(START, "input_guard")
|
|
45
|
+
.addConditionalEdges("input_guard", (state) => (state.overLimit ? "log" : "route_message"), {
|
|
46
|
+
log: "log",
|
|
47
|
+
route_message: "route_message",
|
|
48
|
+
})
|
|
49
|
+
.addEdge("route_message", "generate")
|
|
50
|
+
.addEdge("generate", "log")
|
|
51
|
+
.addEdge("log", END);
|
|
52
|
+
return graph.compile();
|
|
53
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { GenerateAnswerFn } from "./gemini.js";
|
|
2
|
+
import type { RateLimitConfig } from "./types.js";
|
|
3
|
+
export interface ChatHandlerConfig {
|
|
4
|
+
db: D1Database;
|
|
5
|
+
generateAnswer: GenerateAnswerFn;
|
|
6
|
+
rateLimitConfig?: RateLimitConfig;
|
|
7
|
+
}
|
|
8
|
+
export declare function createChatHandler(config: ChatHandlerConfig): (request: Request) => Promise<Response>;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { buildChatGraph } from "./graph.js";
|
|
2
|
+
import { logChat } from "./log.js";
|
|
3
|
+
import { checkRateLimit } from "./rate-limit.js";
|
|
4
|
+
import { DEFAULT_RATE_LIMIT_CONFIG } from "./types.js";
|
|
5
|
+
const MAX_INPUT_LENGTH = 1000;
|
|
6
|
+
export function createChatHandler(config) {
|
|
7
|
+
const graph = buildChatGraph({
|
|
8
|
+
checkRateLimit: (ip) => checkRateLimit(config.db, ip, new Date(), config.rateLimitConfig ?? DEFAULT_RATE_LIMIT_CONFIG),
|
|
9
|
+
generateAnswer: config.generateAnswer,
|
|
10
|
+
logChat: (entry) => logChat(config.db, entry),
|
|
11
|
+
});
|
|
12
|
+
return async (request) => {
|
|
13
|
+
let body;
|
|
14
|
+
try {
|
|
15
|
+
body = await request.json();
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return Response.json({ error: "invalid JSON body" }, { status: 400 });
|
|
19
|
+
}
|
|
20
|
+
const message = body?.message;
|
|
21
|
+
if (typeof message !== "string" || message.trim().length === 0) {
|
|
22
|
+
return Response.json({ error: "message must be a non-empty string" }, { status: 400 });
|
|
23
|
+
}
|
|
24
|
+
if (message.length > MAX_INPUT_LENGTH) {
|
|
25
|
+
return Response.json({ error: `message must be at most ${MAX_INPUT_LENGTH} characters` }, { status: 400 });
|
|
26
|
+
}
|
|
27
|
+
const ip = request.headers.get("CF-Connecting-IP") ?? "unknown";
|
|
28
|
+
const result = await graph.invoke({ input: message, ip });
|
|
29
|
+
return Response.json({ answer: result.answer, route: result.route ?? "rate_limited" });
|
|
30
|
+
};
|
|
31
|
+
}
|
package/dist/chat/log.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export async function logChat(db, entry, now = new Date()) {
|
|
2
|
+
await db
|
|
3
|
+
.prepare("INSERT INTO chat_logs (created_at, ip, route, message, response, over_limit) VALUES (?, ?, ?, ?, ?, ?)")
|
|
4
|
+
.bind(now.toISOString(), entry.ip, entry.route, entry.message, entry.response, entry.overLimit ? 1 : 0)
|
|
5
|
+
.run();
|
|
6
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { DEFAULT_RATE_LIMIT_CONFIG } from "./types.js";
|
|
2
|
+
const MINUTE_MS = 60_000;
|
|
3
|
+
const DAY_MS = 24 * 60 * MINUTE_MS;
|
|
4
|
+
async function countSince(db, ip, sinceIso) {
|
|
5
|
+
const row = await db
|
|
6
|
+
.prepare("SELECT COUNT(*) as count FROM chat_logs WHERE ip = ? AND created_at >= ? AND over_limit = 0")
|
|
7
|
+
.bind(ip, sinceIso)
|
|
8
|
+
.first();
|
|
9
|
+
return row?.count ?? 0;
|
|
10
|
+
}
|
|
11
|
+
export async function checkRateLimit(db, ip, now = new Date(), config = DEFAULT_RATE_LIMIT_CONFIG) {
|
|
12
|
+
const shortWindowStart = new Date(now.getTime() - config.shortWindowMinutes * MINUTE_MS).toISOString();
|
|
13
|
+
const shortCount = await countSince(db, ip, shortWindowStart);
|
|
14
|
+
if (shortCount >= config.shortWindowMax) {
|
|
15
|
+
return { allowed: false, reason: "short_window" };
|
|
16
|
+
}
|
|
17
|
+
const dailyStart = new Date(now.getTime() - DAY_MS).toISOString();
|
|
18
|
+
const dailyCount = await countSince(db, ip, dailyStart);
|
|
19
|
+
if (dailyCount >= config.dailyMax) {
|
|
20
|
+
return { allowed: false, reason: "daily" };
|
|
21
|
+
}
|
|
22
|
+
return { allowed: true };
|
|
23
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const INQUIRY_KEYWORDS = ["依頼", "相談", "見積", "発注", "案件", "お願いしたい"];
|
|
2
|
+
const WORKS_KEYWORDS = ["works", "作品", "実績", "zenn", "記事"];
|
|
3
|
+
export function classifyRoute(input) {
|
|
4
|
+
const lower = input.toLowerCase();
|
|
5
|
+
if (INQUIRY_KEYWORDS.some((keyword) => input.includes(keyword))) {
|
|
6
|
+
return "inquiry";
|
|
7
|
+
}
|
|
8
|
+
if (WORKS_KEYWORDS.some((keyword) => lower.includes(keyword))) {
|
|
9
|
+
return "works";
|
|
10
|
+
}
|
|
11
|
+
return "thoughts";
|
|
12
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export type ChatRoute = "thoughts" | "works" | "inquiry" | "rate_limited";
|
|
2
|
+
export interface RateLimitConfig {
|
|
3
|
+
shortWindowMinutes: number;
|
|
4
|
+
shortWindowMax: number;
|
|
5
|
+
dailyMax: number;
|
|
6
|
+
}
|
|
7
|
+
export declare const DEFAULT_RATE_LIMIT_CONFIG: RateLimitConfig;
|
|
8
|
+
export type RateLimitReason = "short_window" | "daily";
|
|
9
|
+
export interface RateLimitResult {
|
|
10
|
+
allowed: boolean;
|
|
11
|
+
reason?: RateLimitReason;
|
|
12
|
+
}
|
|
13
|
+
export interface ChatLogEntry {
|
|
14
|
+
ip: string;
|
|
15
|
+
route: ChatRoute;
|
|
16
|
+
message: string;
|
|
17
|
+
response: string;
|
|
18
|
+
overLimit: boolean;
|
|
19
|
+
}
|
|
20
|
+
export interface ChatGraphDeps {
|
|
21
|
+
checkRateLimit: (ip: string) => Promise<RateLimitResult>;
|
|
22
|
+
generateAnswer: (input: string, route: ChatRoute) => Promise<string>;
|
|
23
|
+
logChat: (entry: ChatLogEntry) => Promise<void>;
|
|
24
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { generateKnowledge } from "./ingest/generate.js";
|
|
2
|
+
export { createUrlMatcher } from "./ingest/glob.js";
|
|
3
|
+
export { htmlToText } from "./ingest/html-to-text.js";
|
|
4
|
+
export type { IngestConfig, KnowledgeDocument, KnowledgePage, KnowledgeSource, ZennIngestConfig, } from "./ingest/types.js";
|
|
5
|
+
export { createChatHandler } from "./chat/handler.js";
|
|
6
|
+
export type { ChatHandlerConfig } from "./chat/handler.js";
|
|
7
|
+
export { buildChatGraph } from "./chat/graph.js";
|
|
8
|
+
export { checkRateLimit } from "./chat/rate-limit.js";
|
|
9
|
+
export { createGeminiGenerator, buildSystemPrompt } from "./chat/gemini.js";
|
|
10
|
+
export type { GenerateAnswerFn, GeminiGeneratorConfig } from "./chat/gemini.js";
|
|
11
|
+
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";
|
|
14
|
+
export { DEFAULT_RATE_LIMIT_CONFIG } from "./chat/types.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { generateKnowledge } from "./ingest/generate.js";
|
|
2
|
+
export { createUrlMatcher } from "./ingest/glob.js";
|
|
3
|
+
export { htmlToText } from "./ingest/html-to-text.js";
|
|
4
|
+
export { createChatHandler } from "./chat/handler.js";
|
|
5
|
+
export { buildChatGraph } from "./chat/graph.js";
|
|
6
|
+
export { checkRateLimit } from "./chat/rate-limit.js";
|
|
7
|
+
export { createGeminiGenerator, buildSystemPrompt } from "./chat/gemini.js";
|
|
8
|
+
export { classifyRoute } from "./chat/route.js";
|
|
9
|
+
export { logChat } from "./chat/log.js";
|
|
10
|
+
export { DEFAULT_RATE_LIMIT_CONFIG } from "./chat/types.js";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { generateKnowledge } from "./generate.js";
|
|
4
|
+
async function main() {
|
|
5
|
+
const configPath = process.argv[2];
|
|
6
|
+
const outputPath = process.argv[3];
|
|
7
|
+
if (!configPath || !outputPath) {
|
|
8
|
+
console.error("usage: folio-agent-ingest <config.json> <output.json>");
|
|
9
|
+
process.exitCode = 1;
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
const config = JSON.parse(await readFile(configPath, "utf-8"));
|
|
13
|
+
const knowledge = await generateKnowledge(config);
|
|
14
|
+
await writeFile(outputPath, JSON.stringify(knowledge, null, 2));
|
|
15
|
+
console.log(`wrote ${knowledge.pages.length} page(s), ~${knowledge.estimatedTokens} tokens -> ${outputPath}`);
|
|
16
|
+
for (const warning of knowledge.warnings) {
|
|
17
|
+
console.warn(`warning: ${warning}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
main().catch((error) => {
|
|
21
|
+
console.error(error);
|
|
22
|
+
process.exitCode = 1;
|
|
23
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps a file path relative to a content root (dist/ or knowledge/) to the
|
|
3
|
+
* URL path it represents, e.g. "works/foo/index.html" -> "/works/foo",
|
|
4
|
+
* "about.html" -> "/about", "index.html" -> "/".
|
|
5
|
+
*/
|
|
6
|
+
export declare function fileToUrlPath(relPath: string): string;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { sep } from "node:path";
|
|
2
|
+
/**
|
|
3
|
+
* Maps a file path relative to a content root (dist/ or knowledge/) to the
|
|
4
|
+
* URL path it represents, e.g. "works/foo/index.html" -> "/works/foo",
|
|
5
|
+
* "about.html" -> "/about", "index.html" -> "/".
|
|
6
|
+
*/
|
|
7
|
+
export function fileToUrlPath(relPath) {
|
|
8
|
+
const posixPath = relPath.split(sep).join("/");
|
|
9
|
+
const withoutExt = posixPath.replace(/\.[^/.]+$/, "");
|
|
10
|
+
const withoutIndex = withoutExt.replace(/(^|\/)index$/, "");
|
|
11
|
+
const urlPath = `/${withoutIndex}`.replace(/\/+/g, "/");
|
|
12
|
+
return urlPath === "" ? "/" : urlPath;
|
|
13
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { createUrlMatcher } from "./glob.js";
|
|
2
|
+
import { htmlToText } from "./html-to-text.js";
|
|
3
|
+
import { readTextFile, scanDist, scanKnowledgeDir } from "./scan-dist.js";
|
|
4
|
+
import { DEFAULT_TOKEN_WARNING_THRESHOLD, estimateTokens } from "./token-count.js";
|
|
5
|
+
import { scanZennArticles } from "./zenn.js";
|
|
6
|
+
export async function generateKnowledge(config) {
|
|
7
|
+
const matchesUrl = createUrlMatcher(config);
|
|
8
|
+
const pages = [];
|
|
9
|
+
const distFiles = await scanDist(config.distDir);
|
|
10
|
+
for (const file of distFiles.filter((f) => matchesUrl(f.urlPath))) {
|
|
11
|
+
const html = await readTextFile(file.absolutePath);
|
|
12
|
+
const { title, text } = htmlToText(html);
|
|
13
|
+
pages.push({ url: file.urlPath, source: "dist", title, text });
|
|
14
|
+
}
|
|
15
|
+
if (config.knowledgeDir) {
|
|
16
|
+
const knowledgeFiles = await scanKnowledgeDir(config.knowledgeDir);
|
|
17
|
+
for (const file of knowledgeFiles) {
|
|
18
|
+
const text = (await readTextFile(file.absolutePath)).trim();
|
|
19
|
+
pages.push({ url: file.urlPath, source: "knowledge", title: file.urlPath, text });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
if (config.zenn) {
|
|
23
|
+
pages.push(...(await scanZennArticles(config.zenn)));
|
|
24
|
+
}
|
|
25
|
+
pages.sort((a, b) => a.url.localeCompare(b.url));
|
|
26
|
+
const estimatedTokens = estimateTokens(pages.map((p) => p.text).join("\n"));
|
|
27
|
+
const threshold = config.tokenWarningThreshold ?? DEFAULT_TOKEN_WARNING_THRESHOLD;
|
|
28
|
+
const warnings = [];
|
|
29
|
+
if (estimatedTokens > threshold) {
|
|
30
|
+
warnings.push(`estimated knowledge size (${estimatedTokens} tokens) exceeds the warning threshold (${threshold}). ` +
|
|
31
|
+
"Consider narrowing include globs, or switching to a RAG-based approach.");
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
generatedAt: new Date().toISOString(),
|
|
35
|
+
pages,
|
|
36
|
+
estimatedTokens,
|
|
37
|
+
warnings,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import picomatch from "picomatch";
|
|
2
|
+
export function createUrlMatcher(config) {
|
|
3
|
+
const isIncluded = picomatch(config.include);
|
|
4
|
+
const isExcluded = config.exclude && config.exclude.length > 0 ? picomatch(config.exclude) : () => false;
|
|
5
|
+
return (urlPath) => isIncluded(urlPath) && !isExcluded(urlPath);
|
|
6
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { parse } from "node-html-parser";
|
|
2
|
+
const NON_CONTENT_TAGS = ["script", "style", "noscript", "template"];
|
|
3
|
+
export function htmlToText(html) {
|
|
4
|
+
const root = parse(html);
|
|
5
|
+
for (const tag of NON_CONTENT_TAGS) {
|
|
6
|
+
for (const el of root.querySelectorAll(tag)) {
|
|
7
|
+
el.remove();
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
const title = root.querySelector("title")?.text.trim() ?? "";
|
|
11
|
+
const bodyText = root.querySelector("body")?.text ?? root.text;
|
|
12
|
+
const text = bodyText
|
|
13
|
+
.replace(/[ \t]+/g, " ")
|
|
14
|
+
.split("\n")
|
|
15
|
+
.map((line) => line.trim())
|
|
16
|
+
.filter((line) => line.length > 0)
|
|
17
|
+
.join("\n");
|
|
18
|
+
return { title, text };
|
|
19
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface ScannedFile {
|
|
2
|
+
urlPath: string;
|
|
3
|
+
absolutePath: string;
|
|
4
|
+
}
|
|
5
|
+
export declare function scanDist(distDir: string): Promise<ScannedFile[]>;
|
|
6
|
+
export declare function scanKnowledgeDir(knowledgeDir: string): Promise<ScannedFile[]>;
|
|
7
|
+
export declare function readTextFile(absolutePath: string): Promise<string>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
import { fileToUrlPath } from "./file-to-url.js";
|
|
4
|
+
async function walk(dir, root) {
|
|
5
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
6
|
+
const files = [];
|
|
7
|
+
for (const entry of entries) {
|
|
8
|
+
const fullPath = join(dir, entry.name);
|
|
9
|
+
if (entry.isDirectory()) {
|
|
10
|
+
files.push(...(await walk(fullPath, root)));
|
|
11
|
+
}
|
|
12
|
+
else {
|
|
13
|
+
files.push(fullPath);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return files;
|
|
17
|
+
}
|
|
18
|
+
export async function scanDist(distDir) {
|
|
19
|
+
const allFiles = await walk(distDir, distDir);
|
|
20
|
+
return allFiles
|
|
21
|
+
.filter((absolutePath) => absolutePath.endsWith(".html"))
|
|
22
|
+
.map((absolutePath) => ({
|
|
23
|
+
urlPath: fileToUrlPath(relative(distDir, absolutePath)),
|
|
24
|
+
absolutePath,
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
27
|
+
export async function scanKnowledgeDir(knowledgeDir) {
|
|
28
|
+
const allFiles = await walk(knowledgeDir, knowledgeDir);
|
|
29
|
+
return allFiles
|
|
30
|
+
.filter((absolutePath) => absolutePath.endsWith(".md"))
|
|
31
|
+
.map((absolutePath) => ({
|
|
32
|
+
urlPath: fileToUrlPath(relative(knowledgeDir, absolutePath)),
|
|
33
|
+
absolutePath,
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
export async function readTextFile(absolutePath) {
|
|
37
|
+
return readFile(absolutePath, "utf-8");
|
|
38
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
const CHARS_PER_TOKEN_ESTIMATE = 4;
|
|
2
|
+
export const DEFAULT_TOKEN_WARNING_THRESHOLD = 100_000;
|
|
3
|
+
/** Rough token estimate (chars / 4), good enough for a build-time budget warning. */
|
|
4
|
+
export function estimateTokens(text) {
|
|
5
|
+
return Math.ceil(text.length / CHARS_PER_TOKEN_ESTIMATE);
|
|
6
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface IngestConfig {
|
|
2
|
+
/** Absolute or cwd-relative path to the built site (e.g. "dist"). */
|
|
3
|
+
distDir: string;
|
|
4
|
+
/** URL path globs to include, e.g. ["/", "/works/**", "/about"]. */
|
|
5
|
+
include: string[];
|
|
6
|
+
/** URL path globs to exclude, e.g. ["/works/draft-*"]. */
|
|
7
|
+
exclude?: string[];
|
|
8
|
+
/** Directory mirroring URL paths with supplementary Markdown, e.g. "knowledge". */
|
|
9
|
+
knowledgeDir?: string;
|
|
10
|
+
/** Zenn CLI articles/ ingestion. Omit to skip Zenn articles entirely. */
|
|
11
|
+
zenn?: ZennIngestConfig;
|
|
12
|
+
/** Token threshold above which the CLI warns that CAG may no longer fit. */
|
|
13
|
+
tokenWarningThreshold?: number;
|
|
14
|
+
}
|
|
15
|
+
export interface ZennIngestConfig {
|
|
16
|
+
/** Local path (absolute or cwd-relative) to the Zenn CLI articles/ directory. */
|
|
17
|
+
articlesDir: string;
|
|
18
|
+
/** Base used to build public article URLs, e.g. "https://zenn.dev/username/articles". No trailing slash. */
|
|
19
|
+
baseUrl: string;
|
|
20
|
+
}
|
|
21
|
+
export type KnowledgeSource = "dist" | "knowledge" | "zenn";
|
|
22
|
+
export interface KnowledgePage {
|
|
23
|
+
url: string;
|
|
24
|
+
source: KnowledgeSource;
|
|
25
|
+
title: string;
|
|
26
|
+
text: string;
|
|
27
|
+
}
|
|
28
|
+
export interface KnowledgeDocument {
|
|
29
|
+
generatedAt: string;
|
|
30
|
+
pages: KnowledgePage[];
|
|
31
|
+
estimatedTokens: number;
|
|
32
|
+
warnings: string[];
|
|
33
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { KnowledgePage, ZennIngestConfig } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Reads published Zenn CLI articles from a local articles/ directory (no
|
|
4
|
+
* subdirectories expected) and maps them to KnowledgePage entries. Drafts
|
|
5
|
+
* (published: false) and files without a published field are excluded.
|
|
6
|
+
*/
|
|
7
|
+
export declare function scanZennArticles(config: ZennIngestConfig): Promise<KnowledgePage[]>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { basename, extname, join } from "node:path";
|
|
3
|
+
import { readTextFile } from "./scan-dist.js";
|
|
4
|
+
const FRONTMATTER_PATTERN = /^---\n([\s\S]*?)\n---\n?/;
|
|
5
|
+
const TITLE_PATTERN = /^title:\s*"?(.*?)"?\s*$/m;
|
|
6
|
+
const PUBLISHED_PATTERN = /^published:\s*(true|false)\s*$/m;
|
|
7
|
+
/**
|
|
8
|
+
* Reads published Zenn CLI articles from a local articles/ directory (no
|
|
9
|
+
* subdirectories expected) and maps them to KnowledgePage entries. Drafts
|
|
10
|
+
* (published: false) and files without a published field are excluded.
|
|
11
|
+
*/
|
|
12
|
+
export async function scanZennArticles(config) {
|
|
13
|
+
const entries = await readdir(config.articlesDir, { withFileTypes: true });
|
|
14
|
+
const pages = [];
|
|
15
|
+
for (const entry of entries) {
|
|
16
|
+
if (!entry.isFile() || !entry.name.endsWith(".md"))
|
|
17
|
+
continue;
|
|
18
|
+
const absolutePath = join(config.articlesDir, entry.name);
|
|
19
|
+
const content = await readTextFile(absolutePath);
|
|
20
|
+
const frontmatterMatch = content.match(FRONTMATTER_PATTERN);
|
|
21
|
+
if (!frontmatterMatch)
|
|
22
|
+
continue;
|
|
23
|
+
const frontmatter = frontmatterMatch[1];
|
|
24
|
+
const publishedMatch = frontmatter.match(PUBLISHED_PATTERN);
|
|
25
|
+
if (publishedMatch?.[1] !== "true")
|
|
26
|
+
continue;
|
|
27
|
+
const slug = basename(entry.name, extname(entry.name));
|
|
28
|
+
const titleMatch = frontmatter.match(TITLE_PATTERN);
|
|
29
|
+
const title = titleMatch ? titleMatch[1] : slug;
|
|
30
|
+
const text = content.slice(frontmatterMatch[0].length).trim();
|
|
31
|
+
pages.push({
|
|
32
|
+
url: `${config.baseUrl}/${slug}`,
|
|
33
|
+
source: "zenn",
|
|
34
|
+
title,
|
|
35
|
+
text,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return pages;
|
|
39
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS chat_logs (
|
|
2
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
3
|
+
created_at TEXT NOT NULL,
|
|
4
|
+
ip TEXT NOT NULL,
|
|
5
|
+
route TEXT NOT NULL,
|
|
6
|
+
message TEXT NOT NULL,
|
|
7
|
+
response TEXT NOT NULL,
|
|
8
|
+
over_limit INTEGER NOT NULL DEFAULT 0
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
CREATE INDEX IF NOT EXISTS idx_chat_logs_ip_created_at ON chat_logs (ip, created_at);
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@folio-agent/handler",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Chat handler (LangGraph.js on Cloudflare Workers) + knowledge ingestion CLI",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"migrations"
|
|
12
|
+
],
|
|
13
|
+
"bin": {
|
|
14
|
+
"folio-agent-ingest": "dist/ingest/cli.js"
|
|
15
|
+
},
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/yktsnet/folio-agent.git",
|
|
19
|
+
"directory": "packages/handler"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://github.com/yktsnet/folio-agent#readme",
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/yktsnet/folio-agent/issues"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"chatbot",
|
|
27
|
+
"cloudflare-workers",
|
|
28
|
+
"langgraph",
|
|
29
|
+
"gemini",
|
|
30
|
+
"cag"
|
|
31
|
+
],
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsc -b"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@google/genai": "^2.10.0",
|
|
40
|
+
"@langchain/langgraph": "^1.4.7",
|
|
41
|
+
"node-html-parser": "^8.0.4",
|
|
42
|
+
"picomatch": "^4.0.5"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@cloudflare/workers-types": "^5.20260705.1",
|
|
46
|
+
"@types/picomatch": "^4.0.2",
|
|
47
|
+
"wrangler": "^4.107.0"
|
|
48
|
+
}
|
|
49
|
+
}
|