diff --git a/.claude/rules/data-access.md b/.claude/rules/data-access.md new file mode 100644 index 00000000..f3faf801 --- /dev/null +++ b/.claude/rules/data-access.md @@ -0,0 +1,29 @@ +--- +paths: + - server/src/** +--- + +# データアクセス + +## SQL を書くのは repository だけ + +`createDb` / drizzle のクエリビルダ / `sql` テンプレートを使うのは `server/src/repository/**` に限る。service・routes・handlers・mastra からは repository のメソッドを呼ぶ。 + +呼び出しの階層は自由(repository → service → routes / repository → routes / service → routes のいずれも可)。縛るのは「DB 操作の入口は repository」だけ。 + +## 置き場の判断 + +| 状況 | 置き場 | +| ---- | ------ | +| 返す行が 1 つのテーブルのもの(JOIN の有無は問わない) | そのテーブルの repository | +| 返り値が複数テーブルにまたがる / 主テーブルがない | service が各 repository を呼んで組み立てる | + +複数テーブルを横断する処理(保管期間削除・ユーザー削除など)は service に置く。ただし service が `createDb` して直接 delete するのではなく、各 repository の削除メソッドを呼ぶオーケストレーターにする。 + +## 例外 + +パフォーマンス上どうしても 1 本の SQL で書きたい横断クエリだけ例外。その場合は「どのドメインの問いに答えるクエリか」で repository を選び、意図はメソッド名で表す(`findThreadsWithUsage` のように)。 + +## 既存コード + +`services/analytics/*` `services/data-retention.ts` `services/persona-extractor.ts` 等にこの原則より前の直接アクセスが残っている。触る機会があれば repository へ寄せるが、この原則のためだけの一括リファクタはしない。 diff --git a/server/CLAUDE.md b/server/CLAUDE.md index c6484c87..ab08ba31 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -102,6 +102,12 @@ throw new HTTPException(404, { message: "Not found" }); - temperature を効かせたい決定的な処理は `deterministicModelConfig` を使う(reasoning を有効にすると temperature が無効化される) - 動的 instructions: 現在日時が必要な場合のみ関数化(`lib/date.ts` の `getCurrentDateInfo()` を使用) +### データアクセス + +SQL(`createDb` / drizzle / `sql` テンプレート)を書くのは `repository/` だけ。service・routes・handlers・mastra は repository のメソッドを呼ぶ。呼び出しの階層(repository → service → routes / repository → routes / service → routes)は自由。 + +置き場は「返す行が 1 つのテーブルなら(JOIN 有無を問わず)そのテーブルの repository、複数テーブルにまたがる / 主テーブルが無いなら service が各 repository を呼んで組み立てる」。詳細は `.claude/rules/data-access.md`。 + ### ルート規約 - エラー: `throw new HTTPException(code, { message })` でスロー(グローバルエラーハンドラーが `{ error: { code, message } }` 形式に変換) diff --git a/server/src/repository/review-repository.ts b/server/src/repository/review-repository.ts index 00542da3..e51eb720 100644 --- a/server/src/repository/review-repository.ts +++ b/server/src/repository/review-repository.ts @@ -1,7 +1,8 @@ -import { asc, desc, eq, sql } from "drizzle-orm"; +import { and, asc, desc, eq, isNull, sql } from "drizzle-orm"; import { createDb, messageFeedback, + type NewRetrievalRun, type NewReviewDecision, retrievalRuns, reviewDecisions, @@ -95,6 +96,28 @@ export const reviewRepository = { }; }, + async insertRun(d1: D1Database, values: NewRetrievalRun) { + const db = createDb(d1); + await db.insert(retrievalRuns).values(values); + }, + + async linkRunsToMessage( + d1: D1Database, + answerRunId: string, + messageId: string, + ) { + const db = createDb(d1); + await db + .update(retrievalRuns) + .set({ messageId }) + .where( + and( + eq(retrievalRuns.answerRunId, answerRunId), + isNull(retrievalRuns.messageId), + ), + ); + }, + async listRunsByAnswerRunId(d1: D1Database, answerRunId: string) { const db = createDb(d1); return await db diff --git a/server/src/routes/admin/corrections.test.ts b/server/src/routes/admin/corrections.test.ts index 80f7ef7c..87915d34 100644 --- a/server/src/routes/admin/corrections.test.ts +++ b/server/src/routes/admin/corrections.test.ts @@ -165,11 +165,15 @@ describe("POST /", () => { expect(res.status).toBe(404); }); - it("訂正を published で保存し発行する", async () => { + it("draft で保存し、発行成功後に published へ更新する", async () => { vi.mocked(knowledgeSourceRepository.findByPath).mockResolvedValue( sourceRow, ); - vi.mocked(knowledgeCorrectionRepository.insert).mockResolvedValue( + vi.mocked(knowledgeCorrectionRepository.insert).mockResolvedValue({ + ...correction, + status: "draft", + }); + vi.mocked(knowledgeCorrectionRepository.update).mockResolvedValue( correction, ); @@ -192,25 +196,31 @@ describe("POST /", () => { expect.objectContaining({ correctsSourcePath: "bus/index.md", body: "土曜は運休です", - status: "published", + status: "draft", approvedBy: "user-1", answerRunId: "ar-1", }), ); expect(publishCorrection).toHaveBeenCalledWith( expect.objectContaining({ d1: mockEnv.DB }), - correction, + expect.objectContaining({ status: "draft" }), { canonicalUrl: "https://example.com/bus" }, ); + expect(knowledgeCorrectionRepository.update).toHaveBeenCalledWith( + mockEnv.DB, + "cor-1", + { status: "published" }, + ); }); it("発行に失敗したら 500 を返す", async () => { vi.mocked(knowledgeSourceRepository.findByPath).mockResolvedValue( sourceRow, ); - vi.mocked(knowledgeCorrectionRepository.insert).mockResolvedValue( - correction, - ); + vi.mocked(knowledgeCorrectionRepository.insert).mockResolvedValue({ + ...correction, + status: "draft", + }); vi.mocked(publishCorrection).mockResolvedValue({ indexed: true, status: "approved", @@ -227,6 +237,7 @@ describe("POST /", () => { mockEnv, ); expect(res.status).toBe(500); + expect(knowledgeCorrectionRepository.update).not.toHaveBeenCalled(); }); }); @@ -287,6 +298,68 @@ describe("POST /{id}/retire", () => { }); }); +describe("POST /{id}/publish", () => { + it("retired の訂正は 404", async () => { + vi.mocked(knowledgeCorrectionRepository.findById).mockResolvedValue({ + ...correction, + status: "retired", + }); + + const res = await app.request( + authedRequest("/cor-1/publish", { method: "POST" }), + undefined, + mockEnv, + ); + expect(res.status).toBe(404); + }); + + it("draft を再発行して published にする", async () => { + vi.mocked(knowledgeCorrectionRepository.findById).mockResolvedValue({ + ...correction, + status: "draft", + }); + vi.mocked(knowledgeCorrectionRepository.update).mockResolvedValue( + correction, + ); + + const res = await app.request( + authedRequest("/cor-1/publish", { method: "POST" }), + undefined, + mockEnv, + ); + + expect(res.status).toBe(200); + expect(publishCorrection).toHaveBeenCalled(); + expect(knowledgeCorrectionRepository.update).toHaveBeenCalledWith( + mockEnv.DB, + "cor-1", + { status: "published" }, + ); + }); + + it("発行に失敗したら published にしない", async () => { + vi.mocked(knowledgeCorrectionRepository.findById).mockResolvedValue({ + ...correction, + status: "draft", + }); + vi.mocked(publishCorrection).mockResolvedValue({ + indexed: true, + status: "approved", + chunks: 0, + error: "embed failed", + }); + + const res = await app.request( + authedRequest("/cor-1/publish", { method: "POST" }), + undefined, + mockEnv, + ); + + expect(res.status).toBe(500); + expect(knowledgeCorrectionRepository.update).not.toHaveBeenCalled(); + }); +}); + describe("POST /{id}/reverify", () => { it("retired の訂正は 404", async () => { vi.mocked(knowledgeCorrectionRepository.findById).mockResolvedValue({ @@ -321,6 +394,11 @@ describe("POST /{id}/reverify", () => { ); expect(res.status).toBe(200); + expect(publishCorrection).toHaveBeenCalledWith( + expect.objectContaining({ d1: mockEnv.DB }), + expect.objectContaining({ approvedBy: "user-1" }), + expect.anything(), + ); expect(knowledgeCorrectionRepository.update).toHaveBeenCalledWith( mockEnv.DB, "cor-1", @@ -330,7 +408,6 @@ describe("POST /{id}/reverify", () => { verifiedAt: expect.any(String), }), ); - expect(publishCorrection).toHaveBeenCalled(); }); }); diff --git a/server/src/routes/admin/corrections.ts b/server/src/routes/admin/corrections.ts index a6cc104b..77505de6 100644 --- a/server/src/routes/admin/corrections.ts +++ b/server/src/routes/admin/corrections.ts @@ -28,7 +28,7 @@ const CorrectionSchema = z.object({ id: z.string(), correctsSourcePath: z.string(), body: z.string(), - status: z.enum(["published", "retired"]), + status: z.enum(["draft", "published", "retired"]), verifiedAt: z.string(), approvedBy: z.string(), relatedFeedbackId: z.string().nullable(), @@ -42,7 +42,7 @@ const toCorrectionResponse = (correction: KnowledgeCorrection) => ({ id: correction.id, correctsSourcePath: correction.correctsSourcePath, body: correction.body, - status: correction.status as "published" | "retired", + status: correction.status as "draft" | "published" | "retired", verifiedAt: correction.verifiedAt, approvedBy: correction.approvedBy, relatedFeedbackId: correction.relatedFeedbackId, @@ -165,11 +165,11 @@ correctionsAdminRoutes.openapi(createRoute_, async (c) => { } const now = new Date(); - const correction = await knowledgeCorrectionRepository.insert(c.env.DB, { + const draft = await knowledgeCorrectionRepository.insert(c.env.DB, { id: crypto.randomUUID(), correctsSourcePath, body, - status: "published", + status: "draft", verifiedAt: now.toISOString().slice(0, 10), approvedBy: adminUser.id, relatedFeedbackId, @@ -177,14 +177,20 @@ correctionsAdminRoutes.openapi(createRoute_, async (c) => { createdAt: now.toISOString(), }); - await publishOrFail(c.env, correction, { + await publishOrFail(c.env, draft, { canonicalUrl: source.canonicalUrl ?? undefined, }); + const published = await knowledgeCorrectionRepository.update( + c.env.DB, + draft.id, + { status: "published" }, + ); + return c.json( { message: "訂正を発行しました", - correction: toCorrectionResponse(correction), + correction: toCorrectionResponse(published), }, 200, ); @@ -286,14 +292,19 @@ correctionsAdminRoutes.openapi(reverifyRoute, async (c) => { }); } + const verifiedAt = new Date().toISOString().slice(0, 10); + await publishOrFail(c.env, { + ...correction, + verifiedAt, + approvedBy: adminUser.id, + }); + const updated = await knowledgeCorrectionRepository.update(c.env.DB, id, { - verifiedAt: new Date().toISOString().slice(0, 10), + verifiedAt, approvedBy: adminUser.id, needsReviewAt: null, }); - await publishOrFail(c.env, updated); - return c.json( { message: "訂正を再確認済みにしました", @@ -303,6 +314,57 @@ correctionsAdminRoutes.openapi(reverifyRoute, async (c) => { ); }); +const publishRoute = createRoute({ + method: "post", + path: "/{id}/publish", + summary: "未反映の訂正を再発行", + description: "発行に失敗して未反映のままの訂正を、R2 と検索へ再反映します", + tags: ["Admin - Corrections"], + request: { + params: z.object({ id: z.string().min(1) }), + }, + responses: { + 200: { + description: "再発行成功", + content: { + "application/json": { + schema: z.object({ + message: z.string(), + correction: CorrectionSchema, + }), + }, + }, + }, + 401: errorResponse(401), + 403: errorResponse(403), + 404: errorResponse(404), + 500: errorResponse(500), + }, +}); + +correctionsAdminRoutes.openapi(publishRoute, async (c) => { + const { id } = c.req.valid("param"); + + const correction = await knowledgeCorrectionRepository.findById(c.env.DB, id); + if (!correction || correction.status === "retired") { + throw new HTTPException(404, { message: "訂正が見つかりません" }); + } + + await publishOrFail(c.env, correction); + + const updated = await knowledgeCorrectionRepository.update(c.env.DB, id, { + status: "published", + }); + + return c.json( + { + message: "訂正を再発行しました", + correction: toCorrectionResponse(updated), + }, + 200, + ); +}); + const republishRoute = createRoute({ method: "post", path: "/republish", diff --git a/server/src/services/knowledge/indexing.test.ts b/server/src/services/knowledge/indexing.test.ts index 4790a14b..3e716c97 100644 --- a/server/src/services/knowledge/indexing.test.ts +++ b/server/src/services/knowledge/indexing.test.ts @@ -65,7 +65,7 @@ describe("indexKnowledgeSource", () => { approvalStatus: "pending", canonicalUrl: "https://example.com/bus", }); - expect(row?.sourceHash).toMatch(/^[0-9a-f]{64}$/); + expect(row?.sourceHash).toBeNull(); }); it("approved の情報源は index して chunk_count を記録する", async () => { @@ -94,7 +94,7 @@ describe("indexKnowledgeSource", () => { expect(row?.indexedAt).not.toBeNull(); }); - it("rejected / disabled は index せずメタデータだけ更新する", async () => { + it("rejected / disabled は index せず frontmatter だけ反映する", async () => { await knowledgeSourceRepository.insert(d1, { sourcePath: "bus/index.md", approvalStatus: "rejected", @@ -105,9 +105,6 @@ describe("indexKnowledgeSource", () => { expect(result).toEqual({ indexed: false, status: "rejected", chunks: 0 }); expect(processKnowledgeFile).not.toHaveBeenCalled(); - - const row = await knowledgeSourceRepository.findByPath(d1, "bus/index.md"); - expect(row?.canonicalUrl).toBe("https://example.com/bus"); }); it("approveAs 指定で未登録の情報源を approved として登録し index する", async () => { @@ -196,19 +193,18 @@ describe("indexKnowledgeSource", () => { expect(processKnowledgeFile).toHaveBeenCalled(); }); - it("内容不変なら再 index はしてもメタデータは書き換えない", async () => { + it("index 成功時は frontmatter のメタデータを最新に更新する", async () => { await knowledgeSourceRepository.insert(d1, { sourcePath: "bus/index.md", approvalStatus: "approved", - sourceHash: await sha256Hex(content), - canonicalUrl: "https://example.com/manual", + canonicalUrl: "https://example.com/old", createdAt: "2026-09-01T00:00:00.000Z", }); await indexKnowledgeSource("bus/index.md", content, deps); const row = await knowledgeSourceRepository.findByPath(d1, "bus/index.md"); - expect(row?.canonicalUrl).toBe("https://example.com/manual"); + expect(row?.canonicalUrl).toBe("https://example.com/bus"); expect(row?.chunkCount).toBe(3); }); @@ -297,6 +293,46 @@ describe("indexKnowledgeSource", () => { const row = await knowledgeSourceRepository.findByPath(d1, "bus/index.md"); expect(row?.indexedAt).toBeNull(); }); + + it("index が失敗したら sourceHash を更新せず、再試行が skipUnchanged で握り潰されない", async () => { + await knowledgeSourceRepository.insert(d1, { + sourcePath: "bus/index.md", + approvalStatus: "approved", + sourceHash: "old-hash", + chunkCount: 12, + indexedAt: "2026-09-01T00:00:00.000Z", + createdAt: "2026-09-01T00:00:00.000Z", + }); + vi.mocked(processKnowledgeFile).mockResolvedValueOnce({ + chunks: 0, + error: "embedding failed", + }); + + await indexKnowledgeSource("bus/index.md", content, deps, { + skipUnchanged: true, + }); + + const failed = await knowledgeSourceRepository.findByPath( + d1, + "bus/index.md", + ); + expect(failed?.sourceHash).toBe("old-hash"); + expect(failed?.indexedAt).toBeNull(); + expect(failed?.chunkCount).toBe(0); + + const retried = await indexKnowledgeSource("bus/index.md", content, deps, { + skipUnchanged: true, + }); + + expect(retried).toMatchObject({ indexed: true, chunks: 3 }); + expect(processKnowledgeFile).toHaveBeenCalledTimes(2); + const recovered = await knowledgeSourceRepository.findByPath( + d1, + "bus/index.md", + ); + expect(recovered?.indexedAt).not.toBeNull(); + expect(recovered?.sourceHash).toBe(await sha256Hex(content)); + }); }); describe("removeKnowledgeSource", () => { diff --git a/server/src/services/knowledge/indexing.ts b/server/src/services/knowledge/indexing.ts index 40025eb6..5d7f967c 100644 --- a/server/src/services/knowledge/indexing.ts +++ b/server/src/services/knowledge/indexing.ts @@ -52,26 +52,22 @@ export const indexKnowledgeSource = async ( if (!existing) { await knowledgeSourceRepository.insert(deps.d1, { - ...record, - r2Etag: options.r2Etag, + sourcePath: key, + canonicalUrl: record.canonicalUrl, + sourceType: record.sourceType, + sourceAuthority: record.sourceAuthority, + verifiedAt: record.verifiedAt, approvalStatus: status, approvedBy: promoted ? options.approveAs : undefined, approvedAt: promoted ? now : undefined, createdAt: now, }); } else { - if (existing.sourceHash !== record.sourceHash) { - const { sourcePath: _, ...meta } = record; - await knowledgeSourceRepository.update(deps.d1, key, { - ...meta, - ...(options.r2Etag && { r2Etag: options.r2Etag }), - }); - if (existing.sourceHash) { - await knowledgeCorrectionRepository.markNeedsReviewByCorrects( - deps.d1, - key, - ); - } + if (existing.sourceHash !== record.sourceHash && existing.sourceHash) { + await knowledgeCorrectionRepository.markNeedsReviewByCorrects( + deps.d1, + key, + ); } if (promoted && existing.approvalStatus === "pending") { await knowledgeSourceRepository.update(deps.d1, key, { @@ -100,8 +96,16 @@ export const indexKnowledgeSource = async ( deps.d1, ); - if (!result.error) { - await knowledgeSourceRepository.markIndexed(deps.d1, key, result.chunks); + if (result.error) { + await knowledgeSourceRepository.markRemoved(deps.d1, key); + } else { + const { sourcePath: _, ...meta } = record; + await knowledgeSourceRepository.update(deps.d1, key, { + ...meta, + ...(options.r2Etag && { r2Etag: options.r2Etag }), + chunkCount: result.chunks, + indexedAt: new Date().toISOString(), + }); } return { diff --git a/server/src/services/knowledge/retrieval-trace.ts b/server/src/services/knowledge/retrieval-trace.ts index ba4d4039..aa748763 100644 --- a/server/src/services/knowledge/retrieval-trace.ts +++ b/server/src/services/knowledge/retrieval-trace.ts @@ -1,9 +1,8 @@ import type { RequestContext } from "@mastra/core/request-context"; -import { and, eq, isNull } from "drizzle-orm"; -import { createDb, retrievalRuns } from "~/db"; import { logger } from "~/lib/logger"; import { waitUntilInBackground } from "~/lib/wait-until"; import { getRequestDb } from "~/mastra/request-context"; +import { reviewRepository } from "~/repository/review-repository"; export type RetrievalHit = { source: string; @@ -27,8 +26,7 @@ export const recordRetrievalRun = async ( const d1 = getRequestDb(requestContext); if (!d1) return; try { - const db = createDb(d1); - await db.insert(retrievalRuns).values({ + await reviewRepository.insertRun(d1, { id: crypto.randomUUID(), answerRunId: requestContext?.get("answerRunId") as string | undefined, threadId: requestContext?.get("usageThreadId") as string | undefined, @@ -71,16 +69,7 @@ export const linkRetrievalRunsToMessage = async ( await Promise.allSettled(pending); } try { - const db = createDb(d1); - await db - .update(retrievalRuns) - .set({ messageId }) - .where( - and( - eq(retrievalRuns.answerRunId, answerRunId), - isNull(retrievalRuns.messageId), - ), - ); + await reviewRepository.linkRunsToMessage(d1, answerRunId, messageId); } catch (error) { logger.warn("[RetrievalTrace] failed to link message", { error: String(error), diff --git a/shared/src/api/repository/correction-repository.test.ts b/shared/src/api/repository/correction-repository.test.ts index 0daf0c91..af9d09a8 100644 --- a/shared/src/api/repository/correction-repository.test.ts +++ b/shared/src/api/repository/correction-repository.test.ts @@ -76,6 +76,29 @@ describe("createCorrection", () => { }); }); +describe("publishCorrection", () => { + it("path に id を埋め込む", async () => { + server.use( + http.post(`${API}/admin/corrections/c-1/publish`, () => + HttpResponse.json({ message: "ok" }), + ), + ); + + const result = await repo.publishCorrection("c-1"); + expect(result?.message).toBe("ok"); + }); + + it("5xx は throw する", async () => { + server.use( + http.post(`${API}/admin/corrections/c-1/publish`, () => + HttpResponse.json({ error: { message: "x" } }, { status: 500 }), + ), + ); + + await expect(repo.publishCorrection("c-1")).rejects.toBeDefined(); + }); +}); + describe("retireCorrection", () => { it("path に id を埋め込む", async () => { server.use( diff --git a/shared/src/api/repository/correction-repository.ts b/shared/src/api/repository/correction-repository.ts index 4d616ff1..ebb6da4d 100644 --- a/shared/src/api/repository/correction-repository.ts +++ b/shared/src/api/repository/correction-repository.ts @@ -20,6 +20,15 @@ export const createCorrectionRepository = (client: ApiClient) => ({ return data; }, + publishCorrection: async (id: string) => { + const { data, error } = await client.POST( + "/admin/corrections/{id}/publish", + { params: { path: { id } } }, + ); + if (error) throw error; + return data; + }, + retireCorrection: async (id: string) => { const { data, error } = await client.POST( "/admin/corrections/{id}/retire", diff --git a/shared/src/api/types.d.ts b/shared/src/api/types.d.ts index 11118760..fcf6daaf 100644 --- a/shared/src/api/types.d.ts +++ b/shared/src/api/types.d.ts @@ -2495,6 +2495,7 @@ export interface paths { edited?: boolean; }[]; editedCount?: number; + skippedCount?: number; }; }; }; @@ -4321,7 +4322,7 @@ export interface paths { correctsSourcePath: string; body: string; /** @enum {string} */ - status: "published" | "retired"; + status: "draft" | "published" | "retired"; verifiedAt: string; approvedBy: string; relatedFeedbackId: string | null; @@ -4399,7 +4400,7 @@ export interface paths { correctsSourcePath: string; body: string; /** @enum {string} */ - status: "published" | "retired"; + status: "draft" | "published" | "retired"; verifiedAt: string; approvedBy: string; relatedFeedbackId: string | null; @@ -4512,7 +4513,7 @@ export interface paths { correctsSourcePath: string; body: string; /** @enum {string} */ - status: "published" | "retired"; + status: "draft" | "published" | "retired"; verifiedAt: string; approvedBy: string; relatedFeedbackId: string | null; @@ -4611,7 +4612,120 @@ export interface paths { correctsSourcePath: string; body: string; /** @enum {string} */ - status: "published" | "retired"; + status: "draft" | "published" | "retired"; + verifiedAt: string; + approvedBy: string; + relatedFeedbackId: string | null; + answerRunId: string | null; + needsReviewAt: string | null; + 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; + }; + }; + }; + }; + /** @description サーバーエラー */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: { + code: number; + message: string; + }; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/corrections/{id}/publish": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 未反映の訂正を再発行 + * @description 発行に失敗して未反映のままの訂正を、R2 と検索へ再反映します + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description 再発行成功 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + message: string; + correction: { + id: string; + correctsSourcePath: string; + body: string; + /** @enum {string} */ + status: "draft" | "published" | "retired"; verifiedAt: string; approvedBy: string; relatedFeedbackId: string | null; @@ -4867,7 +4981,7 @@ export interface paths { head?: never; /** * 情報源候補の承認状態を変更 - * @description 承認した候補はナレッジ収集の対象として scraper パイプラインに追加します + * @description 承認・却下の判断を記録します。承認済み候補の収集は通常の取り込み手順で行います */ patch: { parameters: { diff --git a/web/src/app/dashboard/components/CorrectionsPanel.test.tsx b/web/src/app/dashboard/components/CorrectionsPanel.test.tsx index 02c4e026..4c88c4ec 100644 --- a/web/src/app/dashboard/components/CorrectionsPanel.test.tsx +++ b/web/src/app/dashboard/components/CorrectionsPanel.test.tsx @@ -135,6 +135,36 @@ describe("CorrectionsPanel", () => { ).toBeInTheDocument(); }); + it("未反映の訂正は件数付きタブから再発行できる", async () => { + let published = false; + server.use( + http.get(`${API}/admin/corrections`, () => + HttpResponse.json({ + corrections: [correction({ status: "draft", body: "未反映の訂正" })], + }), + ), + http.post(`${API}/admin/corrections/cor-1/publish`, () => { + published = true; + return HttpResponse.json({ + message: "ok", + correction: correction({ status: "published" }), + }); + }), + ); + + renderWithQuery(); + + await userEvent.click( + await screen.findByRole("button", { name: "未反映 (1)" }), + ); + expect( + screen.getByText("未反映(回答に反映されていません)"), + ).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "再発行する" })); + await waitFor(() => expect(published).toBe(true)); + }); + it("要再確認は件数付きタブから維持できる", async () => { let reverified = false; server.use( diff --git a/web/src/app/dashboard/components/CorrectionsPanel.tsx b/web/src/app/dashboard/components/CorrectionsPanel.tsx index 4d828b8d..df8ed1d2 100644 --- a/web/src/app/dashboard/components/CorrectionsPanel.tsx +++ b/web/src/app/dashboard/components/CorrectionsPanel.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { useCorrections, + usePublishCorrection, useRetireCorrection, useReverifyCorrection, } from "~/app/dashboard/hooks/useCorrections"; @@ -11,11 +12,12 @@ import { PanelLoading } from "~/components/ui/PanelLoading"; import { confirmDialog } from "~/lib/dialog"; import { formatDateTime } from "~/lib/format"; -type StatusFilter = "published" | "needs_review" | "retired" | "all"; +type StatusFilter = "published" | "needs_review" | "draft" | "retired" | "all"; const FILTERS: Array<{ value: StatusFilter; label: string }> = [ { value: "published", label: "公開中" }, { value: "needs_review", label: "要再確認" }, + { value: "draft", label: "未反映" }, { value: "retired", label: "廃止済み" }, { value: "all", label: "すべて" }, ]; @@ -25,6 +27,7 @@ export const CorrectionsPanel = () => { const { data, isLoading, error } = useCorrections(); const retireMutation = useRetireCorrection(); const reverifyMutation = useReverifyCorrection(); + const publishMutation = usePublishCorrection(); if (isLoading) { return ; @@ -45,6 +48,7 @@ export const CorrectionsPanel = () => { const needsReviewCount = all.filter( (c) => c.status === "published" && c.needsReviewAt, ).length; + const draftCount = all.filter((c) => c.status === "draft").length; const handleRetire = (id: string) => { if (!confirmDialog("この訂正を廃止しますか?検索対象から外れます。")) { @@ -61,15 +65,23 @@ export const CorrectionsPanel = () => { label: option.value === "needs_review" && needsReviewCount > 0 ? `${option.label} (${needsReviewCount})` - : option.label, + : option.value === "draft" && draftCount > 0 + ? `${option.label} (${draftCount})` + : option.label, }))} value={filter} onChange={setFilter} /> - {(retireMutation.isError || reverifyMutation.isError) && ( + {(retireMutation.isError || + reverifyMutation.isError || + publishMutation.isError) && ( - {formatError(retireMutation.error ?? reverifyMutation.error)} + {formatError( + retireMutation.error ?? + reverifyMutation.error ?? + publishMutation.error, + )} )} @@ -92,6 +104,10 @@ export const CorrectionsPanel = () => { 廃止済み + ) : correction.status === "draft" ? ( + + 未反映(回答に反映されていません) + ) : correction.needsReviewAt ? ( 要再確認(元ページが更新されました) @@ -109,6 +125,26 @@ export const CorrectionsPanel = () => {

{correction.body}

+ {correction.status === "draft" && ( +
+ + +
+ )} {correction.status === "published" && (
{correction.needsReviewAt && ( diff --git a/web/src/app/dashboard/components/review/ReviewDetailModal.tsx b/web/src/app/dashboard/components/review/ReviewDetailModal.tsx index 22a79416..51ccbdc7 100644 --- a/web/src/app/dashboard/components/review/ReviewDetailModal.tsx +++ b/web/src/app/dashboard/components/review/ReviewDetailModal.tsx @@ -201,16 +201,18 @@ export const ReviewDetailModal = ({ answerRunId, onClose }: Props) => { sourceOptions={correctionSourceOptions} />
+ ) : correctionSourceOptions.length > 0 ? ( + ) : ( - correctionSourceOptions.length > 0 && ( - - ) +

+ 参照したナレッジが無いため訂正は作成できません。情報自体が不足している場合は「情報源不足」を選んでください +

)}