diff --git a/admin/src/App.tsx b/admin/src/App.tsx index 564bd175..04be6ee6 100644 --- a/admin/src/App.tsx +++ b/admin/src/App.tsx @@ -6,6 +6,7 @@ import AiModels from "./pages/ai-models"; import Users from "./pages/users"; import AuditLogs from "./pages/audit-logs"; import WikiHealth from "./pages/wiki-health"; +import ActivityLog from "./pages/ActivityLog"; /** * Root component for the admin SPA: sets up routing and the admin auth guard. @@ -31,6 +32,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/admin/src/api/activity.ts b/admin/src/api/activity.ts new file mode 100644 index 00000000..235c3ec1 --- /dev/null +++ b/admin/src/api/activity.ts @@ -0,0 +1,84 @@ +import { adminFetch } from "./client"; + +/** + * 活動ログの種別。 + * Activity kind enumeration. + */ +export type ActivityKind = + | "clip_ingest" + | "chat_promote" + | "lint_run" + | "wiki_generate" + | "index_build" + | "wiki_schema_update"; + +/** + * 活動ログの起点(user / ai / system)。 + * Activity actor. + */ +export type ActivityActor = "user" | "ai" | "system"; + +/** + * 1 件の活動ログ。 + * A single activity entry. + */ +export interface ActivityEntry { + id: string; + kind: ActivityKind; + actor: ActivityActor; + target_page_ids: string[]; + detail: Record | null; + created_at: string; +} + +/** + * 活動ログ取得レスポンス。 + * Response from GET /api/activity. + */ +export interface ActivityListResponse { + entries: ActivityEntry[]; + total: number; + limit: number; +} + +/** + * 一覧クエリパラメータ。 + * List query parameters. + */ +export interface ActivityListParams { + kind?: ActivityKind; + actor?: ActivityActor; + from?: string; + to?: string; + limit?: number; + offset?: number; +} + +async function getErrorMessage(res: Response, fallback: string): Promise { + const err = await res.json().catch(() => ({ message: res.statusText })); + return (err as { message?: string }).message ?? fallback; +} + +/** + * 自分の活動ログを取得する。 + * Fetches the current user's activity entries. + * + * @param params - フィルタ・ページング / Filters and paging + */ +export async function listActivity(params: ActivityListParams = {}): Promise { + const query = new URLSearchParams(); + if (params.kind) query.set("kind", params.kind); + if (params.actor) query.set("actor", params.actor); + if (params.from) query.set("from", params.from); + if (params.to) query.set("to", params.to); + if (typeof params.limit === "number") query.set("limit", String(params.limit)); + if (typeof params.offset === "number") query.set("offset", String(params.offset)); + const qs = query.toString(); + const path = qs ? `/api/activity?${qs}` : "/api/activity"; + + const res = await adminFetch(path); + if (!res.ok) { + throw new Error(await getErrorMessage(res, "Failed to fetch activity entries")); + } + return res.json(); +} diff --git a/admin/src/api/lint.ts b/admin/src/api/lint.ts index c65a03a3..0120e3b1 100644 --- a/admin/src/api/lint.ts +++ b/admin/src/api/lint.ts @@ -4,7 +4,13 @@ import { adminFetch } from "./client"; * Lint ルール名の型。 * Lint rule name type. */ -export type LintRule = "orphan" | "ghost_many" | "title_similar" | "conflict" | "broken_link"; +export type LintRule = + | "orphan" + | "ghost_many" + | "title_similar" + | "conflict" + | "broken_link" + | "stale"; /** * Lint 重要度の型。 diff --git a/admin/src/pages/ActivityLog.tsx b/admin/src/pages/ActivityLog.tsx new file mode 100644 index 00000000..f7091818 --- /dev/null +++ b/admin/src/pages/ActivityLog.tsx @@ -0,0 +1,218 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { + Badge, + Button, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@zedi/ui"; +import { + listActivity, + type ActivityActor, + type ActivityEntry, + type ActivityKind, +} from "@/api/activity"; +import { formatDate } from "@/lib/dateUtils"; + +/** + * ルール/起点のラベルマップ。 + * Labels for activity kind and actor. + */ +const KIND_LABELS: Record = { + clip_ingest: "クリップ取り込み / Clip ingest", + chat_promote: "Chat → Wiki 昇格 / Chat promote", + lint_run: "Lint 実行 / Lint run", + wiki_generate: "Wiki 生成 / Wiki generate", + index_build: "Index 構築 / Index build", + wiki_schema_update: "スキーマ更新 / Schema update", +}; +const ACTOR_LABELS: Record = { + user: "ユーザー / User", + ai: "AI", + system: "システム / System", +}; + +const ANY = "__any__"; +const KINDS: ActivityKind[] = [ + "clip_ingest", + "chat_promote", + "lint_run", + "wiki_generate", + "index_build", + "wiki_schema_update", +]; +const ACTORS: ActivityActor[] = ["user", "ai", "system"]; + +/** + * detail JSON からサマリ文字列を組み立てる。 + * Builds a human-readable summary from a detail payload. + */ +function formatDetail(entry: ActivityEntry): string { + const detail = entry.detail ?? {}; + if (entry.kind === "lint_run" && typeof detail.total === "number") { + return `${detail.total} findings`; + } + if (entry.kind === "index_build") { + const total = typeof detail.totalPages === "number" ? detail.totalPages : "?"; + const cats = typeof detail.categoryCount === "number" ? detail.categoryCount : "?"; + return `${total} pages / ${cats} categories`; + } + if (entry.kind === "clip_ingest" || entry.kind === "chat_promote") { + const title = typeof detail.title === "string" ? detail.title : null; + const url = typeof detail.url === "string" ? detail.url : null; + return [title, url].filter(Boolean).join(" — ") || "—"; + } + if (entry.kind === "wiki_schema_update") { + const len = typeof detail.contentLength === "number" ? detail.contentLength : "?"; + return `content: ${len} chars`; + } + return JSON.stringify(detail); +} + +/** + * 管理画面「活動ログ」ページ。`__index__` 再構築ボタンも備える。 + * Admin Activity Log page. + */ +export default function ActivityLog() { + const [entries, setEntries] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [kindFilter, setKindFilter] = useState(undefined); + const [actorFilter, setActorFilter] = useState(undefined); + + const mountedRef = useRef(true); + + const load = useCallback(async () => { + if (mountedRef.current) setLoading(true); + if (mountedRef.current) setError(null); + try { + const result = await listActivity({ kind: kindFilter, actor: actorFilter, limit: 100 }); + if (!mountedRef.current) return; + setEntries(result.entries); + setTotal(result.total); + } catch (e) { + if (!mountedRef.current) return; + setError(e instanceof Error ? e.message : String(e)); + } finally { + if (mountedRef.current) setLoading(false); + } + }, [kindFilter, actorFilter]); + + useEffect(() => { + mountedRef.current = true; + void load(); + return () => { + mountedRef.current = false; + }; + }, [load]); + + const onKind = (v: string) => setKindFilter(v === ANY ? undefined : (v as ActivityKind)); + const onActor = (v: string) => setActorFilter(v === ANY ? undefined : (v as ActivityActor)); + + return ( +
+
+

Activity Log / 活動ログ

+ +
+ +
+
+ + +
+
+ + +
+
+ + {error && ( +
{error}
+ )} + + {loading && entries.length === 0 ? ( +

読み込み中...

+ ) : entries.length === 0 ? ( +

活動ログはまだありません。

+ ) : ( +
+ + + + 種別 + 起点 + 詳細 + 関連ページ + 記録日時 + + + + {entries.map((entry) => ( + + + {KIND_LABELS[entry.kind]} + + + {ACTOR_LABELS[entry.actor]} + + + {formatDetail(entry)} + + + {entry.target_page_ids.length} + + + {formatDate(entry.created_at)} + + + ))} + +
+

+ 表示 {entries.length} / 合計 {total} 件 +

+
+ )} +
+ ); +} diff --git a/admin/src/pages/Layout.tsx b/admin/src/pages/Layout.tsx index de0b07d2..634a02f8 100644 --- a/admin/src/pages/Layout.tsx +++ b/admin/src/pages/Layout.tsx @@ -1,5 +1,5 @@ import { Outlet, Link, useLocation } from "react-router-dom"; -import { Bot, Users, ScrollText, HeartPulse } from "lucide-react"; +import { Bot, Users, ScrollText, HeartPulse, Activity } from "lucide-react"; import { SidebarProvider, Sidebar, @@ -20,6 +20,7 @@ const navLinks = [ { to: "/users", label: "ユーザー管理", icon: Users }, { to: "/audit-logs", label: "監査ログ", icon: ScrollText }, { to: "/wiki-health", label: "Wiki Health", icon: HeartPulse }, + { to: "/activity-log", label: "活動ログ", icon: Activity }, ]; /** diff --git a/admin/src/pages/wiki-health/WikiHealthContent.tsx b/admin/src/pages/wiki-health/WikiHealthContent.tsx index 7af31c03..0106d79e 100644 --- a/admin/src/pages/wiki-health/WikiHealthContent.tsx +++ b/admin/src/pages/wiki-health/WikiHealthContent.tsx @@ -26,6 +26,7 @@ const RULE_LABELS: Record = { title_similar: "タイトル類似 / Title Similar", conflict: "矛盾 / Conflict", broken_link: "リンク切れ / Broken Link", + stale: "古い情報 / Stale", }; /** @@ -104,7 +105,7 @@ export function WikiHealthContent({ {/* サマリ表示 / Summary display */} {summary && ( -
+
{summary.map((s) => (
{RULE_LABELS[s.rule]}
diff --git a/server/api/drizzle/0010_add_activity_log.sql b/server/api/drizzle/0010_add_activity_log.sql new file mode 100644 index 00000000..60d05392 --- /dev/null +++ b/server/api/drizzle/0010_add_activity_log.sql @@ -0,0 +1,25 @@ +-- Add `activity_log` table for Wiki-level actions (P4, otomatty/zedi#598). +-- Wiki レベルの行動履歴(ingest / chat 昇格 / lint 実行 / wiki 生成等)を記録する +-- append-only ログ。`ai_usage_logs`(課金)や `admin_audit_logs`(管理者監査) +-- とは用途が異なり、ユーザーの Wiki がどう育ったかを時系列で可視化する。 +-- +-- See parent epic otomatty/zedi#594, sub-issue #598. + +CREATE TABLE "activity_log" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "owner_id" text NOT NULL, + "kind" text NOT NULL, + "actor" text NOT NULL, + "target_page_ids" text[] DEFAULT '{}' NOT NULL, + "detail" jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "activity_log" + ADD CONSTRAINT "activity_log_owner_id_user_id_fk" + FOREIGN KEY ("owner_id") REFERENCES "user"("id") ON DELETE cascade ON UPDATE no action; +--> statement-breakpoint +CREATE INDEX "idx_activity_log_owner_created" ON "activity_log" ("owner_id", "created_at" DESC); +--> statement-breakpoint +CREATE INDEX "idx_activity_log_owner_kind_created" + ON "activity_log" ("owner_id", "kind", "created_at" DESC); diff --git a/server/api/drizzle/0011_add_page_special_kind.sql b/server/api/drizzle/0011_add_page_special_kind.sql new file mode 100644 index 00000000..3951ca65 --- /dev/null +++ b/server/api/drizzle/0011_add_page_special_kind.sql @@ -0,0 +1,14 @@ +-- Add `special_kind` column to `pages` for Wiki "index" / "log" pseudo-pages +-- (P4, otomatty/zedi#598). +-- Karpathy LLM Wiki パターンの `__index__` / `__log__` 相当の特殊ページを +-- `pages` テーブル内で表現するためのカラム。NULL は通常ページ。 + +ALTER TABLE "pages" ADD COLUMN "special_kind" text; +--> statement-breakpoint +CREATE INDEX "idx_pages_owner_special_kind" ON "pages" ("owner_id", "special_kind"); +--> statement-breakpoint +-- 部分ユニークインデックス: オーナーごとに各 special_kind は最大 1 行(非削除のもの)。 +-- Partial unique: at most one row per (owner_id, special_kind) among non-deleted pages. +CREATE UNIQUE INDEX "idx_pages_unique_special_kind_per_owner" + ON "pages" ("owner_id", "special_kind") + WHERE "special_kind" IS NOT NULL AND "is_deleted" = false; diff --git a/server/api/drizzle/meta/_journal.json b/server/api/drizzle/meta/_journal.json index 0dbf68cd..efd94191 100644 --- a/server/api/drizzle/meta/_journal.json +++ b/server/api/drizzle/meta/_journal.json @@ -57,6 +57,27 @@ "when": 1776556800000, "tag": "0008_add_lint_findings", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1776643200000, + "tag": "0009_add_page_is_schema", + "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1776729600000, + "tag": "0010_add_activity_log", + "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1776816000000, + "tag": "0011_add_page_special_kind", + "breakpoints": true } ] } diff --git a/server/api/src/__tests__/services/activityLogService.test.ts b/server/api/src/__tests__/services/activityLogService.test.ts new file mode 100644 index 00000000..7694577a --- /dev/null +++ b/server/api/src/__tests__/services/activityLogService.test.ts @@ -0,0 +1,109 @@ +/** + * activityLogService の単体テスト。 + * Unit tests for activityLogService (recordActivity + listActivityForOwner). + */ +import { describe, it, expect, vi } from "vitest"; +import { createMockDb } from "../createMockDb.js"; +import { + recordActivity, + listActivityForOwner, + ACTIVITY_LIST_DEFAULT_LIMIT, + ACTIVITY_LIST_MAX_LIMIT, +} from "../../services/activityLogService.js"; + +describe("recordActivity", () => { + it("inserts a row and returns the inserted record", async () => { + const expected = { + id: "act-1", + ownerId: "user-1", + kind: "lint_run" as const, + actor: "user" as const, + targetPageIds: [], + detail: { total: 3 }, + createdAt: new Date("2026-04-17T00:00:00Z"), + }; + const { db } = createMockDb([[expected]]); + + const result = await recordActivity(db as never, { + ownerId: "user-1", + kind: "lint_run", + actor: "user", + detail: { total: 3 }, + }); + expect(result).toEqual(expected); + }); + + it("returns null and does NOT throw on DB failure (non-fatal logging)", async () => { + const db = { + insert: () => { + throw new Error("simulated db failure"); + }, + }; + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const result = await recordActivity(db as never, { + ownerId: "user-1", + kind: "lint_run", + actor: "user", + }); + expect(result).toBeNull(); + expect(errorSpy).toHaveBeenCalledWith("recordActivity failed (non-fatal)", expect.any(Error)); + errorSpy.mockRestore(); + }); + + it("defaults targetPageIds to [] when omitted", async () => { + const { db, chains } = createMockDb([[{ id: "x" }]]); + await recordActivity(db as never, { + ownerId: "u", + kind: "wiki_generate", + actor: "ai", + }); + const insertChain = chains[0]; + const valuesOp = insertChain?.ops.find((op) => op.method === "values"); + expect(valuesOp).toBeDefined(); + const firstArg = valuesOp?.args[0] as { targetPageIds: unknown[] }; + expect(firstArg.targetPageIds).toEqual([]); + }); +}); + +describe("listActivityForOwner", () => { + it("clamps limit to [1, MAX] and offset to [0, ∞)", async () => { + const { db, chains } = createMockDb([[], [{ count: 0 }]]); + await listActivityForOwner(db as never, "u1", { limit: 9999, offset: -5 }); + const listChain = chains[0]; + const limitOp = listChain?.ops.find((op) => op.method === "limit"); + const offsetOp = listChain?.ops.find((op) => op.method === "offset"); + expect(limitOp?.args[0]).toBe(ACTIVITY_LIST_MAX_LIMIT); + expect(offsetOp?.args[0]).toBe(0); + }); + + it("uses the default limit when none is provided", async () => { + const { db, chains } = createMockDb([[], [{ count: 0 }]]); + await listActivityForOwner(db as never, "u1"); + const listChain = chains[0]; + const limitOp = listChain?.ops.find((op) => op.method === "limit"); + expect(limitOp?.args[0]).toBe(ACTIVITY_LIST_DEFAULT_LIMIT); + }); + + it("returns rows and total from the count query", async () => { + const fakeRow = { + id: "a-1", + ownerId: "u1", + kind: "lint_run", + actor: "user", + targetPageIds: [], + detail: null, + createdAt: new Date(), + }; + const { db } = createMockDb([[fakeRow], [{ count: 7 }]]); + const res = await listActivityForOwner(db as never, "u1"); + expect(res.rows).toEqual([fakeRow]); + expect(res.total).toBe(7); + }); + + it("returns total=0 when the count row is missing", async () => { + const { db } = createMockDb([[], []]); + const res = await listActivityForOwner(db as never, "u1"); + expect(res.total).toBe(0); + }); +}); diff --git a/server/api/src/__tests__/services/indexBuilder.test.ts b/server/api/src/__tests__/services/indexBuilder.test.ts new file mode 100644 index 00000000..9d6ae8aa --- /dev/null +++ b/server/api/src/__tests__/services/indexBuilder.test.ts @@ -0,0 +1,118 @@ +/** + * indexBuilder 純関数のユニットテスト。 + * Unit tests for the pure helpers in indexBuilder. + */ +import { describe, it, expect } from "vitest"; +import { + buildIndexFromPages, + categoryLabelFor, + compareCategoryLabels, + renderIndexMarkdown, + INDEX_PAGE_TITLE, +} from "../../services/indexBuilder.js"; + +describe("categoryLabelFor", () => { + it("returns (無題 / Untitled) for null, undefined, and whitespace-only", () => { + expect(categoryLabelFor(null)).toBe("(無題 / Untitled)"); + expect(categoryLabelFor(undefined)).toBe("(無題 / Untitled)"); + expect(categoryLabelFor(" ")).toBe("(無題 / Untitled)"); + }); + + it("returns upper-cased first ASCII letter", () => { + expect(categoryLabelFor("apple")).toBe("A"); + expect(categoryLabelFor("Zebra")).toBe("Z"); + expect(categoryLabelFor("wiki")).toBe("W"); + }); + + it("returns 0-9 for digit-leading titles", () => { + expect(categoryLabelFor("2025 年のまとめ")).toBe("0-9"); + expect(categoryLabelFor("100 things")).toBe("0-9"); + }); + + it("returns 日本語 for Japanese leading character", () => { + expect(categoryLabelFor("日本の歴史")).toBe("日本語"); + expect(categoryLabelFor("あいさつ")).toBe("日本語"); + expect(categoryLabelFor("カメラ")).toBe("日本語"); + }); + + it("returns その他 / Other for symbols or emoji", () => { + expect(categoryLabelFor("#hashtag")).toBe("その他 / Other"); + expect(categoryLabelFor("🔥 burns")).toBe("その他 / Other"); + }); +}); + +describe("compareCategoryLabels", () => { + it("orders digits before letters, letters before Japanese, Japanese before Other", () => { + const labels = ["日本語", "A", "0-9", "その他 / Other", "Z"]; + const sorted = [...labels].sort(compareCategoryLabels); + expect(sorted).toEqual(["0-9", "A", "Z", "日本語", "その他 / Other"]); + }); + + it("places (無題 / Untitled) last", () => { + const labels = ["(無題 / Untitled)", "A", "日本語"]; + const sorted = [...labels].sort(compareCategoryLabels); + expect(sorted[sorted.length - 1]).toBe("(無題 / Untitled)"); + }); +}); + +describe("buildIndexFromPages", () => { + const FIXED_NOW = new Date("2026-04-17T10:00:00Z"); + + it("groups pages by first-letter category with stable ordering", () => { + const doc = buildIndexFromPages( + [ + { id: "p1", title: "Alpha", updatedAt: FIXED_NOW }, + { id: "p2", title: "beta", updatedAt: FIXED_NOW }, + { id: "p3", title: "猫のページ", updatedAt: FIXED_NOW }, + { id: "p4", title: "2025 review", updatedAt: FIXED_NOW }, + ], + FIXED_NOW, + ); + expect(doc.totalPages).toBe(4); + expect(doc.categories.map((c) => c.label)).toEqual(["0-9", "A", "B", "日本語"]); + expect(doc.generatedAt).toBe(FIXED_NOW.toISOString()); + }); + + it("sorts entries within a category by title (locale ja)", () => { + const doc = buildIndexFromPages( + [ + { id: "p1", title: "Banana", updatedAt: FIXED_NOW }, + { id: "p2", title: "Apple", updatedAt: FIXED_NOW }, + { id: "p3", title: "apricot", updatedAt: FIXED_NOW }, + ], + FIXED_NOW, + ); + const aCat = doc.categories.find((c) => c.label === "A"); + expect(aCat).toBeDefined(); + expect(aCat?.entries.map((e) => e.title)).toEqual(["Apple", "apricot"]); + }); + + it("accepts ISO-string updatedAt and passes it through", () => { + const iso = "2026-04-01T00:00:00.000Z"; + const doc = buildIndexFromPages([{ id: "p1", title: "Alpha", updatedAt: iso }], FIXED_NOW); + expect(doc.categories[0]?.entries[0]?.updatedAt).toBe(iso); + }); + + it("produces markdown that includes category headings and wiki links", () => { + const doc = buildIndexFromPages( + [{ id: "p1", title: "Alpha", updatedAt: FIXED_NOW }], + FIXED_NOW, + ); + expect(doc.markdown).toContain("# Wiki Index"); + expect(doc.markdown).toContain("## A"); + expect(doc.markdown).toContain("[[Alpha]]"); + }); +}); + +describe("renderIndexMarkdown", () => { + it("produces the empty-state hint when no categories", () => { + const md = renderIndexMarkdown([], "2026-04-17T00:00:00Z"); + expect(md).toContain("まだページがありません"); + }); +}); + +describe("INDEX_PAGE_TITLE", () => { + it("equals the expected special kind identifier", () => { + expect(INDEX_PAGE_TITLE).toBe("__index__"); + }); +}); diff --git a/server/api/src/app.ts b/server/api/src/app.ts index 89392d78..d35f0fb7 100644 --- a/server/api/src/app.ts +++ b/server/api/src/app.ts @@ -36,6 +36,7 @@ import webhookPolarRoutes from "./routes/webhooks/polar.js"; import checkoutRoutes from "./routes/checkout.js"; import subscriptionManageRoutes from "./routes/subscriptionManage.js"; import lintRoutes from "./routes/lint.js"; +import activityRoutes from "./routes/activity.js"; /** * Creates and configures the Hono API app (routes, CORS, etc.). @@ -119,6 +120,9 @@ export function createApp(): Hono { // Lint (Wiki Health, P2) app.route("/api/lint", lintRoutes); + // Activity log + __index__ rebuild (LLM Wiki P4) + app.route("/api/activity", activityRoutes); + // Chrome Extension (OAuth + clip-and-create) app.route("/api/ext", extRoutes); diff --git a/server/api/src/lib/articleExtractor.ts b/server/api/src/lib/articleExtractor.ts index c747fc93..a84bcc8f 100644 --- a/server/api/src/lib/articleExtractor.ts +++ b/server/api/src/lib/articleExtractor.ts @@ -203,6 +203,10 @@ function extractYouTubeVideoId(url: string): string | null { /^https?:\/\/(?:www\.)?youtube\.com\/watch\?(?:[^&]+&)*v=([a-zA-Z0-9_-]{11})(?:&[^\s]*)?$/i, /^https?:\/\/youtu\.be\/([a-zA-Z0-9_-]{11})(?:\?[^\s]*)?$/i, /^https?:\/\/(?:www\.)?youtube\.com\/embed\/([a-zA-Z0-9_-]{11})(?:\?[^\s]*)?$/i, + // YouTube Shorts: https://www.youtube.com/shorts/ + // Shorts は動画専用パイプラインに乗せたいので、watch / youtu.be / embed と + // 同じ抽出経路に含める。 + /^https?:\/\/(?:www\.)?youtube\.com\/shorts\/([a-zA-Z0-9_-]{11})(?:[/?][^\s]*)?$/i, ]; for (const pattern of patterns) { const match = url.match(pattern); diff --git a/server/api/src/routes/activity.ts b/server/api/src/routes/activity.ts new file mode 100644 index 00000000..d16f567e --- /dev/null +++ b/server/api/src/routes/activity.ts @@ -0,0 +1,220 @@ +/** + * /api/activity — Wiki activity log endpoints (P4, otomatty/zedi#598). + * + * GET /api/activity — list entries for the authenticated user + * (filterable by kind / actor / date range). + * GET /api/activity/index — read-only fetch of the current `__index__` + * summary. Does NOT rebuild or write to + * activity_log. + * POST /api/activity/index/rebuild — rebuild the `__index__` special page + * for the authenticated user. + * + * 認証ユーザー自身の活動ログを参照するエンドポイントと、 + * `__index__` 特殊ページの現在状態取得 / 再構築エンドポイント。 + */ +import { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { and, eq } from "drizzle-orm"; +import { authRequired } from "../middleware/auth.js"; +import { + listActivityForOwner, + recordActivity, + ACTIVITY_LIST_DEFAULT_LIMIT, + ACTIVITY_LIST_MAX_LIMIT, +} from "../services/activityLogService.js"; +import type { ActivityActor, ActivityKind } from "../schema/activityLog.js"; +import { buildIndexForOwner, rebuildIndexForOwner } from "../services/indexBuilder.js"; +import { pages } from "../schema/pages.js"; +import type { AppEnv } from "../types/index.js"; + +const app = new Hono(); +app.use("*", authRequired); + +const VALID_KINDS: ReadonlyArray = [ + "clip_ingest", + "chat_promote", + "lint_run", + "wiki_generate", + "index_build", + "wiki_schema_update", +]; +const VALID_ACTORS: ReadonlyArray = ["user", "ai", "system"]; + +/** + * ISO 8601 風の日付文字列をパースし、不正値は null を返す。 + * Parse an ISO-ish date, returning null on invalid input. + */ +function parseDate(raw: string | undefined): { date: Date | null; invalid: boolean } { + if (raw === undefined) return { date: null, invalid: false }; + const trimmed = raw.trim(); + if (!trimmed) return { date: null, invalid: false }; + const d = new Date(trimmed); + if (Number.isNaN(d.getTime())) return { date: null, invalid: true }; + return { date: d, invalid: false }; +} + +/** + * `kind` / `actor` クエリを検証する。不正な値は HTTPException を投げる。 + * Validates the `kind` / `actor` query strings; throws on invalid values. + */ +function parseKindAndActor(kindRaw: string | undefined, actorRaw: string | undefined) { + const kind = + kindRaw && VALID_KINDS.includes(kindRaw as ActivityKind) + ? (kindRaw as ActivityKind) + : undefined; + const actor = + actorRaw && VALID_ACTORS.includes(actorRaw as ActivityActor) + ? (actorRaw as ActivityActor) + : undefined; + if (kindRaw && !kind) { + throw new HTTPException(400, { message: `invalid kind: ${kindRaw}` }); + } + if (actorRaw && !actor) { + throw new HTTPException(400, { message: `invalid actor: ${actorRaw}` }); + } + return { kind, actor }; +} + +/** + * `from` / `to` クエリを検証し Date を返す。不正な組み合わせは HTTPException を投げる。 + * Validates the `from` / `to` query strings; throws on invalid / inverted range. + */ +function parseDateRange(fromRaw: string | undefined, toRaw: string | undefined) { + const from = parseDate(fromRaw); + if (from.invalid) { + throw new HTTPException(400, { message: "invalid 'from' date (ISO 8601 required)" }); + } + const to = parseDate(toRaw); + if (to.invalid) { + throw new HTTPException(400, { message: "invalid 'to' date (ISO 8601 required)" }); + } + if (from.date && to.date && from.date > to.date) { + throw new HTTPException(400, { message: "'from' must be earlier than or equal to 'to'" }); + } + return { from: from.date ?? undefined, to: to.date ?? undefined }; +} + +/** + * GET /api/activity + * + * Query params: `kind`, `actor`, `from`, `to`, `limit`, `offset`. + * クエリ: `kind`・`actor`・`from`・`to`・`limit`・`offset`。 + */ +app.get("/", async (c) => { + const userId = c.get("userId"); + const db = c.get("db"); + + const { kind, actor } = parseKindAndActor( + c.req.query("kind")?.trim(), + c.req.query("actor")?.trim(), + ); + const { from, to } = parseDateRange(c.req.query("from"), c.req.query("to")); + + const limitRaw = Number(c.req.query("limit") ?? ACTIVITY_LIST_DEFAULT_LIMIT); + const offsetRaw = Number(c.req.query("offset") ?? 0); + const limit = Number.isFinite(limitRaw) ? limitRaw : ACTIVITY_LIST_DEFAULT_LIMIT; + const offset = Number.isFinite(offsetRaw) ? offsetRaw : 0; + + const { rows, total } = await listActivityForOwner(db, userId, { + kind, + actor, + from, + to, + limit, + offset, + }); + + return c.json({ + entries: rows.map((r) => ({ + id: r.id, + kind: r.kind, + actor: r.actor, + target_page_ids: r.targetPageIds, + detail: r.detail, + created_at: r.createdAt.toISOString(), + })), + total, + limit: Math.min(Math.max(limit, 1), ACTIVITY_LIST_MAX_LIMIT), + }); +}); + +/** + * GET /api/activity/index — Read the current `__index__` summary without + * triggering a rebuild. Returns the page ID (if one exists) along with a + * freshly-computed category summary derived from the user's current pages. + * + * Does NOT write to `pages` / `page_contents` or `activity_log`, so it is + * safe to call on every view. When no `__index__` page has been built yet, + * `pageId` is null and the summary reflects what a rebuild *would* produce. + * + * `__index__` 特殊ページの現在状態を取得する読み取り専用エンドポイント。 + * DB への書き込みは一切行わないので、ページ表示のたびに呼んでも + * `activity_log` が膨らまない。`__index__` がまだ存在しない場合は + * `pageId=null` を返す。 + */ +app.get("/index", async (c) => { + const userId = c.get("userId"); + const db = c.get("db"); + + const document = await buildIndexForOwner(db, userId); + + const [existing] = await db + .select({ id: pages.id, updatedAt: pages.updatedAt }) + .from(pages) + .where( + and( + eq(pages.ownerId, userId), + eq(pages.specialKind, "__index__"), + eq(pages.isDeleted, false), + ), + ) + .limit(1); + + return c.json({ + pageId: existing?.id ?? null, + lastBuiltAt: existing?.updatedAt?.toISOString() ?? null, + totalPages: document.totalPages, + categories: document.categories.map((cat) => ({ + label: cat.label, + count: cat.entries.length, + })), + }); +}); + +/** + * POST /api/activity/index/rebuild — rebuild the `__index__` special page + * for the authenticated user and return its ID plus category summary. + * + * `__index__` 特殊ページを再構築し、カテゴリ概要を返す。 + */ +app.post("/index/rebuild", async (c) => { + const userId = c.get("userId"); + const db = c.get("db"); + + const { pageId, created, document } = await rebuildIndexForOwner(db, userId); + + await recordActivity(db, { + ownerId: userId, + kind: "index_build", + actor: "user", + targetPageIds: [pageId], + detail: { + created, + totalPages: document.totalPages, + categoryCount: document.categories.length, + }, + }); + + return c.json({ + pageId, + created, + totalPages: document.totalPages, + categories: document.categories.map((cat) => ({ + label: cat.label, + count: cat.entries.length, + })), + generatedAt: document.generatedAt, + }); +}); + +export default app; diff --git a/server/api/src/routes/ingest.ts b/server/api/src/routes/ingest.ts index 6db0273b..9d87c175 100644 --- a/server/api/src/routes/ingest.ts +++ b/server/api/src/routes/ingest.ts @@ -19,14 +19,10 @@ import { sources } from "../schema/sources.js"; import { pageSources } from "../schema/pageSources.js"; import { eq, and } from "drizzle-orm"; import { extractArticleFromUrl } from "../lib/articleExtractor.js"; +import { validateModelAccessOrThrow } from "../lib/aiAccessHelpers.js"; import { callProvider, getProviderApiKeyName } from "../services/aiProviders.js"; import { getUserTier } from "../services/subscriptionService.js"; -import { - checkUsage, - validateModelAccess, - calculateCost, - recordUsage, -} from "../services/usageService.js"; +import { checkUsage, calculateCost, recordUsage } from "../services/usageService.js"; import { createIngestLlmDriver, buildIngestPlannerPrompt, @@ -36,6 +32,7 @@ import { } from "../services/ingestPlanner.js"; import { pages } from "../schema/pages.js"; import { pageContents } from "../schema/pageContents.js"; +import { recordActivity } from "../services/activityLogService.js"; import type { AppEnv, AIProviderType } from "../types/index.js"; const app = new Hono(); @@ -152,21 +149,34 @@ app.post("/plan", authRequired, rateLimit(), async (c) => { if (!supportedProviders.includes(body.provider as AIProviderType)) { throw new HTTPException(400, { message: `unsupported provider: ${body.provider}` }); } - const provider = body.provider as AIProviderType; + // NOTE: the client-supplied provider is *only* used for input validation / + // 4xx surfacing. The actual provider used for API key lookup and the + // upstream call is the one resolved from the DB (modelInfo.provider) below. + // クライアントの provider は入力バリデーションのみで使用し、実呼び出しは + // DB 上の modelInfo.provider に統一する。 const model = body.model.trim(); const rawLimit = Number(body.candidateLimit ?? 5); const candidateLimit = Number.isFinite(rawLimit) ? Math.min(Math.max(rawLimit, 1), 10) : 5; // --- Model access & usage enforcement (mirrors /api/ai/chat) --- + // Use the *Throw variant so unknown / tier-gated models surface as 4xx + // instead of falling through the global handler as 500. + // 不明モデルや tier 制限を 4xx として返すため Throw 版を使う。 const tier = await getUserTier(userId, db); - const modelInfo = await validateModelAccess(model, tier, db); + const modelInfo = await validateModelAccessOrThrow(model, tier, db); const usageCheck = await checkUsage(userId, tier, db); if (!usageCheck.allowed) { throw new HTTPException(429, { message: "Monthly budget exceeded" }); } - const apiKeyName = getProviderApiKeyName(provider); + // API key lookup must use the DB-resolved provider, not the client-supplied one. + // Otherwise a stale client could pass `provider="google"` with an OpenAI model id + // and we would load the wrong credential and fail upstream auth. + // クライアント送信値ではなく DB 解決済みの provider で API キー名を引く。 + // ズレると別プロバイダーの鍵で呼び出して認証失敗する。 + const resolvedProvider = modelInfo.provider as AIProviderType; + const apiKeyName = getProviderApiKeyName(resolvedProvider); const apiKey = process.env[apiKeyName]; if (!apiKey) { throw new HTTPException(503, { message: `API key not configured: ${apiKeyName}` }); @@ -391,6 +401,24 @@ app.post("/apply", authRequired, rateLimit(), async (c) => { .onConflictDoNothing(); } + // Record the ingest action in activity_log. + // Chat promotion reuses this endpoint with kind="conversation"; branch the + // activity kind so the UI can distinguish "clip" vs "chat→wiki" flows. + // Chat → Wiki 昇格経路は本エンドポイントを kind="conversation" で再利用する。 + // 活動ログ側の種別もそれに合わせて切り替える。 + await recordActivity(db, { + ownerId: userId, + kind: body.kind === "conversation" ? "chat_promote" : "clip_ingest", + actor: "user", + targetPageIds: body.targetPageId ? [body.targetPageId] : [], + detail: { + sourceId, + sourceKind: body.kind, + title: body.title, + url: body.url ?? null, + }, + }); + return c.json({ sourceId, targetPageId: body.targetPageId ?? null }); }); diff --git a/server/api/src/routes/wikiSchema.ts b/server/api/src/routes/wikiSchema.ts index f2e074b1..a03bc3d3 100644 --- a/server/api/src/routes/wikiSchema.ts +++ b/server/api/src/routes/wikiSchema.ts @@ -15,6 +15,7 @@ import { eq, and } from "drizzle-orm"; import { authRequired } from "../middleware/auth.js"; import { pages } from "../schema/pages.js"; import { pageContents } from "../schema/pageContents.js"; +import { recordActivity } from "../services/activityLogService.js"; import type { AppEnv } from "../types/index.js"; const app = new Hono(); @@ -87,10 +88,12 @@ app.put("/", authRequired, async (c) => { // Wrap the read-modify-write in a transaction so concurrent PUTs from the // same user cannot both INSERT and race the (owner_id) WHERE is_schema=true - // unique index. A row-level lock (FOR UPDATE) on the existing row, or the - // unique index itself on insert, serializes the operation. - // 同一ユーザーの並行 PUT が両方 INSERT して一意インデックスで衝突するのを防ぐため、 - // 読み書きをトランザクションで囲む。 + // unique index, AND so that the pages row and its page_contents body are + // committed atomically (otherwise a partial failure could leave a schema + // page with no body / mismatched body). + // 同一ユーザーの並行 PUT が両方 INSERT して一意インデックスで衝突するのを防ぎ、 + // かつ pages 行と page_contents 本体をアトミックにコミットするため、 + // 全更新をトランザクションで囲む。 const pageId = await db.transaction(async (tx) => { const [existing] = await tx .select({ id: pages.id }) @@ -99,42 +102,51 @@ app.put("/", authRequired, async (c) => { .for("update") .limit(1); + let resolvedPageId: string; if (existing) { await tx.update(pages).set({ title, updatedAt: now }).where(eq(pages.id, existing.id)); - return existing.id; + resolvedPageId = existing.id; + } else { + const [newPage] = await tx + .insert(pages) + .values({ + ownerId: userId, + title, + isSchema: true, + createdAt: now, + updatedAt: now, + }) + .returning({ id: pages.id }); + + if (!newPage) { + throw new HTTPException(500, { message: "Failed to create schema page" }); + } + resolvedPageId = newPage.id; } - const [newPage] = await tx - .insert(pages) + await tx + .insert(pageContents) .values({ - ownerId: userId, - title, - isSchema: true, - createdAt: now, + pageId: resolvedPageId, + ydocState: Buffer.alloc(0), + contentText: content, updatedAt: now, }) - .returning({ id: pages.id }); + .onConflictDoUpdate({ + target: pageContents.pageId, + set: { contentText: content, updatedAt: now }, + }); - if (!newPage) { - throw new HTTPException(500, { message: "Failed to create schema page" }); - } - return newPage.id; + return resolvedPageId; }); - // Upsert page_contents in a single round-trip. - // page_contents を 1 回の往復で upsert する。 - await db - .insert(pageContents) - .values({ - pageId, - ydocState: Buffer.alloc(0), - contentText: content, - updatedAt: now, - }) - .onConflictDoUpdate({ - target: pageContents.pageId, - set: { contentText: content, updatedAt: now }, - }); + await recordActivity(db, { + ownerId: userId, + kind: "wiki_schema_update", + actor: "user", + targetPageIds: [pageId], + detail: { title, contentLength: content.length }, + }); return c.json({ pageId, title, content }); }); diff --git a/server/api/src/schema/activityLog.ts b/server/api/src/schema/activityLog.ts new file mode 100644 index 00000000..e122e8b1 --- /dev/null +++ b/server/api/src/schema/activityLog.ts @@ -0,0 +1,86 @@ +/** + * Activity log for the LLM Wiki pattern (P4, otomatty/zedi#598). + * + * Append-only record of Wiki-level actions: ingest, chat→wiki promotion, + * lint runs, wiki generation, and index rebuilds. Separate from + * `ai_usage_logs` (which tracks token spend) and `admin_audit_logs` (admin-only + * compliance trail); this table is per-user and describes *what happened to + * the wiki* so users can see how their knowledge base evolved. + * + * LLM Wiki パターンの活動ログ。 + * Ingest / Chat → Wiki 昇格 / Lint 実行 / Wiki 生成 / Index 再構築などを + * 追記専用で記録する。課金用の `ai_usage_logs`・管理者監査用の + * `admin_audit_logs` とは別物で、Wiki の成長履歴をユーザーに見せることを + * 目的とする。 + * + * @see https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f + */ +import { pgTable, uuid, text, timestamp, jsonb, index } from "drizzle-orm/pg-core"; +import { users } from "./users.js"; + +/** + * Kind of activity recorded in the log. + * 活動ログに記録する種別。 + * + * - `clip_ingest`: Web クリップ → Wiki への ingest 実行 / Web clip ingested into wiki + * - `chat_promote`: AI Chat 会話の Wiki ページ昇格 / AI chat promoted to wiki page + * - `lint_run`: Lint エンジンのバッチ実行 / Lint engine run + * - `wiki_generate`: AI による Wiki ページ生成 / AI-driven wiki page generation + * - `index_build`: `__index__` 特殊ページの再構築 / Rebuild of the `__index__` page + * - `wiki_schema_update`: Wiki スキーマ編集 / Wiki schema edit + */ +export type ActivityKind = + | "clip_ingest" + | "chat_promote" + | "lint_run" + | "wiki_generate" + | "index_build" + | "wiki_schema_update"; + +/** + * Actor of an activity: who initiated it. + * 操作の起点。"user"=ユーザー手動 / "ai"=AI 経由 / "system"=バッチ・内部呼び出し。 + */ +export type ActivityActor = "user" | "ai" | "system"; + +/** + * Wiki activity log table. + * Wiki の行動ログテーブル。 + * + * @property id - レコードの一意 ID / Row ID + * @property ownerId - 対象ユーザー ID / Owner user ID + * @property kind - 活動種別 / Activity kind + * @property actor - 起点の種別 / Initiator category + * @property targetPageIds - 対象ページ ID 配列(0 件以上)/ Related page IDs (zero or more) + * @property detail - ルール固有の詳細 JSON / Rule-specific detail payload + * @property createdAt - 記録時刻 / Recorded at + */ +export const activityLog = pgTable( + "activity_log", + { + id: uuid("id").primaryKey().defaultRandom(), + ownerId: text("owner_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + kind: text("kind").notNull().$type(), + actor: text("actor").notNull().$type(), + targetPageIds: text("target_page_ids").array().notNull().default([]), + detail: jsonb("detail").$type>(), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + // Most queries list the latest entries for an owner, optionally filtered by kind. + // 大半のクエリはオーナーごとに新しい順で取得し、kind で絞り込む。 + index("idx_activity_log_owner_created").on(table.ownerId, table.createdAt.desc()), + index("idx_activity_log_owner_kind_created").on( + table.ownerId, + table.kind, + table.createdAt.desc(), + ), + ], +); + +/** Select type for `activity_log`. / `activity_log` の SELECT 型。 */ +export type ActivityLog = typeof activityLog.$inferSelect; +/** Insert type for `activity_log`. / `activity_log` の INSERT 型。 */ +export type NewActivityLog = typeof activityLog.$inferInsert; diff --git a/server/api/src/schema/index.ts b/server/api/src/schema/index.ts index f95c986f..daae4608 100644 --- a/server/api/src/schema/index.ts +++ b/server/api/src/schema/index.ts @@ -8,7 +8,7 @@ export { type UserRole, type UserStatus, } from "./users.js"; -export { pages, type Page, type NewPage } from "./pages.js"; +export { pages, type Page, type NewPage, type PageSpecialKind } from "./pages.js"; export { notes, notePages, @@ -67,6 +67,13 @@ export { type LintRule, type LintSeverity, } from "./lintFindings.js"; +export { + activityLog, + type ActivityLog, + type NewActivityLog, + type ActivityKind, + type ActivityActor, +} from "./activityLog.js"; export { usersRelations, @@ -88,4 +95,5 @@ export { sourcesRelations, pageSourcesRelations, lintFindingsRelations, + activityLogRelations, } from "./relations.js"; diff --git a/server/api/src/schema/lintFindings.ts b/server/api/src/schema/lintFindings.ts index ed503ec1..953ce1d0 100644 --- a/server/api/src/schema/lintFindings.ts +++ b/server/api/src/schema/lintFindings.ts @@ -4,8 +4,20 @@ import { users } from "./users.js"; /** * Lint ルール名の型。 * Lint rule name type. + * + * `stale` は P4 で追加。ページが新しいソースで更新されたのに + * ページ本体が古いまま取り残されているケースを検出する。 + * + * `stale` was added in P4 and flags pages whose linked sources have been + * re-extracted more recently than the page itself was updated. */ -export type LintRule = "orphan" | "ghost_many" | "title_similar" | "conflict" | "broken_link"; +export type LintRule = + | "orphan" + | "ghost_many" + | "title_similar" + | "conflict" + | "broken_link" + | "stale"; /** * Lint 重要度の型。 diff --git a/server/api/src/schema/pages.ts b/server/api/src/schema/pages.ts index 2d84eb52..30a03bea 100644 --- a/server/api/src/schema/pages.ts +++ b/server/api/src/schema/pages.ts @@ -2,6 +2,19 @@ import { pgTable, uuid, text, timestamp, boolean, index } from "drizzle-orm/pg-c import { sql } from "drizzle-orm"; import { users } from "./users.js"; +/** + * Special page kinds that stand apart from normal wiki entries. + * + * `__index__` は AI がカテゴリ別に生成するカテゴリ目次ページ、 + * `__log__` は将来の活動ログ表示用の予約。`null` 相当(通常ページ)は + * カラム値 NULL で表現する。 + * + * Reserved page kinds; `__index__` is the category table-of-contents page, + * `__log__` is reserved for a future activity-log view. Normal pages leave + * the column NULL. + */ +export type PageSpecialKind = "__index__" | "__log__"; + /** * Wiki pages table. Holds mutable Wiki entries owned by a user. * 可変の Wiki ページテーブル(各ユーザーが所有)。 @@ -26,6 +39,14 @@ export const pages = pgTable( * Wiki の「憲法」ページを示すフラグ(オーナーごとに最大 1 つ)。 */ isSchema: boolean("is_schema").default(false).notNull(), + /** + * Kind of special page (`__index__`, `__log__`). NULL for normal pages. + * A partial unique index keeps at most one row per (owner, kind). + * + * 特殊ページの種別(`__index__`・`__log__`)。通常ページは NULL。 + * 部分ユニークインデックスによりオーナーごとに各 kind 最大 1 行。 + */ + specialKind: text("special_kind").$type(), }, (table) => [ index("idx_pages_owner_id").on(table.ownerId), @@ -34,6 +55,7 @@ export const pages = pgTable( index("idx_pages_is_deleted") .on(table.ownerId) .where(sql`NOT ${table.isDeleted}`), + index("idx_pages_owner_special_kind").on(table.ownerId, table.specialKind), ], ); diff --git a/server/api/src/schema/relations.ts b/server/api/src/schema/relations.ts index d4d22c22..2afb2fd4 100644 --- a/server/api/src/schema/relations.ts +++ b/server/api/src/schema/relations.ts @@ -11,6 +11,7 @@ import { aiUsageLogs, aiMonthlyUsage } from "./aiModels.js"; import { sources } from "./sources.js"; import { pageSources } from "./pageSources.js"; import { lintFindings } from "./lintFindings.js"; +import { activityLog } from "./activityLog.js"; export /** * @@ -272,3 +273,14 @@ export const lintFindingsRelations = relations(lintFindings, ({ one }) => ({ references: [users.id], }), })); + +/** + * `activity_log` のリレーション定義。オーナーへの多対一。 + * Relations for `activity_log`: many-to-one to owner. + */ +export const activityLogRelations = relations(activityLog, ({ one }) => ({ + owner: one(users, { + fields: [activityLog.ownerId], + references: [users.id], + }), +})); diff --git a/server/api/src/services/activityLogService.ts b/server/api/src/services/activityLogService.ts new file mode 100644 index 00000000..a84364f1 --- /dev/null +++ b/server/api/src/services/activityLogService.ts @@ -0,0 +1,153 @@ +/** + * Service helpers for the `activity_log` table (P4, otomatty/zedi#598). + * + * `recordActivity` is the write-path used by ingest / lint / chat→wiki / wiki + * generation / index build. It deliberately swallows errors — logging must + * never break the feature it is observing. + * + * `list*` helpers back the admin ActivityLog page. They enforce a hard limit + * to prevent accidental large scans. + * + * `activity_log` テーブルの書き込み・読み出しヘルパ。 + * `recordActivity` は ingest・lint・chat 昇格・wiki 生成・index 構築から + * 呼び出される。ログの失敗が元機能を壊さないよう意図的に try/catch する。 + * `list*` は管理画面の ActivityLog ページで利用する。 + */ +import { and, desc, eq, gte, lte, sql, type SQL } from "drizzle-orm"; +import { activityLog } from "../schema/activityLog.js"; +import type { + ActivityActor, + ActivityKind, + ActivityLog, + NewActivityLog, +} from "../schema/activityLog.js"; +import type { Database } from "../types/index.js"; + +/** + * Arguments required to record a single activity. + * 1 件の行動を記録するための引数。 + */ +export interface RecordActivityInput { + /** Owner of the activity (user id). / 対象ユーザー ID */ + ownerId: string; + /** Activity kind. / 活動種別 */ + kind: ActivityKind; + /** Who initiated the activity. / 起点 */ + actor: ActivityActor; + /** Related page IDs (empty array is fine). / 関連ページ ID(0 件可) */ + targetPageIds?: string[]; + /** Arbitrary JSON detail payload. / 詳細 JSON */ + detail?: Record; +} + +/** + * Inserts one activity log row. + * + * Does NOT throw on DB failure — logging is a side-concern; swallowing errors + * prevents the observed feature (e.g. ingest) from being interrupted. + * A `console.error` is emitted so ops can still notice silent failures. + * + * 1 件の活動を記録する。 + * DB 書き込み失敗でも throw しない(本処理を巻き込まない)。失敗時は + * `console.error` で検知できるようにする。 + * + * @param db - データベース接続 / Database connection + * @param input - 記録内容 / Record payload + * @returns 挿入された行(失敗時は null)/ Inserted row, or null on failure + */ +export async function recordActivity( + db: Database, + input: RecordActivityInput, +): Promise { + try { + const values: NewActivityLog = { + ownerId: input.ownerId, + kind: input.kind, + actor: input.actor, + targetPageIds: input.targetPageIds ?? [], + detail: input.detail ?? null, + }; + const [inserted] = await db.insert(activityLog).values(values).returning(); + return inserted ?? null; + } catch (err) { + // Logging the logger is a non-fatal concern; surface but don't re-throw. + // ロガー側の失敗は非致命的。console.error で見えるようにしつつ伝播させない。 + console.error("recordActivity failed (non-fatal)", err); + return null; + } +} + +/** Default page size for list queries. / 一覧取得のデフォルト件数 */ +export const ACTIVITY_LIST_DEFAULT_LIMIT = 50; +/** Maximum page size for list queries. / 一覧取得の最大件数 */ +export const ACTIVITY_LIST_MAX_LIMIT = 200; + +/** + * Filters for listing activity entries. + * 活動ログ一覧取得のフィルタ。 + */ +export interface ListActivityFilters { + kind?: ActivityKind; + actor?: ActivityActor; + /** Inclusive lower bound on createdAt. / createdAt の下限(含む) */ + from?: Date; + /** Inclusive upper bound on createdAt. / createdAt の上限(含む) */ + to?: Date; + limit?: number; + offset?: number; +} + +/** + * Clamps a numeric value between lo and hi. + * 数値を [lo, hi] にクリップする。 + */ +function clamp(n: number, lo: number, hi: number): number { + if (!Number.isFinite(n)) return lo; + return Math.min(Math.max(n, lo), hi); +} + +/** + * Lists activity entries for a user, newest first. + * + * ユーザーの活動ログを新しい順に取得する。 + * + * @param db - データベース接続 / Database connection + * @param ownerId - 対象ユーザー ID / Owner user ID + * @param filters - 絞り込み条件 / Filters + * @returns 件数と行配列 / Count + rows + */ +export async function listActivityForOwner( + db: Database, + ownerId: string, + filters: ListActivityFilters = {}, +): Promise<{ rows: ActivityLog[]; total: number }> { + const limit = clamp( + Number(filters.limit ?? ACTIVITY_LIST_DEFAULT_LIMIT), + 1, + ACTIVITY_LIST_MAX_LIMIT, + ); + const offset = clamp(Number(filters.offset ?? 0), 0, Number.MAX_SAFE_INTEGER); + + const conditions: SQL[] = [eq(activityLog.ownerId, ownerId)]; + if (filters.kind) conditions.push(eq(activityLog.kind, filters.kind)); + if (filters.actor) conditions.push(eq(activityLog.actor, filters.actor)); + if (filters.from) conditions.push(gte(activityLog.createdAt, filters.from)); + if (filters.to) conditions.push(lte(activityLog.createdAt, filters.to)); + + const whereClause = and(...conditions); + + const rows = await db + .select() + .from(activityLog) + .where(whereClause) + .orderBy(desc(activityLog.createdAt), desc(activityLog.id)) + .limit(limit) + .offset(offset); + + const [countRow] = await db + .select({ count: sql`cast(count(*) as integer)` }) + .from(activityLog) + .where(whereClause); + + return { rows, total: countRow?.count ?? 0 }; +} diff --git a/server/api/src/services/indexBuilder.ts b/server/api/src/services/indexBuilder.ts new file mode 100644 index 00000000..75c2e979 --- /dev/null +++ b/server/api/src/services/indexBuilder.ts @@ -0,0 +1,345 @@ +/** + * Index builder service (P4, otomatty/zedi#598). + * + * Generates the contents of the special `__index__` page — a category + * table-of-contents listing every non-deleted wiki page. The initial + * implementation uses a deterministic rule-based categorization (first + * character / language bucket) so it can run synchronously without an LLM + * or embedding cluster; the hook is wired such that an LLM categorizer can + * swap in later without changing callers. + * + * Karpathy LLM Wiki の `index.md` に相当する `__index__` 特殊ページの内容を + * 生成する。初期実装は LLM を使わないルールベース(頭文字 / 言語別)で、 + * 将来 LLM / embedding クラスタに差し替え可能な形にしている。 + */ +import { and, asc, eq, isNull } from "drizzle-orm"; +import { pages } from "../schema/pages.js"; +import { pageContents } from "../schema/pageContents.js"; +import type { Database } from "../types/index.js"; + +/** + * A single page entry as it appears in the index. + * インデックスに並ぶ 1 ページ分の情報。 + */ +export interface IndexEntry { + /** Page ID. / ページ ID */ + id: string; + /** Display title (falls back to "(無題 / untitled)"). / 表示タイトル */ + title: string; + /** Most recent update timestamp as ISO 8601. / 最終更新時刻 (ISO 8601) */ + updatedAt: string; +} + +/** + * One category bucket of the index. + * インデックスの 1 カテゴリ。 + */ +export interface IndexCategory { + /** Category label, e.g. "A", "日本語", "数字 / Numeric". / カテゴリ名 */ + label: string; + /** Pages sorted by title within the category. / タイトル順のページ配列 */ + entries: IndexEntry[]; +} + +/** + * Fully-built index document ready to persist. + * 保存可能なインデックス本体。 + */ +export interface IndexDocument { + /** Total non-deleted pages considered. / 対象ページ総数 */ + totalPages: number; + /** Pages that did not appear in any category. / どのカテゴリにも入らなかったページ(orphan 候補) */ + orphanCount: number; + /** Categories sorted by label. / ラベル順のカテゴリ */ + categories: IndexCategory[]; + /** Markdown rendering of the index. / Markdown 表現 */ + markdown: string; + /** When the document was built. / 構築時刻 */ + generatedAt: string; +} + +/** + * Returns a category label for a given title. + * + * - Empty / whitespace-only titles → "(無題 / Untitled)". + * - ASCII letters → upper-cased first letter (A, B, ...). + * - Digits → "0-9". + * - Japanese characters (CJK ideographs, hiragana, katakana) → "日本語". + * - Anything else → "その他 / Other". + * + * 頭文字からカテゴリラベルを算出する。アルファベットは大文字化、数字は + * "0-9"、日本語(漢字・ひらがな・カタカナ)は "日本語"、それ以外は + * "その他 / Other"。空タイトルは "(無題 / Untitled)"。 + * + * @param title - Page title / ページタイトル + * @returns Category label / カテゴリラベル + */ +export function categoryLabelFor(title: string | null | undefined): string { + if (!title || title.trim().length === 0) return "(無題 / Untitled)"; + const first = [...title.trim()][0]; + if (!first) return "(無題 / Untitled)"; + if (/[0-9]/.test(first)) return "0-9"; + if (/[A-Za-z]/.test(first)) return first.toUpperCase(); + // Japanese ranges: CJK Unified Ideographs, Hiragana, Katakana (half-width too). + // 日本語: CJK 漢字・ひらがな・カタカナ(半角含む)。 + if (/[\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF\uFF66-\uFF9F]/.test(first)) { + return "日本語"; + } + return "その他 / Other"; +} + +/** + * Sorts category labels in a stable, reader-friendly order: digits first, + * then Latin letters A-Z, then Japanese, then Other, then fallback sort. + * + * カテゴリ順序: 0-9 → A〜Z → 日本語 → その他 → ほか。 + */ +export function compareCategoryLabels(a: string, b: string): number { + const order = (label: string): number => { + if (label === "0-9") return 0; + if (/^[A-Z]$/.test(label)) return 1; + if (label === "日本語") return 2; + if (label === "その他 / Other") return 3; + if (label === "(無題 / Untitled)") return 5; + return 4; + }; + const orderDiff = order(a) - order(b); + if (orderDiff !== 0) return orderDiff; + return a.localeCompare(b, "ja"); +} + +/** + * Renders categories into a human-readable Markdown document. + * + * カテゴリ配列を Markdown 文字列に整形する。 + * + * @param categories - Category array / カテゴリ配列 + * @param generatedAt - Timestamp string (ISO 8601) / 生成時刻 (ISO 8601) + * @returns Markdown body / Markdown 本文 + */ +export function renderIndexMarkdown(categories: IndexCategory[], generatedAt: string): string { + const lines: string[] = []; + lines.push("# Wiki Index / Wiki カテゴリ目次"); + lines.push(""); + lines.push(`_自動生成 / Auto-generated at ${generatedAt}. Karpathy LLM Wiki の index.md 相当。_`); + lines.push(""); + + if (categories.length === 0) { + lines.push("まだページがありません。 / No pages yet."); + return lines.join("\n"); + } + + for (const category of categories) { + lines.push(`## ${category.label}`); + lines.push(""); + for (const entry of category.entries) { + const displayTitle = entry.title.trim().length === 0 ? "(無題 / untitled)" : entry.title; + lines.push(`- [[${displayTitle}]]`); + } + lines.push(""); + } + + return lines.join("\n"); +} + +/** + * Groups pages into categories and produces a full {@link IndexDocument}. + * Useful as a pure helper in tests. + * + * ページ配列をカテゴリに分け、{@link IndexDocument} を生成する純関数。 + * テスト向けに DB 非依存で公開する。 + * + * @param rawPages - 対象ページ配列 / Input pages + * @param now - Generation timestamp / 生成時刻(省略時は new Date()) + */ +export function buildIndexFromPages( + rawPages: ReadonlyArray<{ id: string; title: string | null; updatedAt: Date | string }>, + now: Date = new Date(), +): IndexDocument { + const bucketMap = new Map(); + + for (const p of rawPages) { + const label = categoryLabelFor(p.title); + const updatedAtIso = p.updatedAt instanceof Date ? p.updatedAt.toISOString() : p.updatedAt; + const entry: IndexEntry = { + id: p.id, + title: p.title ?? "", + updatedAt: updatedAtIso, + }; + const bucket = bucketMap.get(label); + if (bucket) { + bucket.push(entry); + } else { + bucketMap.set(label, [entry]); + } + } + + const categories: IndexCategory[] = [...bucketMap.entries()] + .map(([label, entries]) => ({ + label, + entries: [...entries].sort((a, b) => a.title.localeCompare(b.title, "ja")), + })) + .sort((a, b) => compareCategoryLabels(a.label, b.label)); + + const generatedAt = now.toISOString(); + const markdown = renderIndexMarkdown(categories, generatedAt); + + return { + totalPages: rawPages.length, + orphanCount: 0, + categories, + markdown, + generatedAt, + }; +} + +/** + * Builds an index document for a user by querying all non-deleted, non-special + * pages. Schema pages and the `__index__` / `__log__` pages themselves are + * excluded from the index listing. + * + * 指定ユーザーの非削除・非特殊ページ全件から `IndexDocument` を組み立てる。 + * スキーマ・`__index__` / `__log__` 自体は一覧に含めない。 + * + * @param db - Database connection / データベース接続 + * @param ownerId - Owner user ID / 対象ユーザー ID + * @returns Built index document / 組み立て済みインデックス + */ +export async function buildIndexForOwner(db: Database, ownerId: string): Promise { + const rows = await db + .select({ id: pages.id, title: pages.title, updatedAt: pages.updatedAt }) + .from(pages) + .where( + and( + eq(pages.ownerId, ownerId), + eq(pages.isDeleted, false), + eq(pages.isSchema, false), + // `specialKind` IS NULL => normal pages; special pages excluded. + // `specialKind` が NULL のページのみ対象。 + isNull(pages.specialKind), + ), + ) + .orderBy(asc(pages.title)); + + return buildIndexFromPages(rows); +} + +/** Title of the special `__index__` page. / `__index__` 特殊ページのタイトル。 */ +export const INDEX_PAGE_TITLE = "__index__"; + +/** + * Result of persisting an index page. + * インデックスページ保存結果。 + */ +export interface PersistIndexResult { + /** Page ID of the `__index__` page (created or updated). / `__index__` ページ ID */ + pageId: string; + /** Whether a new page row was created. / 新規作成されたか */ + created: boolean; + /** Built document. / 生成したドキュメント */ + document: IndexDocument; +} + +/** + * Builds the index document for a user and upserts it into a special page + * (`special_kind = '__index__'`). The body is stored in `page_contents.content_text` + * so normal reads (GET /api/pages/:id, search) work unchanged. + * + * ユーザーの `__index__` 特殊ページを upsert する。本文は + * `page_contents.content_text` に格納し、通常ページと同じ読み出し経路で扱える。 + * + * @param db - Database connection / データベース接続 + * @param ownerId - Owner user ID / 対象ユーザー ID + */ +export async function rebuildIndexForOwner( + db: Database, + ownerId: string, +): Promise { + const document = await buildIndexForOwner(db, ownerId); + const now = new Date(); + + const result = await db.transaction(async (tx) => { + const [existing] = await tx + .select({ id: pages.id }) + .from(pages) + .where( + and( + eq(pages.ownerId, ownerId), + eq(pages.specialKind, "__index__"), + eq(pages.isDeleted, false), + ), + ) + .for("update") + .limit(1); + + let pageId: string; + let created: boolean; + if (existing) { + await tx + .update(pages) + .set({ title: INDEX_PAGE_TITLE, updatedAt: now }) + .where(eq(pages.id, existing.id)); + pageId = existing.id; + created = false; + } else { + // Partial unique index (`idx_pages_unique_special_kind_per_owner`) protects + // against two concurrent rebuilds both passing the SELECT above and then + // racing to INSERT. Use ON CONFLICT DO NOTHING + re-SELECT so the loser + // adopts the winner's row instead of aborting the whole transaction + // (a raw unique violation in Postgres marks the tx as failed, and a + // try/catch alone cannot recover without an explicit SAVEPOINT). + // 並行再構築で SELECT を両方通過した場合、生の一意制約違反は tx を失敗状態に + // するため、ON CONFLICT DO NOTHING + 再 SELECT で勝者行を採用する。 + const inserted = await tx + .insert(pages) + .values({ + ownerId, + title: INDEX_PAGE_TITLE, + specialKind: "__index__", + createdAt: now, + updatedAt: now, + }) + .onConflictDoNothing() + .returning({ id: pages.id }); + const newRow = inserted[0]; + if (newRow) { + pageId = newRow.id; + created = true; + } else { + const [winner] = await tx + .select({ id: pages.id }) + .from(pages) + .where( + and( + eq(pages.ownerId, ownerId), + eq(pages.specialKind, "__index__"), + eq(pages.isDeleted, false), + ), + ) + .limit(1); + if (!winner) { + throw new Error("Failed to insert or locate __index__ page"); + } + pageId = winner.id; + created = false; + } + } + + await tx + .insert(pageContents) + .values({ + pageId, + ydocState: Buffer.alloc(0), + contentText: document.markdown, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: pageContents.pageId, + set: { contentText: document.markdown, updatedAt: now }, + }); + + return { pageId, created }; + }); + + return { ...result, document }; +} diff --git a/server/api/src/services/lintEngine/index.ts b/server/api/src/services/lintEngine/index.ts index 72c51d2e..e19390d4 100644 --- a/server/api/src/services/lintEngine/index.ts +++ b/server/api/src/services/lintEngine/index.ts @@ -1,5 +1,6 @@ import { eq, and, isNull, sql } from "drizzle-orm"; import { lintFindings } from "../../schema/lintFindings.js"; +import { recordActivity } from "../activityLogService.js"; import type { Database } from "../../types/index.js"; import type { LintFindingCandidate, LintRuleResult } from "./types.js"; import { runOrphanRule } from "./rules/orphan.js"; @@ -7,6 +8,7 @@ import { runGhostManyRule } from "./rules/ghostMany.js"; import { runTitleSimilarRule } from "./rules/titleSimilar.js"; import { runBrokenLinkRule } from "./rules/brokenLink.js"; import { runConflictRule } from "./rules/conflict.js"; +import { runStaleRule } from "./rules/stale.js"; export type { LintFindingCandidate, LintRuleResult } from "./types.js"; @@ -29,6 +31,7 @@ export async function runAllLintRules(ownerId: string, db: Database): Promise
  • ({ rule: r.rule, count: r.findings.length })), + }, + }); + return results; } diff --git a/server/api/src/services/lintEngine/rules/brokenLink.ts b/server/api/src/services/lintEngine/rules/brokenLink.ts index 9368bf6a..e3539411 100644 --- a/server/api/src/services/lintEngine/rules/brokenLink.ts +++ b/server/api/src/services/lintEngine/rules/brokenLink.ts @@ -36,6 +36,7 @@ export async function runBrokenLinkRule(ownerId: string, db: Database): Promise< SELECT 1 FROM pages AS target WHERE target.id = ${links.targetId} AND target.is_deleted = true + AND target.owner_id = ${ownerId} )`, ), ); diff --git a/server/api/src/services/lintEngine/rules/conflict.ts b/server/api/src/services/lintEngine/rules/conflict.ts index 0460789e..024b71cf 100644 --- a/server/api/src/services/lintEngine/rules/conflict.ts +++ b/server/api/src/services/lintEngine/rules/conflict.ts @@ -9,7 +9,10 @@ import type { LintRuleResult, LintFindingCandidate } from "../types.js"; * Regex patterns for extracting numeric/date claims from content. */ const DATE_PATTERN = /(\d{4})[年/-](\d{1,2})[月/-](\d{1,2})[日]?/g; -const NUMBER_PATTERN = /(\d[\d,.]+)\s*(km|m|kg|g|人|円|ドル|年|歳|万|億)/g; +// `\d[\d,.]*` (not `+`) so single-digit claims like `3人` / `7km` / `5年` are +// matched. `+` would require at least two numeric characters. +// `+` だと 2 文字以上必要になり 1 桁の主張 (3人 等) を取りこぼすため `*` を使う。 +const NUMBER_PATTERN = /(\d[\d,.]*)\s*(km|m|kg|g|人|円|ドル|年|歳|万|億)/g; /** * テキストからファクト(数値・日付の主張)を抽出する。 diff --git a/server/api/src/services/lintEngine/rules/orphan.ts b/server/api/src/services/lintEngine/rules/orphan.ts index 7d4747b9..59faf715 100644 --- a/server/api/src/services/lintEngine/rules/orphan.ts +++ b/server/api/src/services/lintEngine/rules/orphan.ts @@ -32,6 +32,7 @@ export async function runOrphanRule(ownerId: string, db: Database): Promise = {}): StaleRow => ({ + page_id: "p1", + title: "Page One", + page_updated_at: new Date("2026-04-01T00:00:00Z"), + source_id: "s1", + source_title: "Source 1", + source_url: "https://example.com/1", + source_extracted_at: new Date("2026-04-10T00:00:00Z"), + ...overrides, +}); + +/** + * Asserts `value` is defined and narrows its type. + * 値が undefined でないことを検証して型を絞り込む。 + */ +function must(value: T | undefined, message: string): T { + if (value === undefined) throw new Error(message); + return value; +} + +describe("foldStaleRowsIntoFindings", () => { + it("returns empty when there are no stale rows", () => { + expect(foldStaleRowsIntoFindings([])).toEqual([]); + }); + + it("collapses multiple stale sources for the same page into a single finding", () => { + const rows: StaleRow[] = [ + makeRow({ source_id: "s1", source_extracted_at: new Date("2026-04-02T00:00:00Z") }), + makeRow({ source_id: "s2", source_extracted_at: new Date("2026-04-05T00:00:00Z") }), + makeRow({ source_id: "s3", source_extracted_at: new Date("2026-04-03T00:00:00Z") }), + ]; + const findings = foldStaleRowsIntoFindings(rows); + expect(findings).toHaveLength(1); + const finding: LintFindingCandidate = must(findings[0], "expected 1 finding"); + expect(finding.rule).toBe("stale"); + expect(finding.severity).toBe("warn"); + expect(finding.pageIds).toEqual(["p1"]); + const stale = finding.detail.staleSources as Array<{ sourceId: string }>; + // sorted by newest extracted_at first + expect(stale.map((s) => s.sourceId)).toEqual(["s2", "s3", "s1"]); + }); + + it("produces one finding per page", () => { + const rows: StaleRow[] = [ + makeRow({ page_id: "p1", title: "P1" }), + makeRow({ page_id: "p2", title: "P2" }), + ]; + const findings = foldStaleRowsIntoFindings(rows); + expect(findings).toHaveLength(2); + expect(findings.map((f) => f.pageIds[0]).sort()).toEqual(["p1", "p2"]); + }); + + it("falls back to '(無題 / untitled)' when title is null", () => { + const findings = foldStaleRowsIntoFindings([makeRow({ title: null })]); + const finding: LintFindingCandidate = must(findings[0], "expected 1 finding"); + expect(finding.detail.title).toBe("(無題 / untitled)"); + }); + + it("serializes dates as ISO 8601 strings", () => { + const findings = foldStaleRowsIntoFindings([makeRow()]); + const finding: LintFindingCandidate = must(findings[0], "expected 1 finding"); + expect(finding.detail.pageUpdatedAt).toBe("2026-04-01T00:00:00.000Z"); + const stale = finding.detail.staleSources as Array<{ extractedAt: string }>; + const first = must(stale[0], "expected stale source"); + expect(first.extractedAt).toBe("2026-04-10T00:00:00.000Z"); + }); +}); diff --git a/server/api/src/services/lintEngine/rules/stale.ts b/server/api/src/services/lintEngine/rules/stale.ts new file mode 100644 index 00000000..39014ce8 --- /dev/null +++ b/server/api/src/services/lintEngine/rules/stale.ts @@ -0,0 +1,129 @@ +/** + * Stale claim detection rule (P4, otomatty/zedi#598). + * + * A page is flagged as "stale" when any source cited by it + * (`page_sources` → `sources`) has been re-extracted *after* the page itself + * was last updated. This means the underlying reference material moved on + * but the wiki entry summarizing it did not. + * + * Stale claim 検出ルール。 + * あるページが引用しているソース(`page_sources` → `sources`)のうち、 + * `sources.extracted_at` が `pages.updated_at` より新しいものがある場合、 + * 「出典は更新されたのに Wiki 本体が追随できていない」状態とみなして + * Stale finding を生成する。 + */ +import { sql } from "drizzle-orm"; +import type { Database } from "../../../types/index.js"; +import type { LintFindingCandidate, LintRuleResult } from "../types.js"; + +/** + * Raw row returned by the stale detection query. + * 検出クエリが返す 1 行。 + */ +export type StaleRow = { + page_id: string; + title: string | null; + page_updated_at: Date; + source_id: string; + source_title: string | null; + source_url: string | null; + source_extracted_at: Date; +} & Record; + +/** + * Pure helper that folds stale rows into candidate findings. + * One finding per page, with all stale sources listed (sorted by most + * recently extracted first). + * + * 検出行を findings に折り畳む純関数。ページごとに 1 finding、 + * 古いソースは `staleSources` 配列に最新抽出順で含める。 + * + * @param rows - Stale rows / 検出行 + * @returns Candidate findings / 候補 findings + */ +export function foldStaleRowsIntoFindings(rows: ReadonlyArray): LintFindingCandidate[] { + const byPage = new Map< + string, + { + pageId: string; + title: string | null; + pageUpdatedAt: Date; + sources: Array<{ id: string; title: string | null; url: string | null; extractedAt: Date }>; + } + >(); + + for (const row of rows) { + const src = { + id: row.source_id, + title: row.source_title, + url: row.source_url, + extractedAt: row.source_extracted_at, + }; + const entry = byPage.get(row.page_id); + if (entry) { + entry.sources.push(src); + } else { + byPage.set(row.page_id, { + pageId: row.page_id, + title: row.title, + pageUpdatedAt: row.page_updated_at, + sources: [src], + }); + } + } + + return [...byPage.values()].map((p) => ({ + rule: "stale" as const, + severity: "warn" as const, + pageIds: [p.pageId], + detail: { + title: p.title ?? "(無題 / untitled)", + pageUpdatedAt: p.pageUpdatedAt.toISOString(), + staleSources: [...p.sources] + .sort((a, b) => b.extractedAt.getTime() - a.extractedAt.getTime()) + .map((s) => ({ + sourceId: s.id, + title: s.title, + url: s.url, + extractedAt: s.extractedAt.toISOString(), + })), + suggestion: + "出典が更新されています。ページ本文の見直しを検討してください / Linked source has been re-extracted after this page's last update. Please review.", + }, + })); +} + +/** + * Detects pages whose linked sources have been re-extracted since the page + * was last updated. + * + * 引用元ソースがページより後に再抽出されている場合を検出する。 + * + * @param ownerId - 対象ユーザー ID / Target user ID + * @param db - データベース接続 / Database connection + * @returns Stale 検出結果 / Stale findings + */ +export async function runStaleRule(ownerId: string, db: Database): Promise { + // Raw SQL: a date-compared JOIN across page_sources + sources is simpler here, + // and avoids pulling down pages+contents blobs. + // raw SQL で JOIN する理由は、日付比較付きの結合が素直になるため。 + const result = await db.execute(sql` + SELECT + p.id AS page_id, + p.title AS title, + p.updated_at AS page_updated_at, + s.id AS source_id, + s.title AS source_title, + s.url AS source_url, + s.extracted_at AS source_extracted_at + FROM pages p + INNER JOIN page_sources ps ON ps.page_id = p.id + INNER JOIN sources s ON s.id = ps.source_id + WHERE p.owner_id = ${ownerId} + AND p.is_deleted = false + AND s.extracted_at > p.updated_at + `); + + const findings = foldStaleRowsIntoFindings(result.rows); + return { rule: "stale", findings }; +} diff --git a/src/App.tsx b/src/App.tsx index 4b94fd99..5d62133e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,6 +16,7 @@ import McpAuthorize from "./pages/McpAuthorize"; import PageEditorPage from "./pages/PageEditor"; import Settings from "./pages/Settings"; import WikiSchemaPage from "./pages/WikiSchemaPage"; +import IndexPage from "./pages/IndexPage"; import Pricing from "./pages/Pricing"; import SubscriptionManagement from "./pages/SubscriptionManagement"; import Donate from "./pages/Donate"; @@ -108,6 +109,7 @@ const App = () => ( } /> } /> } /> + } /> } /> = ({ open, onOpenChang }; }, [clearPreviewUrl]); + // 親が直接 `open=false` にしたケース(DialogContent の Esc/オーバーレイ以外、 + // たとえば外部状態で閉じる場合)に備え、`open` の遷移を監視して + // 進行中の OCR / describe を必ず中断し、内部状態をリセットする。 + // Without this, closing via the parent could leave a previous run state + // (preview / progress) when the dialog reopens. + useEffect(() => { + if (open) return; + abortRef.current?.abort(); + abortRef.current = null; + setStep("source"); + setSelectedImage(null); + clearPreviewUrl(); + setProcessingMode("none"); + setError(null); + setOcrProgress(null); + setIsProcessing(false); + }, [open, clearPreviewUrl]); + // 画像選択 /** * diff --git a/src/hooks/useImageUpload.ts b/src/hooks/useImageUpload.ts index 16551952..03343ce5 100644 --- a/src/hooks/useImageUpload.ts +++ b/src/hooks/useImageUpload.ts @@ -124,7 +124,13 @@ export function useImageUpload(): UseImageUploadReturn { }, signal, }); - throwIfAborted(); + // NOTE: do NOT call `throwIfAborted()` here. The remote write has + // already completed at this point; throwing now would hide a + // successful upload from the caller and orphan the stored asset. + // Cancellation must propagate via the `signal` passed into the + // provider above, before the upload resolves. + // ここで throwIfAborted を呼ぶとアップロード完了済みのアセットが孤児化する。 + // キャンセルは signal 経由で provider に伝えること。 setState((prev) => ({ ...prev, diff --git a/src/hooks/useWikiSchema.ts b/src/hooks/useWikiSchema.ts index 2031b10f..dc7d57d8 100644 --- a/src/hooks/useWikiSchema.ts +++ b/src/hooks/useWikiSchema.ts @@ -15,7 +15,19 @@ export interface WikiSchemaData { content: string; } -const WIKI_SCHEMA_KEY = ["wiki-schema"] as const; +/** + * Builds a per-user React Query cache key for the wiki schema. + * ユーザーごとに分離されたキャッシュキーを構築する。 + * + * Using a static key would let one user's schema bleed into another user's + * session after a logout/login until refetch finishes (and `setQueryData` + * would write back into the shared slot). + * 静的キーだと再フェッチ完了までユーザー間でデータが見える可能性があるため、 + * userId を含めて分離する。 + */ +function getWikiSchemaKey(userId: string | undefined) { + return ["wiki-schema", userId ?? "anonymous"] as const; +} /** * Fetches the wiki schema page text from the API. @@ -57,9 +69,10 @@ async function updateWikiSchema(body: { export function useWikiSchema() { const { user } = useAuth(); const queryClient = useQueryClient(); + const queryKey = getWikiSchemaKey(user?.id); const query = useQuery({ - queryKey: WIKI_SCHEMA_KEY, + queryKey, queryFn: fetchWikiSchema, enabled: !!user, staleTime: 60_000, @@ -68,7 +81,7 @@ export function useWikiSchema() { const mutation = useMutation({ mutationFn: updateWikiSchema, onSuccess: (data) => { - queryClient.setQueryData(WIKI_SCHEMA_KEY, data); + queryClient.setQueryData(queryKey, data); }, }); diff --git a/src/lib/markdownExport.ts b/src/lib/markdownExport.ts index 86d9f77d..406b0e93 100644 --- a/src/lib/markdownExport.ts +++ b/src/lib/markdownExport.ts @@ -78,9 +78,13 @@ Object.assign(nodeHandlers, { }, youtubeEmbed: (n) => { // 異常な videoId が Markdown 構文を壊さないよう、厳格に検証してからエンコードする - // Strictly validate videoId to prevent malformed Markdown; encode before embedding - const rawVideoId = (n.attrs?.videoId as string) || ""; - const videoId = rawVideoId.trim(); + // Strictly validate videoId to prevent malformed Markdown; encode before embedding. + // Use `typeof === "string"` instead of `as string` because the `as` cast is + // a TypeScript-only hint; if the runtime value is e.g. an object, calling + // `.trim()` on it would throw and break export/copy. + // `as string` は実行時保護にならないため、`typeof` で確実に文字列チェックする。 + const rawVideoId = n.attrs?.videoId; + const videoId = typeof rawVideoId === "string" ? rawVideoId.trim() : ""; if (!/^[a-zA-Z0-9_-]{11}$/.test(videoId)) return ""; return `[YouTube](https://www.youtube.com/watch?v=${encodeURIComponent(videoId)})\n\n`; }, diff --git a/src/lib/wikiGenerator/wikiGeneratorFromChatPrompt.ts b/src/lib/wikiGenerator/wikiGeneratorFromChatPrompt.ts index 8a83f881..40fe3173 100644 --- a/src/lib/wikiGenerator/wikiGeneratorFromChatPrompt.ts +++ b/src/lib/wikiGenerator/wikiGeneratorFromChatPrompt.ts @@ -8,6 +8,16 @@ * ユーザー入力はタグで囲みテンプレート埋め込みにし、見出し注入や「執筆ルール」境界の破壊を防ぐ。 */ +/** + * Escapes a closing tag that would otherwise prematurely terminate the + * surrounding XML-like section block. Defensive only: `userSchema` is + * user-controlled text and could contain ``. + * 終了タグを混入されてセクション境界が破壊されるのを防ぐための防御的エスケープ。 + */ +function escapeUserSchemaContent(input: string): string { + return input.replaceAll("", "<\\/user_schema>"); +} + /** * Builds the user message for chat-page wiki generation from title, outline, and conversation. * タイトル・アウトライン・会話から、チャット由来ページ生成用ユーザープロンプトを組み立てる。 @@ -25,7 +35,7 @@ export function buildChatPageWikiUserPrompt( userSchema && userSchema.trim() ? `\n## ユーザー定義スキーマ(構成・表記ルールなど。必ず従うこと) -${userSchema.trim()} +${escapeUserSchemaContent(userSchema.trim())} \n` : ""; diff --git a/src/pages/IndexPage.tsx b/src/pages/IndexPage.tsx new file mode 100644 index 00000000..6990bb2f --- /dev/null +++ b/src/pages/IndexPage.tsx @@ -0,0 +1,243 @@ +/** + * `/index` — Wiki Index viewer page (P4, otomatty/zedi#598). + * + * Displays the user's auto-generated `__index__` special page as a + * read-only category table-of-contents. The mount path only reads the + * current state (GET), so merely viewing the page does not pollute + * `activity_log`; the explicit "Rebuild" button triggers POST, which + * writes the page and records an `index_build` activity entry. + * + * ユーザーの `__index__` 特殊ページ(自動生成のカテゴリ目次)を閲覧する + * 読み取り専用ページ。初回マウント時は GET のみを呼び、activity_log を + * 汚染しない。手動再構築ボタン押下でのみ POST を発行する。 + */ +import React, { useCallback, useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { ArrowLeft, Loader2, RefreshCw } from "lucide-react"; +import { Button, useToast } from "@zedi/ui"; +import Container from "@/components/layout/Container"; + +/** + * Read-only response from GET /api/activity/index. + * GET /api/activity/index のレスポンス。 + */ +interface IndexFetchResponse { + pageId: string | null; + lastBuiltAt: string | null; + totalPages: number; + categories: Array<{ label: string; count: number }>; +} + +/** + * Rebuild response from POST /api/activity/index/rebuild. + * POST /api/activity/index/rebuild のレスポンス。 + */ +interface IndexRebuildResponse { + pageId: string; + created: boolean; + totalPages: number; + categories: Array<{ label: string; count: number }>; + generatedAt: string; +} + +/** + * Normalized view model that unifies GET and POST responses for rendering. + * GET / POST 双方を同じ UI で扱うための正規化ビューモデル。 + */ +interface IndexViewModel { + pageId: string | null; + totalPages: number; + categories: Array<{ label: string; count: number }>; + /** ISO-8601 — `lastBuiltAt` from GET or `generatedAt` from POST. / 表示用時刻。 */ + timestamp: string | null; +} + +function getApiBaseUrl(): string { + return (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? ""; +} + +/** + * GET /api/activity/index — read-only fetch. No DB write, no activity entry. + * 読み取り専用の取得 API 呼び出し。 + */ +async function fetchIndex(): Promise { + const res = await fetch(`${getApiBaseUrl()}/api/activity/index`, { + credentials: "include", + }); + if (!res.ok) { + throw new Error(`Fetch failed: HTTP ${res.status}`); + } + return res.json() as Promise; +} + +/** + * POST /api/activity/index/rebuild — triggers a rebuild and writes activity. + * `__index__` ページを再構築する API 呼び出し。 + */ +async function rebuildIndex(): Promise { + const res = await fetch(`${getApiBaseUrl()}/api/activity/index/rebuild`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + }); + if (!res.ok) { + throw new Error(`Rebuild failed: HTTP ${res.status}`); + } + return res.json() as Promise; +} + +/** + * Index viewer. Initial mount hits GET for a read-only summary; the Rebuild + * button hits POST to write the `__index__` page and log an activity entry. + * + * インデックス閲覧ページ。マウント時は GET、再構築ボタン押下で POST。 + */ +const IndexPage: React.FC = () => { + const { toast } = useToast(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [rebuilding, setRebuilding] = useState(false); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const result = await fetchIndex(); + setData({ + pageId: result.pageId, + totalPages: result.totalPages, + categories: result.categories, + timestamp: result.lastBuiltAt, + }); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + }, []); + + const rebuild = useCallback(async () => { + setRebuilding(true); + setError(null); + try { + const result = await rebuildIndex(); + setData({ + pageId: result.pageId, + totalPages: result.totalPages, + categories: result.categories, + timestamp: result.generatedAt, + }); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + toast({ title: "Index の再構築に失敗しました", variant: "destructive" }); + } finally { + setRebuilding(false); + } + }, [toast]); + + useEffect(() => { + void load(); + }, [load]); + + const busy = loading || rebuilding; + + return ( +
    +
    + +
    + +

    Wiki Index / カテゴリ目次

    +
    + +
    +
    + +
    + +
    +

    + AI が生成するカテゴリ別目次(Karpathy LLM Wiki の + index.md + 相当)。ページを追加・更新したあとに再構築するとカテゴリが更新されます。 +

    + + {error && ( +
    {error}
    + )} + + {loading && !data && ( +
    + +
    + )} + + {data && ( + <> +
    +
    + 対象ページ数:{" "} + {data.totalPages} +
    +
    + カテゴリ数:{" "} + {data.categories.length} +
    + {data.timestamp ? ( +
    + Last built at {new Date(data.timestamp).toLocaleString()} +
    + ) : ( +
    + まだ再構築されていません / Not built yet +
    + )} + {data.pageId && ( +
    + + __index__ ページを開く / Open __index__ page + +
    + )} +
    + + {data.categories.length === 0 ? ( +

    まだページがありません。 / No pages yet.

    + ) : ( +
      + {data.categories.map((cat) => ( +
    • + {cat.label} + {cat.count} ページ +
    • + ))} +
    + )} + + )} +
    +
    +
    +
    + ); +}; + +export default IndexPage; diff --git a/src/pages/WikiSchemaPage.tsx b/src/pages/WikiSchemaPage.tsx index fe8d2a09..96d28b47 100644 --- a/src/pages/WikiSchemaPage.tsx +++ b/src/pages/WikiSchemaPage.tsx @@ -94,7 +94,8 @@ const WikiSchemaPage: React.FC = () => { { setTitle(e.target.value); @@ -109,7 +110,8 @@ const WikiSchemaPage: React.FC = () => {