From e1334e0bac9d29e24decb9caf26f196b0f84feca Mon Sep 17 00:00:00 2001 From: owk-owk130 Date: Tue, 1 Sep 2026 22:43:28 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat(server):=20source=5Fcandidates=20?= =?UTF-8?q?=E3=83=86=E3=83=BC=E3=83=96=E3=83=AB=E3=82=92=E8=BF=BD=E5=8A=A0?= =?UTF-8?q?=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018BjNFvQjDzMwF8HaZHnJjP --- server/src/__tests__/helpers/test-db.ts | 13 +++++++++++++ .../src/db/migrations/0032_source_candidates.sql | 14 ++++++++++++++ server/src/db/schema.ts | 16 ++++++++++++++++ 3 files changed, 43 insertions(+) create mode 100644 server/src/db/migrations/0032_source_candidates.sql diff --git a/server/src/__tests__/helpers/test-db.ts b/server/src/__tests__/helpers/test-db.ts index c0ca4104..f71c4fec 100644 --- a/server/src/__tests__/helpers/test-db.ts +++ b/server/src/__tests__/helpers/test-db.ts @@ -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, diff --git a/server/src/db/migrations/0032_source_candidates.sql b/server/src/db/migrations/0032_source_candidates.sql new file mode 100644 index 00000000..d08f292a --- /dev/null +++ b/server/src/db/migrations/0032_source_candidates.sql @@ -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); diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 8052ff23..e46d7c29 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -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(), From 6de95613cc23d79eef306dee21eeec5619a8e2ad Mon Sep 17 00:00:00 2001 From: owk-owk130 Date: Tue, 1 Sep 2026 22:43:33 +0900 Subject: [PATCH 02/10] =?UTF-8?q?feat(server):=20URL=20=E6=AD=A3=E8=A6=8F?= =?UTF-8?q?=E5=8C=96=E3=83=98=E3=83=AB=E3=83=91=E3=82=92=E8=BF=BD=E5=8A=A0?= =?UTF-8?q?=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018BjNFvQjDzMwF8HaZHnJjP --- server/src/lib/url.test.ts | 31 +++++++++++++++++++++++++++++++ server/src/lib/url.ts | 18 ++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 server/src/lib/url.test.ts create mode 100644 server/src/lib/url.ts diff --git a/server/src/lib/url.test.ts b/server/src/lib/url.test.ts new file mode 100644 index 00000000..a8d7909a --- /dev/null +++ b/server/src/lib/url.test.ts @@ -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(); + }); +}); diff --git a/server/src/lib/url.ts b/server/src/lib/url.ts new file mode 100644 index 00000000..29951ce2 --- /dev/null +++ b/server/src/lib/url.ts @@ -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; + } +}; From 41958051f3894e8920468403fa909b56753a4eeb Mon Sep 17 00:00:00 2001 From: owk-owk130 Date: Tue, 1 Sep 2026 22:43:34 +0900 Subject: [PATCH 03/10] =?UTF-8?q?feat(server):=20=E6=83=85=E5=A0=B1?= =?UTF-8?q?=E6=BA=90=E5=80=99=E8=A3=9C=20repository=20=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018BjNFvQjDzMwF8HaZHnJjP --- .../repository/knowledge-source-repository.ts | 14 ++- .../source-candidate-repository.test.ts | 107 ++++++++++++++++++ .../repository/source-candidate-repository.ts | 106 +++++++++++++++++ 3 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 server/src/repository/source-candidate-repository.test.ts create mode 100644 server/src/repository/source-candidate-repository.ts diff --git a/server/src/repository/knowledge-source-repository.ts b/server/src/repository/knowledge-source-repository.ts index 6b201153..b3d61e6d 100644 --- a/server/src/repository/knowledge-source-repository.ts +++ b/server/src/repository/knowledge-source-repository.ts @@ -1,4 +1,4 @@ -import { asc, eq } from "drizzle-orm"; +import { asc, eq, isNotNull } from "drizzle-orm"; import { createDb, type KnowledgeSource, @@ -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 diff --git a/server/src/repository/source-candidate-repository.test.ts b/server/src/repository/source-candidate-repository.test.ts new file mode 100644 index 00000000..5dd8fb7c --- /dev/null +++ b/server/src/repository/source-candidate-repository.test.ts @@ -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(); + 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(); + }); +}); diff --git a/server/src/repository/source-candidate-repository.ts b/server/src/repository/source-candidate-repository.ts new file mode 100644 index 00000000..d38017c3 --- /dev/null +++ b/server/src/repository/source-candidate-repository.ts @@ -0,0 +1,106 @@ +import { desc, eq, sql } from "drizzle-orm"; +import { + createDb, + type NewSourceCandidate, + type SourceCandidate, + sourceCandidates, +} from "~/db"; + +export const SOURCE_CANDIDATE_STATUSES = [ + "pending", + "approved", + "rejected", +] as const; + +export type SourceCandidateStatus = (typeof SOURCE_CANDIDATE_STATUSES)[number]; + +export const sourceCandidateRepository = { + async list(d1: D1Database) { + const db = createDb(d1); + return await db + .select() + .from(sourceCandidates) + .orderBy( + desc(sourceCandidates.occurrenceCount), + desc(sourceCandidates.lastSeenAt), + ) + .all(); + }, + + async findById(d1: D1Database, id: string) { + const db = createDb(d1); + const result = await db + .select() + .from(sourceCandidates) + .where(eq(sourceCandidates.id, id)) + .get(); + return result ?? null; + }, + + async findByUrl(d1: D1Database, url: string) { + const db = createDb(d1); + const result = await db + .select() + .from(sourceCandidates) + .where(eq(sourceCandidates.url, url)) + .get(); + return result ?? null; + }, + + async insert(d1: D1Database, values: NewSourceCandidate) { + const db = createDb(d1); + return await db.insert(sourceCandidates).values(values).returning().get(); + }, + + async upsertOccurrence( + d1: D1Database, + input: { url: string; relatedAnswerRunId?: string }, + ) { + const db = createDb(d1); + const now = new Date().toISOString(); + return await db + .insert(sourceCandidates) + .values({ + id: crypto.randomUUID(), + url: input.url, + relatedAnswerRunId: input.relatedAnswerRunId, + lastSeenAt: now, + createdAt: now, + }) + .onConflictDoUpdate({ + target: sourceCandidates.url, + set: { + occurrenceCount: sql`${sourceCandidates.occurrenceCount} + 1`, + lastSeenAt: now, + updatedAt: now, + ...(input.relatedAnswerRunId && { + relatedAnswerRunId: input.relatedAnswerRunId, + }), + }, + }) + .returning() + .get(); + }, + + async updateStatus( + d1: D1Database, + id: string, + input: { status: SourceCandidateStatus; decidedBy: string }, + ) { + const db = createDb(d1); + const now = new Date().toISOString(); + return await db + .update(sourceCandidates) + .set({ + status: input.status, + decidedBy: input.decidedBy, + decidedAt: now, + updatedAt: now, + }) + .where(eq(sourceCandidates.id, id)) + .returning() + .get(); + }, +}; + +export type { SourceCandidate }; From 5f8756a59b45e3148d10d180949e588bbd86ec6a Mon Sep 17 00:00:00 2001 From: owk-owk130 Date: Tue, 1 Sep 2026 22:43:34 +0900 Subject: [PATCH 04/10] =?UTF-8?q?feat(server):=20Web=20=E6=A4=9C=E7=B4=A2?= =?UTF-8?q?=E3=81=AE=E8=AA=BF=E6=9F=BB=E3=83=A1=E3=83=A2=E3=81=8B=E3=82=89?= =?UTF-8?q?=E6=83=85=E5=A0=B1=E6=BA=90=E5=80=99=E8=A3=9C=E3=82=92=E6=A4=9C?= =?UTF-8?q?=E5=87=BA=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018BjNFvQjDzMwF8HaZHnJjP --- .../src/mastra/agents/web-researcher-agent.ts | 31 +++- .../knowledge/source-candidates.test.ts | 153 ++++++++++++++++++ .../services/knowledge/source-candidates.ts | 73 +++++++++ 3 files changed, 251 insertions(+), 6 deletions(-) create mode 100644 server/src/services/knowledge/source-candidates.test.ts create mode 100644 server/src/services/knowledge/source-candidates.ts diff --git a/server/src/mastra/agents/web-researcher-agent.ts b/server/src/mastra/agents/web-researcher-agent.ts index 5e85b1dc..f1b78849 100644 --- a/server/src/mastra/agents/web-researcher-agent.ts +++ b/server/src/mastra/agents/web-researcher-agent.ts @@ -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 = ` あなたはインターネットから最新情報を収集する専門エージェントです。 @@ -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({ @@ -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({}), }, diff --git a/server/src/services/knowledge/source-candidates.test.ts b/server/src/services/knowledge/source-candidates.test.ts new file mode 100644 index 00000000..085cb21a --- /dev/null +++ b/server/src/services/knowledge/source-candidates.test.ts @@ -0,0 +1,153 @@ +import { RequestContext } from "@mastra/core/request-context"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createTestDb, type TestDb } from "~/__tests__/helpers/test-db"; +import { sourceCandidates } from "~/db"; + +const { testDbHolder } = vi.hoisted(() => ({ + testDbHolder: { db: null as TestDb | null }, +})); + +vi.mock("~/db", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createDb: () => testDbHolder.db, + }; +}); + +vi.mock("~/lib/logger", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +const { knowledgeSourceRepository } = await import( + "~/repository/knowledge-source-repository" +); +const { captureSourceCandidates, extractUrls } = await import( + "./source-candidates" +); + +const d1 = {} as D1Database; + +const buildContext = (values: Record = {}) => { + const context = new RequestContext(); + context.set("db", d1); + for (const [key, value] of Object.entries(values)) { + context.set(key, value); + } + return context; +}; + +const seedSource = (sourcePath: string, canonicalUrl: string) => + knowledgeSourceRepository.insert(d1, { + sourcePath, + canonicalUrl, + approvalStatus: "approved", + createdAt: "2026-09-01T00:00:00.000Z", + }); + +describe("extractUrls", () => { + it("テキストから URL を抽出し正規化・重複排除する", () => { + const text = ` +情報源: +- https://example.com/a/ と https://example.com/a#top は同じ +- (https://example.com/b) +- https://example.com/c。 +`; + expect(extractUrls(text)).toEqual([ + "https://example.com/a", + "https://example.com/b", + "https://example.com/c", + ]); + }); + + it("URL が無ければ空配列", () => { + expect(extractUrls("URLなし")).toEqual([]); + }); +}); + +describe("captureSourceCandidates", () => { + beforeEach(async () => { + testDbHolder.db = await createTestDb(); + }); + + it("既知 host の未収集 URL を pending 候補として登録する", async () => { + await seedSource("bus/index.md", "https://vill.example.jp/bus"); + + await captureSourceCandidates( + buildContext({ answerRunId: "ar-1" }), + "詳細は https://vill.example.jp/garbage を参照", + ); + + const rows = await (testDbHolder.db as TestDb) + .select() + .from(sourceCandidates) + .all(); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + url: "https://vill.example.jp/garbage", + status: "pending", + occurrenceCount: 1, + relatedAnswerRunId: "ar-1", + }); + }); + + it("収集済み URL と未知 host の URL は候補にしない", async () => { + await seedSource("bus/index.md", "https://vill.example.jp/bus"); + + await captureSourceCandidates( + buildContext(), + "https://vill.example.jp/bus/ と https://other.example.com/page", + ); + + const rows = await (testDbHolder.db as TestDb) + .select() + .from(sourceCandidates) + .all(); + expect(rows).toHaveLength(0); + }); + + it("既存候補は出現回数を増やし answerRunId を更新する", async () => { + await seedSource("bus/index.md", "https://vill.example.jp/bus"); + + await captureSourceCandidates( + buildContext({ answerRunId: "ar-1" }), + "https://vill.example.jp/garbage", + ); + await captureSourceCandidates( + buildContext({ answerRunId: "ar-2" }), + "https://vill.example.jp/garbage", + ); + + const rows = await (testDbHolder.db as TestDb) + .select() + .from(sourceCandidates) + .all(); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + occurrenceCount: 2, + relatedAnswerRunId: "ar-2", + }); + }); + + it("db が無ければ何もしない", async () => { + await captureSourceCandidates( + new RequestContext(), + "https://vill.example.jp/garbage", + ); + + const rows = await (testDbHolder.db as TestDb) + .select() + .from(sourceCandidates) + .all(); + expect(rows).toHaveLength(0); + }); + + it("取得に失敗しても throw しない", async () => { + const context = buildContext(); + testDbHolder.db = null; + + await expect( + captureSourceCandidates(context, "https://vill.example.jp/garbage"), + ).resolves.toBeUndefined(); + }); +}); diff --git a/server/src/services/knowledge/source-candidates.ts b/server/src/services/knowledge/source-candidates.ts new file mode 100644 index 00000000..27eef3e5 --- /dev/null +++ b/server/src/services/knowledge/source-candidates.ts @@ -0,0 +1,73 @@ +import type { RequestContext } from "@mastra/core/request-context"; +import { logger } from "~/lib/logger"; +import { hostOf, normalizeUrl } from "~/lib/url"; +import { waitUntilInBackground } from "~/lib/wait-until"; +import { getRequestDb } from "~/mastra/request-context"; +import { knowledgeSourceRepository } from "~/repository/knowledge-source-repository"; +import { sourceCandidateRepository } from "~/repository/source-candidate-repository"; + +const URL_PATTERN = /https?:\/\/[^\s)>"'\]]+/g; + +export const extractUrls = (text: string) => [ + ...new Set( + (text.match(URL_PATTERN) ?? []) + .map((raw) => normalizeUrl(raw.replace(/[.,、。]+$/, ""))) + .filter((url): url is string => url !== null), + ), +]; + +export const captureSourceCandidates = async ( + requestContext: RequestContext | undefined, + text: string | undefined, +) => { + const d1 = getRequestDb(requestContext); + if (!d1 || !text) return; + + try { + const urls = extractUrls(text); + if (urls.length === 0) return; + + const canonicalUrls = await knowledgeSourceRepository.listCanonicalUrls(d1); + const knownUrls = new Set( + canonicalUrls + .map(normalizeUrl) + .filter((url): url is string => url !== null), + ); + const knownHosts = new Set( + [...knownUrls] + .map(hostOf) + .filter((host): host is string => host !== null), + ); + + const candidates = urls.filter((url) => { + const host = hostOf(url); + return host !== null && knownHosts.has(host) && !knownUrls.has(url); + }); + if (candidates.length === 0) return; + + const answerRunId = requestContext?.get("answerRunId") as + | string + | undefined; + + for (const url of candidates) { + const row = await sourceCandidateRepository.upsertOccurrence(d1, { + url, + relatedAnswerRunId: answerRunId, + }); + if (row.occurrenceCount === 1) { + logger.info(`[SourceCandidate] new candidate: ${url}`); + } + } + } catch (error) { + logger.warn("[SourceCandidate] failed to capture", { + error: String(error), + }); + } +}; + +export const captureSourceCandidatesInBackground = ( + requestContext: RequestContext | undefined, + text: string | undefined, +) => { + waitUntilInBackground(captureSourceCandidates(requestContext, text)); +}; From bd0d57a8a1bb931fd4c306e721573095f91bd4cd Mon Sep 17 00:00:00 2001 From: owk-owk130 Date: Tue, 1 Sep 2026 22:43:34 +0900 Subject: [PATCH 05/10] =?UTF-8?q?feat(server):=20=E6=83=85=E5=A0=B1?= =?UTF-8?q?=E6=BA=90=E5=80=99=E8=A3=9C=E3=81=AE=E7=AE=A1=E7=90=86=20API=20?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018BjNFvQjDzMwF8HaZHnJjP --- server/src/index.ts | 2 + server/src/routes/admin/index.ts | 1 + .../routes/admin/source-candidates.test.ts | 158 ++++++++++++++++++ server/src/routes/admin/source-candidates.ts | 134 +++++++++++++++ server/src/routes/index.ts | 1 + 5 files changed, 296 insertions(+) create mode 100644 server/src/routes/admin/source-candidates.test.ts create mode 100644 server/src/routes/admin/source-candidates.ts diff --git a/server/src/index.ts b/server/src/index.ts index c9c0d3c8..f7a310ff 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -28,6 +28,7 @@ import { pollAdminRoutes, pollRoutes, reviewAdminRoutes, + sourceCandidatesAdminRoutes, threadsRoutes, twilioVoiceRoutes, userAdminRoutes, @@ -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); diff --git a/server/src/routes/admin/index.ts b/server/src/routes/admin/index.ts index f98d9c5e..1c735c87 100644 --- a/server/src/routes/admin/index.ts +++ b/server/src/routes/admin/index.ts @@ -8,5 +8,6 @@ export { knowledgeAdminRoutes } from "./knowledge"; export { personaAdminRoutes } from "./persona"; export { pollAdminRoutes } from "./poll"; export { reviewAdminRoutes } from "./review"; +export { sourceCandidatesAdminRoutes } from "./source-candidates"; export { userAdminRoutes } from "./users"; export { widgetSiteAdminRoutes } from "./widget-sites"; diff --git a/server/src/routes/admin/source-candidates.test.ts b/server/src/routes/admin/source-candidates.test.ts new file mode 100644 index 00000000..13ad7911 --- /dev/null +++ b/server/src/routes/admin/source-candidates.test.ts @@ -0,0 +1,158 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("~/repository/source-candidate-repository", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("~/repository/source-candidate-repository") + >(); + return { + ...actual, + sourceCandidateRepository: { + list: vi.fn(), + findById: vi.fn(), + updateStatus: vi.fn(), + }, + }; +}); + +vi.mock("~/repository/admin-session-repository", () => ({ + adminSessionRepository: { findValid: vi.fn() }, +})); + +vi.mock("~/repository/admin-user-repository", () => ({ + adminUserRepository: { findById: vi.fn() }, +})); + +vi.mock("~/services/auth/anonymous-session", () => ({ + verifyAnonymousToken: vi.fn(), +})); + +const { sourceCandidateRepository } = await import( + "~/repository/source-candidate-repository" +); +const { adminSessionRepository } = await import( + "~/repository/admin-session-repository" +); +const { adminUserRepository } = await import( + "~/repository/admin-user-repository" +); +const { sourceCandidatesAdminRoutes } = await import("./source-candidates"); + +import { withResolvePrincipal } from "~/__tests__/helpers/test-app"; + +const app = await withResolvePrincipal(sourceCandidatesAdminRoutes); + +const testUser = { + id: "user-1", + username: "admin01", + name: "管理者", + role: "admin", + passwordHash: "100000:salt:hash", + createdAt: "2024-01-01T00:00:00Z", + updatedAt: null, +}; + +const mockEnv = { + DB: {} as D1Database, + JWT_SECRET: "test-secret-32-chars-long-enough", +} as unknown as CloudflareBindings; + +const VALID_OPAQUE_TOKEN = "a".repeat(64); + +const authedRequest = (path: string, init?: RequestInit) => { + const req = new Request(`http://localhost${path}`, init); + req.headers.set("Authorization", `Bearer ${VALID_OPAQUE_TOKEN}`); + return req; +}; + +const candidate = { + id: "cand-1", + url: "https://vill.example.jp/garbage", + status: "pending", + occurrenceCount: 3, + relatedAnswerRunId: "ar-1", + decidedBy: null, + decidedAt: null, + lastSeenAt: "2026-09-01T00:00:00.000Z", + createdAt: "2026-09-01T00:00:00.000Z", + updatedAt: null, +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(adminSessionRepository.findValid).mockResolvedValue({ + token: VALID_OPAQUE_TOKEN, + userId: "user-1", + expiresAt: new Date(Date.now() + 86400000).toISOString(), + createdAt: "2024-01-01T00:00:00Z", + }); + vi.mocked(adminUserRepository.findById).mockResolvedValue(testUser); +}); + +describe("GET /", () => { + it("認証なしは 401", async () => { + const res = await app.request( + new Request("http://localhost/"), + undefined, + mockEnv, + ); + expect(res.status).toBe(401); + }); + + it("候補一覧を返す", async () => { + vi.mocked(sourceCandidateRepository.list).mockResolvedValue([candidate]); + + const res = await app.request(authedRequest("/"), undefined, mockEnv); + + expect(res.status).toBe(200); + const body = (await res.json()) as { candidates: unknown[] }; + expect(body.candidates[0]).toMatchObject({ + url: "https://vill.example.jp/garbage", + occurrenceCount: 3, + }); + }); +}); + +describe("PATCH /{id}/status", () => { + const patchBody = (action: string): RequestInit => ({ + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action }), + }); + + it("候補が無ければ 404", async () => { + vi.mocked(sourceCandidateRepository.findById).mockResolvedValue(null); + + const res = await app.request( + authedRequest("/missing/status", patchBody("approve")), + undefined, + mockEnv, + ); + expect(res.status).toBe(404); + }); + + it.each([ + ["approve", "approved"], + ["reject", "rejected"], + ] as const)("%s で %s に更新する", async (action, status) => { + vi.mocked(sourceCandidateRepository.findById).mockResolvedValue(candidate); + vi.mocked(sourceCandidateRepository.updateStatus).mockResolvedValue({ + ...candidate, + status, + decidedBy: "user-1", + }); + + const res = await app.request( + authedRequest("/cand-1/status", patchBody(action)), + undefined, + mockEnv, + ); + + expect(res.status).toBe(200); + expect(sourceCandidateRepository.updateStatus).toHaveBeenCalledWith( + mockEnv.DB, + "cand-1", + { status, decidedBy: "user-1" }, + ); + }); +}); diff --git a/server/src/routes/admin/source-candidates.ts b/server/src/routes/admin/source-candidates.ts new file mode 100644 index 00000000..49e312d5 --- /dev/null +++ b/server/src/routes/admin/source-candidates.ts @@ -0,0 +1,134 @@ +import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi"; +import { HTTPException } from "hono/http-exception"; + +import { errorResponse } from "~/lib/openapi-errors"; +import type { PrincipalVariables } from "~/lib/principal"; +import { requireAdminUser } from "~/lib/principal"; +import { requireRole } from "~/middleware/require-role"; +import { + SOURCE_CANDIDATE_STATUSES, + type SourceCandidate, + type SourceCandidateStatus, + sourceCandidateRepository, +} from "~/repository/source-candidate-repository"; + +export const sourceCandidatesAdminRoutes = new OpenAPIHono<{ + Bindings: CloudflareBindings; + Variables: Partial; +}>(); + +sourceCandidatesAdminRoutes.use("*", requireRole("admin")); + +const CandidateSchema = z.object({ + id: z.string(), + url: z.string(), + status: z.enum(SOURCE_CANDIDATE_STATUSES), + occurrenceCount: z.number(), + relatedAnswerRunId: z.string().nullable(), + decidedBy: z.string().nullable(), + decidedAt: z.string().nullable(), + lastSeenAt: z.string(), + createdAt: z.string(), + updatedAt: z.string().nullable(), +}); + +const toCandidateResponse = (candidate: SourceCandidate) => ({ + id: candidate.id, + url: candidate.url, + status: candidate.status as SourceCandidateStatus, + occurrenceCount: candidate.occurrenceCount, + relatedAnswerRunId: candidate.relatedAnswerRunId, + decidedBy: candidate.decidedBy, + decidedAt: candidate.decidedAt, + lastSeenAt: candidate.lastSeenAt, + createdAt: candidate.createdAt, + updatedAt: candidate.updatedAt, +}); + +const listRoute = createRoute({ + method: "get", + path: "/", + summary: "情報源候補の一覧を取得", + description: "Web 検索の回答で参照された、未収集の公式ページ候補を一覧します", + tags: ["Admin - Source Candidates"], + responses: { + 200: { + description: "取得成功", + content: { + "application/json": { + schema: z.object({ candidates: z.array(CandidateSchema) }), + }, + }, + }, + 401: errorResponse(401), + 403: errorResponse(403), + }, +}); + +sourceCandidatesAdminRoutes.openapi(listRoute, async (c) => { + const candidates = await sourceCandidateRepository.list(c.env.DB); + return c.json({ candidates: candidates.map(toCandidateResponse) }, 200); +}); + +const updateStatusRoute = createRoute({ + method: "patch", + path: "/{id}/status", + summary: "情報源候補の承認状態を変更", + description: + "承認・却下の判断を記録します。承認済み候補の収集は通常の取り込み手順で行います", + tags: ["Admin - Source Candidates"], + request: { + params: z.object({ id: z.string().min(1) }), + body: { + content: { + "application/json": { + schema: z.object({ action: z.enum(["approve", "reject"]) }), + }, + }, + required: true, + }, + }, + responses: { + 200: { + description: "変更成功", + content: { + "application/json": { + schema: z.object({ + message: z.string(), + candidate: CandidateSchema, + }), + }, + }, + }, + 401: errorResponse(401), + 403: errorResponse(403), + 404: errorResponse(404), + }, +}); + +sourceCandidatesAdminRoutes.openapi(updateStatusRoute, async (c) => { + const { id } = c.req.valid("param"); + const { action } = c.req.valid("json"); + const adminUser = requireAdminUser(c.get("principal")); + + const candidate = await sourceCandidateRepository.findById(c.env.DB, id); + if (!candidate) { + throw new HTTPException(404, { message: "情報源候補が見つかりません" }); + } + + const updated = await sourceCandidateRepository.updateStatus(c.env.DB, id, { + status: action === "approve" ? "approved" : "rejected", + decidedBy: adminUser.id, + }); + + return c.json( + { + message: + action === "approve" + ? "承認しました。収集対象への追加は通常の取り込み手順で行ってください" + : "却下しました", + candidate: toCandidateResponse(updated), + }, + 200, + ); +}); diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index 576140bb..39f544da 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -9,6 +9,7 @@ export { personaAdminRoutes, pollAdminRoutes, reviewAdminRoutes, + sourceCandidatesAdminRoutes, userAdminRoutes, widgetSiteAdminRoutes, } from "./admin"; From a406aa9f68d0808a0ca2d99e6cab870ea751deec Mon Sep 17 00:00:00 2001 From: owk-owk130 Date: Tue, 1 Sep 2026 22:43:35 +0900 Subject: [PATCH 06/10] =?UTF-8?q?feat(server):=20=E5=85=A8=E5=90=8C?= =?UTF-8?q?=E6=9C=9F=E3=81=AE=E3=82=B9=E3=82=AD=E3=83=83=E3=83=97=E4=BB=B6?= =?UTF-8?q?=E6=95=B0=E3=82=92=E3=83=AC=E3=82=B9=E3=83=9D=E3=83=B3=E3=82=B9?= =?UTF-8?q?=E3=81=A7=E5=8F=AF=E8=A6=96=E5=8C=96=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018BjNFvQjDzMwF8HaZHnJjP --- server/src/routes/admin/knowledge/sync.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/server/src/routes/admin/knowledge/sync.ts b/server/src/routes/admin/knowledge/sync.ts index 48f2e01f..0dfdf652 100644 --- a/server/src/routes/admin/knowledge/sync.ts +++ b/server/src/routes/admin/knowledge/sync.ts @@ -64,6 +64,7 @@ const syncAllRoute = createRoute({ }), ), editedCount: z.number().optional(), + skippedCount: z.number().optional(), }), }, }, @@ -85,9 +86,13 @@ knowledgeSyncRoutes.openapi(syncAllRoute, async (c) => { return c.json( { - message: `${result.totalFiles}ファイル、${result.totalChunks}チャンクを同期しました`, + message: + result.skippedCount > 0 + ? `${result.totalFiles}ファイル中${result.skippedCount}件は未承認のためスキップし、${result.totalChunks}チャンクを同期しました` + : `${result.totalFiles}ファイル、${result.totalChunks}チャンクを同期しました`, results: result.results, editedCount: result.editedCount, + skippedCount: result.skippedCount, }, 200, ); From 058eb9ba91bc464063f5a791bc1657cd832eb3a6 Mon Sep 17 00:00:00 2001 From: owk-owk130 Date: Tue, 1 Sep 2026 22:43:35 +0900 Subject: [PATCH 07/10] =?UTF-8?q?feat(shared):=20source-candidates=20API?= =?UTF-8?q?=20=E3=81=AE=E3=82=AF=E3=83=A9=E3=82=A4=E3=82=A2=E3=83=B3?= =?UTF-8?q?=E3=83=88=E3=81=A8=E5=9E=8B=E3=82=92=E8=BF=BD=E5=8A=A0=E3=81=99?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018BjNFvQjDzMwF8HaZHnJjP --- .../repository/source-candidate-repository.ts | 28 +++ shared/src/api/types.d.ts | 186 ++++++++++++++++++ shared/src/api/types.ts | 4 + web/src/lib/api/repository.ts | 3 + 4 files changed, 221 insertions(+) create mode 100644 shared/src/api/repository/source-candidate-repository.ts diff --git a/shared/src/api/repository/source-candidate-repository.ts b/shared/src/api/repository/source-candidate-repository.ts new file mode 100644 index 00000000..70082110 --- /dev/null +++ b/shared/src/api/repository/source-candidate-repository.ts @@ -0,0 +1,28 @@ +import type { ApiClient } from "../create-client"; + +export const createSourceCandidateRepository = (client: ApiClient) => ({ + fetchSourceCandidates: async () => { + const { data, error } = await client.GET("/admin/source-candidates"); + if (error) throw error; + return data; + }, + + updateSourceCandidateStatus: async (params: { + id: string; + action: "approve" | "reject"; + }) => { + const { data, error } = await client.PATCH( + "/admin/source-candidates/{id}/status", + { + params: { path: { id: params.id } }, + body: { action: params.action }, + }, + ); + if (error) throw error; + return data; + }, +}); + +export type SourceCandidateRepository = ReturnType< + typeof createSourceCandidateRepository +>; diff --git a/shared/src/api/types.d.ts b/shared/src/api/types.d.ts index f97a582f..58aa1ee9 100644 --- a/shared/src/api/types.d.ts +++ b/shared/src/api/types.d.ts @@ -4771,6 +4771,192 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/source-candidates": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * 情報源候補の一覧を取得 + * @description Web 検索の回答で参照された、未収集の公式ページ候補を一覧します + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description 取得成功 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + candidates: { + id: string; + url: string; + /** @enum {string} */ + status: "pending" | "approved" | "rejected"; + occurrenceCount: number; + relatedAnswerRunId: string | null; + decidedBy: string | null; + decidedAt: string | null; + lastSeenAt: string; + createdAt: string; + updatedAt: string | null; + }[]; + }; + }; + }; + /** @description 認証エラー */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: { + code: number; + message: string; + }; + }; + }; + }; + /** @description 権限エラー */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: { + code: number; + message: string; + }; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/source-candidates/{id}/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * 情報源候補の承認状態を変更 + * @description 承認した候補はナレッジ収集の対象として scraper パイプラインに追加します + */ + patch: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @enum {string} */ + action: "approve" | "reject"; + }; + }; + }; + responses: { + /** @description 変更成功 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + message: string; + candidate: { + id: string; + url: string; + /** @enum {string} */ + status: "pending" | "approved" | "rejected"; + occurrenceCount: number; + relatedAnswerRunId: string | null; + decidedBy: string | null; + decidedAt: string | null; + lastSeenAt: string; + createdAt: string; + updatedAt: string | null; + }; + }; + }; + }; + /** @description 認証エラー */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: { + code: number; + message: string; + }; + }; + }; + }; + /** @description 権限エラー */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: { + code: number; + message: string; + }; + }; + }; + }; + /** @description リソースが見つかりません */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: { + code: number; + message: string; + }; + }; + }; + }; + }; + }; + trace?: never; + }; "/admin/emergency": { parameters: { query?: never; diff --git a/shared/src/api/types.ts b/shared/src/api/types.ts index 643be0bc..602e4b19 100644 --- a/shared/src/api/types.ts +++ b/shared/src/api/types.ts @@ -143,5 +143,9 @@ export type ReviewDecisionType = "no_issue" | "incorrect" | "source_missing"; export type CorrectionsResponse = GetOk<"/admin/corrections">; export type KnowledgeCorrection = CorrectionsResponse["corrections"][number]; +// 情報源候補 +export type SourceCandidatesResponse = GetOk<"/admin/source-candidates">; +export type SourceCandidate = SourceCandidatesResponse["candidates"][number]; + // OpenAPI paths 自体も再エクスポート export type { paths } from "./types.d"; diff --git a/web/src/lib/api/repository.ts b/web/src/lib/api/repository.ts index 904131e1..2a114d55 100644 --- a/web/src/lib/api/repository.ts +++ b/web/src/lib/api/repository.ts @@ -9,6 +9,7 @@ import { createKnowledgeRepository } from "@nepp-chan/shared/api/repository/know import { createPersonaRepository } from "@nepp-chan/shared/api/repository/persona-repository"; import { createPollRepository } from "@nepp-chan/shared/api/repository/poll-repository"; import { createReviewRepository } from "@nepp-chan/shared/api/repository/review-repository"; +import { createSourceCandidateRepository } from "@nepp-chan/shared/api/repository/source-candidate-repository"; import { createThreadRepository } from "@nepp-chan/shared/api/repository/thread-repository"; import { createWidgetSiteRepository } from "@nepp-chan/shared/api/repository/widget-site-repository"; @@ -29,5 +30,7 @@ export const knowledgeRepository = createKnowledgeRepository(client, API_BASE); export const personaRepository = createPersonaRepository(client); export const pollRepository = createPollRepository(client); export const reviewRepository = createReviewRepository(client); +export const sourceCandidateRepository = + createSourceCandidateRepository(client); export const threadRepository = createThreadRepository(client); export const widgetSiteRepository = createWidgetSiteRepository(client); From 16a0c885d2461f9adb5d4e7829f690ceba66f4fc Mon Sep 17 00:00:00 2001 From: owk-owk130 Date: Tue, 1 Sep 2026 22:43:35 +0900 Subject: [PATCH 08/10] =?UTF-8?q?feat(web):=20=E6=83=85=E5=A0=B1=E6=BA=90?= =?UTF-8?q?=E5=80=99=E8=A3=9C=E3=81=AE=E7=AE=A1=E7=90=86=E7=94=BB=E9=9D=A2?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018BjNFvQjDzMwF8HaZHnJjP --- web/src/app/dashboard/App.tsx | 11 ++ .../components/SourceCandidatesPanel.test.tsx | 108 ++++++++++++++++ .../components/SourceCandidatesPanel.tsx | 120 ++++++++++++++++++ web/src/app/dashboard/hooks/keys.ts | 1 + .../hooks/useSourceCandidates.test.ts | 63 +++++++++ .../dashboard/hooks/useSourceCandidates.ts | 22 ++++ 6 files changed, 325 insertions(+) create mode 100644 web/src/app/dashboard/components/SourceCandidatesPanel.test.tsx create mode 100644 web/src/app/dashboard/components/SourceCandidatesPanel.tsx create mode 100644 web/src/app/dashboard/hooks/useSourceCandidates.test.ts create mode 100644 web/src/app/dashboard/hooks/useSourceCandidates.ts diff --git a/web/src/app/dashboard/App.tsx b/web/src/app/dashboard/App.tsx index 412d5bdc..c6ecc632 100644 --- a/web/src/app/dashboard/App.tsx +++ b/web/src/app/dashboard/App.tsx @@ -7,6 +7,7 @@ import { ChatBubbleLeftIcon, ChatBubbleLeftRightIcon, ClipboardDocumentCheckIcon, + DocumentPlusIcon, EnvelopeIcon, GlobeAltIcon, HandThumbUpIcon, @@ -33,6 +34,7 @@ import { } from "~/app/dashboard/components/mayor/MayorChatPanel"; import { PollPanel } from "~/app/dashboard/components/PollPanel"; import { ReviewPanel } from "~/app/dashboard/components/ReviewPanel"; +import { SourceCandidatesPanel } from "~/app/dashboard/components/SourceCandidatesPanel"; import { UsagePanel } from "~/app/dashboard/components/UsagePanel"; import { VoicesPanel } from "~/app/dashboard/components/VoicesPanel"; import type { VoiceFilter } from "~/app/dashboard/components/voices/helpers"; @@ -51,6 +53,7 @@ export type Tab = | "feedback" | "review" | "corrections" + | "source-candidates" | "invitations" | "widget-sites" | "usage"; @@ -123,6 +126,13 @@ const tabs: { group: "watch", minRole: "admin", }, + { + id: "source-candidates", + label: "情報源候補", + icon: