Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions server/src/__tests__/helpers/test-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,19 @@ export const createTestDb = async () => {
created_at TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS source_candidates (
id TEXT PRIMARY KEY,
url TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'pending',
occurrence_count INTEGER NOT NULL DEFAULT 1,
related_answer_run_id TEXT,
decided_by TEXT,
decided_at TEXT,
last_seen_at TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT
);

CREATE TABLE IF NOT EXISTS knowledge_corrections (
id TEXT PRIMARY KEY,
corrects_source_path TEXT NOT NULL,
Expand Down
14 changes: 14 additions & 0 deletions server/src/db/migrations/0032_source_candidates.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
CREATE TABLE source_candidates (
id TEXT PRIMARY KEY,
url TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'pending',
occurrence_count INTEGER NOT NULL DEFAULT 1,
related_answer_run_id TEXT,
decided_by TEXT,
decided_at TEXT,
last_seen_at TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT
);

CREATE INDEX idx_source_candidates_status ON source_candidates(status);
16 changes: 16 additions & 0 deletions server/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,22 @@ export const retrievalRuns = sqliteTable("retrieval_runs", {
export type RetrievalRun = typeof retrievalRuns.$inferSelect;
export type NewRetrievalRun = typeof retrievalRuns.$inferInsert;

export const sourceCandidates = sqliteTable("source_candidates", {
id: text("id").primaryKey(),
url: text("url").notNull().unique(),
status: text("status").notNull().default("pending"),
occurrenceCount: integer("occurrence_count").notNull().default(1),
relatedAnswerRunId: text("related_answer_run_id"),
decidedBy: text("decided_by"),
decidedAt: text("decided_at"),
lastSeenAt: text("last_seen_at").notNull(),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at"),
});

export type SourceCandidate = typeof sourceCandidates.$inferSelect;
export type NewSourceCandidate = typeof sourceCandidates.$inferInsert;

export const knowledgeCorrections = sqliteTable("knowledge_corrections", {
id: text("id").primaryKey(),
correctsSourcePath: text("corrects_source_path").notNull(),
Expand Down
2 changes: 2 additions & 0 deletions server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
pollAdminRoutes,
pollRoutes,
reviewAdminRoutes,
sourceCandidatesAdminRoutes,
threadsRoutes,
twilioVoiceRoutes,
userAdminRoutes,
Expand Down Expand Up @@ -55,6 +56,7 @@ app.route("/admin/knowledge", knowledgeAdminRoutes);
app.route("/admin/persona", personaAdminRoutes);
app.route("/admin/review", reviewAdminRoutes);
app.route("/admin/corrections", correctionsAdminRoutes);
app.route("/admin/source-candidates", sourceCandidatesAdminRoutes);
app.route("/admin/emergency", emergencyAdminRoutes);
app.route("/admin/invitations", invitationRoutes);
app.route("/admin/users", userAdminRoutes);
Expand Down
31 changes: 31 additions & 0 deletions server/src/lib/url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { hostOf, normalizeUrl } from "./url";

describe("normalizeUrl", () => {
it("hash と末尾スラッシュを除去し host を小文字化する", () => {
expect(normalizeUrl("https://Example.COM/path/#section")).toBe(
"https://example.com/path",
);
});

it("クエリ文字列は保持する", () => {
expect(normalizeUrl("https://example.com/p?id=1")).toBe(
"https://example.com/p?id=1",
);
});

it("URL として不正な文字列は null を返す", () => {
expect(normalizeUrl("not-a-url")).toBeNull();
});
});

describe("hostOf", () => {
it("host を小文字化し www. を除去して返す", () => {
expect(hostOf("https://WWW.Vill.Example.jp/page")).toBe("vill.example.jp");
expect(hostOf("https://vill.example.jp/page")).toBe("vill.example.jp");
});

it("不正な URL は null を返す", () => {
expect(hostOf("::")).toBeNull();
});
});
18 changes: 18 additions & 0 deletions server/src/lib/url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export const normalizeUrl = (value: string) => {
try {
const url = new URL(value.trim());
url.hash = "";
url.hostname = url.hostname.toLowerCase();
return url.href.replace(/\/$/, "");
} catch {
return null;
}
};

export const hostOf = (value: string) => {
try {
return new URL(value.trim()).hostname.toLowerCase().replace(/^www\./, "");
} catch {
return null;
}
};
31 changes: 25 additions & 6 deletions server/src/mastra/agents/web-researcher-agent.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { google } from "@ai-sdk/google";
import { Agent } from "@mastra/core/agent";
import type { RequestContext } from "@mastra/core/request-context";
import type { MastraOnFinishCallbackArgs } from "@mastra/core/stream";
import { getCurrentDateInfo } from "~/lib/date";
import { GEMINI_GROUNDING } from "~/lib/llm-models";
import { withUsageRecording } from "~/services/analytics/llm-usage";
import { usageRecordingOptions } from "~/services/analytics/llm-usage";
import { captureSourceCandidatesInBackground } from "~/services/knowledge/source-candidates";

const baseInstructions = `
あなたはインターネットから最新情報を収集する専門エージェントです。
Expand All @@ -27,10 +30,11 @@ const baseInstructions = `
- 情報の現在性が回答に影響する場合は、いつ時点の情報かを踏まえて扱う。最新状況を確認できない場合は、その不確実性を伝えるか、必要に応じて直接確認を案内する
`;

// Gemini は thinking の効き幅が極端なため、明示せず動的思考に委ねる
const researcherModelConfig = {
model: GEMINI_GROUNDING,
};
const usageOptions = usageRecordingOptions({
source: "subagent",
agent: "web-researcher",
fallbackModel: GEMINI_GROUNDING,
});

export const createWebResearcherAgent = () =>
new Agent({
Expand All @@ -42,7 +46,22 @@ export const createWebResearcherAgent = () =>
## 現在の日時
${getCurrentDateInfo()}
`,
...withUsageRecording(researcherModelConfig, { agent: "web-researcher" }),
// Gemini は thinking の効き幅が極端なため、明示せず動的思考に委ねる
model: GEMINI_GROUNDING,
defaultOptions: (context: { requestContext?: RequestContext }) => {
const options = usageOptions(context);
return {
...options,
onFinish: (event: MastraOnFinishCallbackArgs) => {
const recording = options.onFinish?.(event);
captureSourceCandidatesInBackground(
context.requestContext,
event.text,
);
return recording;
},
};
},
tools: {
googleSearch: google.tools.googleSearch({}),
},
Expand Down
14 changes: 13 additions & 1 deletion server/src/repository/knowledge-source-repository.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { asc, eq } from "drizzle-orm";
import { asc, eq, isNotNull } from "drizzle-orm";
import {
createDb,
type KnowledgeSource,
Expand Down Expand Up @@ -40,6 +40,18 @@ export const knowledgeSourceRepository = {
return result ?? null;
},

async listCanonicalUrls(d1: D1Database) {
const db = createDb(d1);
const rows = await db
.select({ canonicalUrl: knowledgeSources.canonicalUrl })
.from(knowledgeSources)
.where(isNotNull(knowledgeSources.canonicalUrl))
.all();
return rows
.map((row) => row.canonicalUrl)
.filter((url): url is string => url !== null);
},

async list(d1: D1Database) {
const db = createDb(d1);
return await db
Expand Down
107 changes: 107 additions & 0 deletions server/src/repository/source-candidate-repository.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createTestDb, type TestDb } from "~/__tests__/helpers/test-db";

const { testDbHolder } = vi.hoisted(() => ({
testDbHolder: { db: null as TestDb | null },
}));

vi.mock("~/db", async (importOriginal) => {
const actual = await importOriginal<typeof import("~/db")>();
return {
...actual,
createDb: () => testDbHolder.db,
};
});

const { sourceCandidateRepository } = await import(
"./source-candidate-repository"
);

const d1 = {} as D1Database;

const baseCandidate = {
id: "cand-1",
url: "https://vill.example.jp/garbage",
lastSeenAt: "2026-09-01T00:00:00.000Z",
createdAt: "2026-09-01T00:00:00.000Z",
};

describe("sourceCandidateRepository", () => {
beforeEach(async () => {
testDbHolder.db = await createTestDb();
});

it("insert したものを findByUrl / findById で取得できる", async () => {
await sourceCandidateRepository.insert(d1, baseCandidate);

const byUrl = await sourceCandidateRepository.findByUrl(
d1,
baseCandidate.url,
);
expect(byUrl).toMatchObject({
id: "cand-1",
status: "pending",
occurrenceCount: 1,
});
expect(
await sourceCandidateRepository.findById(d1, "cand-1"),
).not.toBeNull();
});

it("list は出現回数の多い順に返す", async () => {
await sourceCandidateRepository.insert(d1, baseCandidate);
await sourceCandidateRepository.insert(d1, {
...baseCandidate,
id: "cand-2",
url: "https://vill.example.jp/bus",
occurrenceCount: 5,
});

const rows = await sourceCandidateRepository.list(d1);
expect(rows.map((r) => r.id)).toEqual(["cand-2", "cand-1"]);
});

it("upsertOccurrence は初回は 1 件目として登録する", async () => {
const row = await sourceCandidateRepository.upsertOccurrence(d1, {
url: baseCandidate.url,
relatedAnswerRunId: "ar-1",
});

expect(row).toMatchObject({
status: "pending",
occurrenceCount: 1,
relatedAnswerRunId: "ar-1",
});
});

it("upsertOccurrence は同一 URL の出現回数を SQL 側で加算する", async () => {
await sourceCandidateRepository.insert(d1, baseCandidate);

const row = await sourceCandidateRepository.upsertOccurrence(d1, {
url: baseCandidate.url,
relatedAnswerRunId: "ar-9",
});

expect(row).toMatchObject({
id: "cand-1",
occurrenceCount: 2,
relatedAnswerRunId: "ar-9",
});
expect(row.lastSeenAt).not.toBe(baseCandidate.lastSeenAt);
});

it("updateStatus は判断者と判断日時を記録する", async () => {
await sourceCandidateRepository.insert(d1, baseCandidate);

const updated = await sourceCandidateRepository.updateStatus(d1, "cand-1", {
status: "approved",
decidedBy: "admin-1",
});

expect(updated).toMatchObject({
status: "approved",
decidedBy: "admin-1",
});
expect(updated.decidedAt).not.toBeNull();
});
});
Loading
Loading