From 7bcb00fd059408bf09a5b87f3ef95fb4f3afe78d Mon Sep 17 00:00:00 2001 From: THOTH Date: Sat, 10 Oct 2026 23:18:32 +0900 Subject: [PATCH 1/4] Read answer evidence when the screen shows the summary view of a result --- SOURCE_MANIFEST.json | 10 +++++----- apps/web/src/api/resultEvidence.test.ts | 24 ++++++++++++++++++++++++ apps/web/src/api/resultEvidence.ts | 14 ++++++++++---- 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/SOURCE_MANIFEST.json b/SOURCE_MANIFEST.json index 4e8176e..cb093d5 100644 --- a/SOURCE_MANIFEST.json +++ b/SOURCE_MANIFEST.json @@ -1,6 +1,6 @@ { "schema_version": "thoth-source-preview-manifest-v1", - "created_at": "2026-10-11T11:42:23.815981+00:00", + "created_at": "2026-10-11T12:00:49.692393+00:00", "license": "MIT", "git_history_included": false, "full_suite_verified": false, @@ -348,13 +348,13 @@ }, { "path": "apps/web/src/api/resultEvidence.test.ts", - "sha256": "5fc6731dbfa58e9e68dd93c7ea73b06119285d627b8fba117ae44b2b8a25b309", - "bytes": 5943 + "sha256": "4ce2f6102016c86ad2d1881c0362ecb458502d5045cf2274b4b15d64b9bb9e07", + "bytes": 8096 }, { "path": "apps/web/src/api/resultEvidence.ts", - "sha256": "2ec47763b71b7afadbf596d1324c8e3e38d0c7266ce0d952e967cb4702b86d3a", - "bytes": 7869 + "sha256": "badd582b14dc01f2ce422756700d72310948ac032ccd5a4b5a665b2e1a3a85b0", + "bytes": 8343 }, { "path": "apps/web/src/api/resultEvidenceIdentity.test.ts", diff --git a/apps/web/src/api/resultEvidence.test.ts b/apps/web/src/api/resultEvidence.test.ts index 70b91b9..9cbe68d 100644 --- a/apps/web/src/api/resultEvidence.test.ts +++ b/apps/web/src/api/resultEvidence.test.ts @@ -87,3 +87,27 @@ it("binds the historical manifest body without requiring operation-only envelope await expect(readResultEvidence(selection, { ...partial.result, thread_id: "foreign" }, { offset: 0 })).rejects.toThrow(); await expect(readResultEvidence(selection, { ...partial.result, request_epoch: 999 }, { offset: 0 })).rejects.toThrow(); }); + +it("reads the evidence when the screen shows a summary view that left some keys out", async () => { + const full = { ...partial.result, coverage: { rows: 3 }, requirements: ["r1"], source_packet: { spans: [1, 2] } }; + vi.mocked(rpc).mockImplementation(async (_method, args) => evidenceResponse(Number(args.selected_evidence_offset ?? 0), args.include_selected_evidence === true, full)); + // the displayed result is the stored one without the heavy keys, wrapper keys kept + expect((await readResultEvidence(selection, partial.result, { offset: 0 })).selected_count).toBe(149); + const { contract_version: _contract, ...withoutWrapperKey } = partial.result; + void _contract; + expect((await readResultEvidence(selection, withoutWrapperKey, { offset: 0 })).selected_count).toBe(149); + expect((await readResultEvidence(selection, full, { offset: 0 })).selected_count).toBe(149); +}); +it("still rejects a displayed value that differs, or a key the stored result does not have", async () => { + const full = { ...partial.result, coverage: { rows: 3 } }; + vi.mocked(rpc).mockImplementation(async (_method, args) => evidenceResponse(Number(args.selected_evidence_offset ?? 0), args.include_selected_evidence === true, full)); + await expect(readResultEvidence(selection, { ...partial.result, answer: "another answer" }, { offset: 0 })).rejects.toThrow("연결을 확인하지 못했습니다"); + await expect(readResultEvidence(selection, { ...partial.result, coverage: { rows: 4 } }, { offset: 0 })).rejects.toThrow("연결을 확인하지 못했습니다"); + await expect(readResultEvidence(selection, { ...partial.result, invented_key: 1 }, { offset: 0 })).rejects.toThrow("연결을 확인하지 못했습니다"); +}); +it("still needs the server's own result and the manifest to be the same whole", async () => { + const response = evidenceResponse(0, true, { ...partial.result, coverage: { rows: 3 } }); + response.value.manifest.result = { ...partial.result }; + vi.mocked(rpc).mockResolvedValue(response); + await expect(readResultEvidence(selection, partial.result, { offset: 0 })).rejects.toThrow("연결을 확인하지 못했습니다"); +}); diff --git a/apps/web/src/api/resultEvidence.ts b/apps/web/src/api/resultEvidence.ts index bdf551f..f1ea7d2 100644 --- a/apps/web/src/api/resultEvidence.ts +++ b/apps/web/src/api/resultEvidence.ts @@ -31,6 +31,11 @@ export function resultContentKey(value: unknown): string { if (value !== null && typeof value === "object") return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${resultContentKey((value as Record)[key])}`).join(",")}}`; return JSON.stringify(value) ?? "null"; } +/** The screen may show a summary view of the stored result: every key it shows must be in the stored result with the + * same value, and a key it leaves out is taken as left out by the summary. A key the stored result does not have is not. */ +function isShownPartOf(shown: Record, stored: Record): boolean { + return Object.entries(shown).every(([key, value]) => Object.hasOwn(stored, key) && resultContentKey(value) === resultContentKey(stored[key])); +} function mismatch(): never { throw new Error("선택한 답변과 근거의 연결을 확인하지 못했습니다. 답변을 다시 확인해 주세요."); } function answerContent(value: Record, thread: string, epoch: unknown, terminal: unknown) { @@ -56,12 +61,13 @@ async function read(selection: ResultSelection, displayed: Record Date: Sun, 11 Oct 2026 03:58:26 +0900 Subject: [PATCH 2/4] Name answer check items apart from trace criteria, show uncompared evidence counts, label expected results by hypothesis, and mark synthetic projects above the conversation --- SOURCE_MANIFEST.json | 76 +++++++++++-------- apps/web/src/components/AnswerHold.tsx | 2 +- .../src/components/ContractV3Screens.test.tsx | 29 +++++-- .../src/components/ConversationTimeline.tsx | 2 + .../ConversationTimelineRefetch.test.tsx | 4 +- .../ConversationTimelineSynthetic.test.tsx | 74 ++++++++++++++++++ apps/web/src/components/ExpectedResults.tsx | 8 +- .../src/components/HypothesisCompare.test.tsx | 9 ++- apps/web/src/components/HypothesisCompare.tsx | 8 +- .../src/components/HypothesisScreen.test.tsx | 4 +- .../web/src/components/ModelSettings.test.tsx | 4 +- .../ResearchEvidenceDetail.test.tsx | 1 + .../components/ResearchFollowupSummary.tsx | 10 +-- apps/web/src/components/ResearchHold.test.tsx | 23 +++++- .../src/components/ResultEvidencePanel.tsx | 1 + apps/web/src/components/ResultScreen.test.tsx | 6 +- .../src/components/SyntheticDataNotice.tsx | 17 +++++ apps/web/src/components/statusLabels.ts | 2 +- apps/web/src/workstation.css | 1 + 19 files changed, 213 insertions(+), 68 deletions(-) create mode 100644 apps/web/src/components/ConversationTimelineSynthetic.test.tsx create mode 100644 apps/web/src/components/SyntheticDataNotice.tsx diff --git a/SOURCE_MANIFEST.json b/SOURCE_MANIFEST.json index cb093d5..5f4f226 100644 --- a/SOURCE_MANIFEST.json +++ b/SOURCE_MANIFEST.json @@ -1,6 +1,6 @@ { "schema_version": "thoth-source-preview-manifest-v1", - "created_at": "2026-10-11T12:00:49.692393+00:00", + "created_at": "2026-10-11T12:00:50.638112+00:00", "license": "MIT", "git_history_included": false, "full_suite_verified": false, @@ -458,8 +458,8 @@ }, { "path": "apps/web/src/components/AnswerHold.tsx", - "sha256": "e682f1265361256d4f5e433fa4e7098eba2e094c87fc38c9ce597019f9ccfb6e", - "bytes": 1561 + "sha256": "887d4b5bd9396f712536ba523b6ce13f2005d68b010da156dab94afcb01f51fb", + "bytes": 1590 }, { "path": "apps/web/src/components/AnswerReadability.test.tsx", @@ -543,13 +543,13 @@ }, { "path": "apps/web/src/components/ContractV3Screens.test.tsx", - "sha256": "ac7213a8ddf9973cc5c3afd1b905dd1866e05a3af8b385e942b37c2ec4a87f77", - "bytes": 7243 + "sha256": "cc58a42347636f4d117d503d4f7495606cff79c5541327049f6ff543104ffdc0", + "bytes": 8555 }, { "path": "apps/web/src/components/ConversationTimeline.tsx", - "sha256": "48dc6fce194ebaa7bbea234f394edf74a5cb193754d77383e05558376d7362e3", - "bytes": 10085 + "sha256": "2cea7be6a13f3a518f5ea28b9362e55f2f447fa6ef7844c3c3014bc43feba333", + "bytes": 10209 }, { "path": "apps/web/src/components/ConversationTimelineDeniedRetention.test.tsx", @@ -568,8 +568,13 @@ }, { "path": "apps/web/src/components/ConversationTimelineRefetch.test.tsx", - "sha256": "3ca5886e9bbbe3c46d2b28723afe42898f8fd1347862d5a1502faf88b2e3bf12", - "bytes": 7547 + "sha256": "cc09437334807466bc778342d6c8b3668af40492617b4660dc560aab0d69411e", + "bytes": 7602 + }, + { + "path": "apps/web/src/components/ConversationTimelineSynthetic.test.tsx", + "sha256": "68980715c34e1c3bea9d28dbd8bfb278defdcab6e104ba84a0a5ef5e35d9f502", + "bytes": 3886 }, { "path": "apps/web/src/components/Disclosure.tsx", @@ -618,8 +623,8 @@ }, { "path": "apps/web/src/components/ExpectedResults.tsx", - "sha256": "3d0fdcae3969e2b8926316bf1196947c5c490c88164888b3f4ccc261bcba27d4", - "bytes": 1275 + "sha256": "3de49d9ded39f4d7acd164d4296ec7de3b43cb87ad1a15ddc2e6e43cba0e4e68", + "bytes": 1547 }, { "path": "apps/web/src/components/FileConnectionPanel.tsx", @@ -638,13 +643,13 @@ }, { "path": "apps/web/src/components/HypothesisCompare.test.tsx", - "sha256": "efd9ebb1f547365b188d4d0c34efb93dc945676eca6baaeb316ecfd7476dd818", - "bytes": 6869 + "sha256": "244d2a05a9cf656a840230fa670ced4f1e6a91952d2fc083c0575b7212366802", + "bytes": 7220 }, { "path": "apps/web/src/components/HypothesisCompare.tsx", - "sha256": "71e230152818510e92a1e0ff33043e72ed8e7e0fd1bb082c0a97ec8196eba28b", - "bytes": 9156 + "sha256": "e12e92e2c125ae7628ec599e12515b9d5dd34078096a65e3a17334445e7f3d70", + "bytes": 9372 }, { "path": "apps/web/src/components/HypothesisCompareDraft.test.tsx", @@ -693,8 +698,8 @@ }, { "path": "apps/web/src/components/HypothesisScreen.test.tsx", - "sha256": "1d3f68fd3458e5a1b384c4f5f6d671ebae1c1abded92720bc966628f63edda02", - "bytes": 10645 + "sha256": "ddf07819291960009011f0a3b7b9cbe139301af2461b125f5ec88a72542d5499", + "bytes": 10655 }, { "path": "apps/web/src/components/LessonRecall.test.tsx", @@ -748,8 +753,8 @@ }, { "path": "apps/web/src/components/ModelSettings.test.tsx", - "sha256": "2a12649f0a89d9a3ba3cba7d5b6500680dbb5ba81e0079991695bbc1a2614b00", - "bytes": 23318 + "sha256": "78d20aa3c8126b7f4a978826bc1119213b13615345a95d1bcfc6f24bd477d711", + "bytes": 23406 }, { "path": "apps/web/src/components/ModelSettings.tsx", @@ -833,18 +838,18 @@ }, { "path": "apps/web/src/components/ResearchEvidenceDetail.test.tsx", - "sha256": "9b2e1573fa475382ba29a6af339263c29200e9971f2759a7dfb8874d392fa4d2", - "bytes": 10627 + "sha256": "db7715c70ee563e82544944ea955b339ab1224b8de32ba9bd70398b68856601d", + "bytes": 10927 }, { "path": "apps/web/src/components/ResearchFollowupSummary.tsx", - "sha256": "dcf25f66a3e2ed86978828551022df13e51292c733f57dc5b9aef6bbc8492c0b", - "bytes": 10005 + "sha256": "008da642697cd4cfa493399095768704d236f8b747199743e972d50899cc731b", + "bytes": 10033 }, { "path": "apps/web/src/components/ResearchHold.test.tsx", - "sha256": "4c99039a3f0189fb080f4dee407173673d4749bd6dca560d5aa7f33cc5615ad1", - "bytes": 5848 + "sha256": "2107537ed6cf5fdfc34ce290175f63c33821df8fb645ccf7807c4d1b133462f8", + "bytes": 7061 }, { "path": "apps/web/src/components/ResearchLiveProgress.tsx", @@ -873,8 +878,8 @@ }, { "path": "apps/web/src/components/ResultEvidencePanel.tsx", - "sha256": "46ac8d2966cfd8533ebae696ad7f232d1393d9d5da04d32d0b6fc9bab2fc7a11", - "bytes": 5106 + "sha256": "897a8f56fef32d758bbe8a42e10dd2ed1af4b5a66a8cf3496e85068d40600598", + "bytes": 5396 }, { "path": "apps/web/src/components/ResultExample.tsx", @@ -883,8 +888,8 @@ }, { "path": "apps/web/src/components/ResultScreen.test.tsx", - "sha256": "f8e0e257e0106bb792deb265842b461cb4263a7bba05b165e559278bb1b1c8a8", - "bytes": 11671 + "sha256": "0933e4c6d988d3955de85e76118049fa2bd5f313c6e9acaa91101d6b9cb0245c", + "bytes": 11706 }, { "path": "apps/web/src/components/ReviewDialog.tsx", @@ -931,6 +936,11 @@ "sha256": "8ba883cf96879aa2254fd9d4919a4d33c0fc93d9e9bc77c8e653a58dc521c977", "bytes": 742 }, + { + "path": "apps/web/src/components/SyntheticDataNotice.tsx", + "sha256": "df4bfdb7ce007d47e263a5920d317bd0b2d3b47bef3efdb718dc6724864bc659", + "bytes": 1093 + }, { "path": "apps/web/src/components/TestActionDraft.test.tsx", "sha256": "f8b421b3d715d61ccc6df44e33ea1ab6196d69f33dc6d0fbef30be06bb6eccc9", @@ -1278,8 +1288,8 @@ }, { "path": "apps/web/src/components/statusLabels.ts", - "sha256": "ae64b841f2787503a58fe55e44acc062651fd0c18612a4f5913b27dd98f62441", - "bytes": 9561 + "sha256": "df8adae9511fb200d9cd52058c5e2d746a60ad3d0cea2e2aeab11380132fe612", + "bytes": 9569 }, { "path": "apps/web/src/components/testActionDraftText.ts", @@ -1423,8 +1433,8 @@ }, { "path": "apps/web/src/workstation.css", - "sha256": "98455faba74c66ddd2589f44e0efab2a9eea7bc2540ddd8bc76aa84b894ff070", - "bytes": 58207 + "sha256": "8f543e6734c2ab140a5b551cb7477fb0cd43a2844da67c1398dcc67b0f18acd5", + "bytes": 58339 }, { "path": "apps/web/timeline-preview.html", diff --git a/apps/web/src/components/AnswerHold.tsx b/apps/web/src/components/AnswerHold.tsx index e669cfa..389f88d 100644 --- a/apps/web/src/components/AnswerHold.tsx +++ b/apps/web/src/components/AnswerHold.tsx @@ -19,7 +19,7 @@ export function AnswerHold({ status, coverage, facts, counter, next }: {

결론 상태: {label}

{facts} {rows.length > 0 && } -

답할 수 있는 범위: {rows.length ? `기준 충족 ${supported}/${rows.length} · 확인 필요 ${needsCheck}` : "평가기준 정보를 아직 읽지 못했습니다"}

+

답할 수 있는 범위: {rows.length ? `답변 점검 항목 ${rows.length}개 중 충족 ${supported} · 확인 필요 ${needsCheck}` : "답변 점검 항목 정보를 아직 읽지 못했습니다"}

{counter &&

{counter}

} {next &&

다음 확보: {next}

} ; diff --git a/apps/web/src/components/ContractV3Screens.test.tsx b/apps/web/src/components/ContractV3Screens.test.tsx index b1df628..c3ea392 100644 --- a/apps/web/src/components/ContractV3Screens.test.tsx +++ b/apps/web/src/components/ContractV3Screens.test.tsx @@ -26,6 +26,12 @@ const result = (tests: Json[], conditions?: string[]) => { return copy as unknown as Record; }; const order = (html: string, names: string[]) => names.map(name => html.indexOf(name)); +// Gives the first hypotheses a statement (an empty string means none was written). +const named = (value: Record, statements: string[]) => { + const hypotheses = (value as unknown as { portfolio: { hypotheses: Json[] } }).portfolio.hypotheses; + statements.forEach((statement, at) => { hypotheses[at].statement = statement; }); + return value; +}; const sorted = (positions: number[]) => positions.every((value, at) => value >= 0 && (at === 0 || value > positions[at - 1])); const section = (value: Record) => renderToStaticMarkup(); @@ -83,23 +89,32 @@ it("shows the model's refutation conditions apart, as an unconfirmed suggestion, }); it("lists what each hypothesis is expected to give for a test, in words, with how many sources it rests on", async () => { - await mount(result([test("t1", "시험", rows([0, "나쁘다", ["span:a", "span:b"]], [1, "같다"], [2, "모름"]))])); + await mount(named(result([test("t1", "시험", rows([0, "나쁘다", ["span:a", "span:b"]], [1, "같다"], [2, "모름"]))]), ["탐지 정의 오류", "라벨 오류", "표본 부족"])); const text = card().querySelector("details.expected-results")!.textContent!; expect(text).toContain("각 가설의 예상 결과(AI 제안)"); - expect(text).toContain("가설 1: 나쁘다 · 근거 2개"); - expect(text).toContain("가설 2: 같다"); - expect(text).toContain("가설 3: 모름"); + expect(text).toContain("가설 1(탐지 정의 오류): 나쁘다 · 근거 2개"); + expect(text).toContain("가설 2(라벨 오류): 같다"); + expect(text).toContain("가설 3(표본 부족): 모름"); expect(text).toContain("확률이나 점수가 아닙니다"); expect(text).not.toMatch(LEAK); }); -it("marks a prediction that rests on no source, and leaves 모름 and sourced predictions as they were", async () => { - await mount(result([test("t1", "시험", rows([0, "나쁘다", ["span:a"]], [1, "같다"], [2, "모름"]))])); +it("marks a prediction that no material confirmed, and leaves 모름 and sourced predictions as they were", async () => { + await mount(named(result([test("t1", "시험", rows([0, "나쁘다", ["span:a"]], [1, "같다"], [2, "모름"]))]), ["가", "나", "다"])); const lines = [...card().querySelectorAll("details.expected-results li")].map(item => item.textContent); - expect(lines).toEqual(["가설 1: 나쁘다 · 근거 1개", "가설 2: 같다 · 근거 없는 예측", "가설 3: 모름"]); + expect(lines).toEqual(["가설 1(가): 나쁘다 · 근거 1개", "가설 2(나): 같다 · 자료로 확인되지 않은 예상", "가설 3(다): 모름"]); expect(lines.join(" ")).not.toMatch(BANNED); }); +it("shortens a long hypothesis statement in the list and writes only the number when the statement is empty", async () => { + const long = "탐지 정의와 판정 기준이 서로 달라 같은 장면을 다르게 세는 오류가 생긴다고 보는 설명입니다"; + await mount(named(result([test("t1", "시험", rows([0, "나쁘다", ["span:a"]], [1, "같다"], [2, "모름"]))]), [long, "", "표본 부족"])); + const lines = [...card().querySelectorAll("details.expected-results li")].map(item => item.textContent!); + expect(lines[0]).toBe(`가설 1(${long.slice(0, 39)}…): 나쁘다 · 근거 1개`); + expect(lines[1]).toBe("가설 2: 같다 · 자료로 확인되지 않은 예상"); + expect(lines[2]).toBe("가설 3(표본 부족): 모름"); +}); + it("shows nothing of v3 on an old result", async () => { await mount(result([test("t1", "시험")])); expect(card().querySelector(".ai-refutation")).toBeNull(); diff --git a/apps/web/src/components/ConversationTimeline.tsx b/apps/web/src/components/ConversationTimeline.tsx index d948679..7980263 100644 --- a/apps/web/src/components/ConversationTimeline.tsx +++ b/apps/web/src/components/ConversationTimeline.tsx @@ -15,6 +15,7 @@ import { ResearchResultCard, type ResearchDetail } from "./ResearchResultCard"; import { ResearchLiveProgress } from "./ResearchLiveProgress"; import { resultUsageLine } from "./resultUsage"; import { useHypothesisLinks } from "./useHypothesisLinks"; +import { SyntheticDataNotice } from "./SyntheticDataNotice"; export function ConversationTimeline({ projectId, threadId, status, sourceUris, onDetail, onContinue, onOpenHistory, onOpenTrace, onRetry, onResume, resuming = false, queuedInstruction = false }: { projectId: string; threadId: string; status?: ResearchStatus; sourceUris?: string[]; queuedInstruction?: boolean; @@ -66,6 +67,7 @@ export function ConversationTimeline({ projectId, threadId, status, sourceUris, const resumeOperation = liveStatus?.request?.operation_id; const emptyExistingThread = Boolean(threadId && query.data && turns.length === 0 && !currentQuestion && !running); return
+ {!example && } {query.hasNextPage && } {query.error && {query.data ? "대화를 다시 확인하지 못했습니다. 마지막으로 불러온 답변을 표시하며, 최신 상태는 확인되지 않았습니다. " : "대화를 불러오지 못했습니다. "}} {query.data?.pages.some(page => page.limited) && 조회 범위를 넘는 과거 이력이 있습니다. 기록에서 확인할 수 있습니다.} diff --git a/apps/web/src/components/ConversationTimelineRefetch.test.tsx b/apps/web/src/components/ConversationTimelineRefetch.test.tsx index abf633a..3b835be 100644 --- a/apps/web/src/components/ConversationTimelineRefetch.test.tsx +++ b/apps/web/src/components/ConversationTimelineRefetch.test.tsx @@ -72,8 +72,8 @@ it("keeps the saved answer with a stale-read warning after window refocus fails await settle(() => Boolean(container.textContent?.includes("최신 상태는 확인되지 않았습니다"))); expect(container.textContent).toContain("저장된 답변 1"); expect(container.textContent).not.toContain("연구를 완료하지 못했습니다"); - // only reads; the marks for hypotheses that came from a trace row are one more read-only query - expect(vi.mocked(rpc).mock.calls.every(([method]) => ["thread/activity/list", "operation/result/read", "hypothesis/link/list"].includes(method))).toBe(true); + // only reads; the marks for hypotheses that came from a trace row and the synthetic-data line (a trace read) are more read-only queries + expect(vi.mocked(rpc).mock.calls.every(([method]) => ["thread/activity/list", "operation/result/read", "hypothesis/link/list", "trace/read"].includes(method))).toBe(true); await returnToWindow("success"); await settle(() => !container.textContent?.includes("최신 상태는 확인되지 않았습니다")); expect(container.textContent).toContain("저장된 답변 1"); diff --git a/apps/web/src/components/ConversationTimelineSynthetic.test.tsx b/apps/web/src/components/ConversationTimelineSynthetic.test.tsx new file mode 100644 index 0000000..0c222a5 --- /dev/null +++ b/apps/web/src/components/ConversationTimelineSynthetic.test.tsx @@ -0,0 +1,74 @@ +// @vitest-environment jsdom +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, expect, it, vi } from "vitest"; +import { rpc } from "../api/rpcClient"; +import type { TraceItem, TraceView } from "../api/trace"; +import { ConversationTimeline } from "./ConversationTimeline"; + +vi.mock("../api/rpcClient", async importOriginal => ({ + ...await importOriginal(), + rpc: vi.fn(), +})); + +let root: Root | undefined; +let container: HTMLDivElement; +let client: QueryClient; + +const item = (title: string, fields: Record = {}): TraceItem => ({ item_id: "i1", item_key: "k1", kind: "REQUIREMENT", title, fields, source_span_refs: [] }); +const view = (...items: TraceItem[]): TraceView => ({ record_digest: null, set_digest: "d", items, links: [], rules: [], results: [], verdicts: [], confirmations: [], pending_changes: [] }); +const conversation = { operation_id: "", state: "SUCCEEDED", value: { conversation: { turns: [], next_before_epoch: null, history_limited: false } } }; + +afterEach(async () => { + if (root) await act(async () => root!.unmount()); + root = undefined; client?.clear(); container?.remove(); vi.resetAllMocks(); +}); + +async function mount(trace: TraceView | Error) { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + vi.mocked(rpc).mockImplementation(async method => { + if (method === "trace/read") { if (trace instanceof Error) throw trace; return { operation_id: "", state: "SUCCEEDED", value: trace }; } + return conversation; + }); + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + container = document.createElement("div"); document.body.append(container); root = createRoot(container); + await act(async () => root!.render( + undefined}/>)); + for (let index = 0; index < 30; index += 1) { + await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); }); + if (container.textContent?.includes("이 작업의 대화 기록을 현재 화면에서 불러올 수 없습니다")) { + await act(async () => { await new Promise(resolve => setTimeout(resolve, 50)); }); // let the trace read settle too + return; + } + } + throw new Error("The conversation never finished loading"); +} +const notice = () => container.querySelector(".conversation-synthetic-notice"); + +it("shows a thin synthetic-data line above the conversation when the project's trace carries the demo marker", async () => { + await mount(view(item("SYNTHETIC DEMO DATA - 요구사항"))); + expect(notice()?.textContent).toBe("합성 데이터입니다. 실제 측정값이 아니며 공학 용도로 쓸 수 없습니다."); + expect(container.firstElementChild!.firstElementChild).toBe(notice()); + const calls = vi.mocked(rpc).mock.calls.map(([method]) => method); + expect(calls.filter(method => method === "trace/read")).toHaveLength(1); +}); + +it("also recognises the synthetic_notice field the trace banner reads", async () => { + await mount(view(item("요구사항", { synthetic_notice: "SYNTHETIC DEMO DATA - NOT MEASURED" }))); + expect(notice()).not.toBeNull(); +}); + +it("shows nothing for an ordinary project", async () => { + await mount(view(item("검출율 요구사항", { note: "실측" }))); + expect(notice()).toBeNull(); + expect(container.textContent).not.toContain("합성 데이터"); +}); + +it("shows nothing when the trace cannot be read or has no trace table", async () => { + await mount(new Error("denied")); + expect(notice()).toBeNull(); + await act(async () => root!.unmount()); root = undefined; client.clear(); container.remove(); + await mount(view()); + expect(notice()).toBeNull(); +}); diff --git a/apps/web/src/components/ExpectedResults.tsx b/apps/web/src/components/ExpectedResults.tsx index e553e5c..ddc3bb4 100644 --- a/apps/web/src/components/ExpectedResults.tsx +++ b/apps/web/src/components/ExpectedResults.tsx @@ -1,6 +1,9 @@ import { objectList, stringValues, textValue } from "../api/presentation"; import type { HypothesisRow } from "./hypothesisView"; +const SHORT = 40; +const shortStatement = (text: string) => (text.length > SHORT ? text.slice(0, SHORT - 1) + "…" : text); + /** What the generator said each hypothesis would give for one test (contract v3), in words. An unconfirmed suggestion; no probability or score. */ export function ExpectedResults({ test, rows }: { test: Record; rows: HypothesisRow[] }) { const table = objectList(test.expected_by_hypothesis); @@ -11,8 +14,9 @@ export function ExpectedResults({ test, rows }: { test: Record; const index = rows.findIndex(row => row.id === textValue(item.hypothesis_id)); const sources = stringValues(item.basis).length; const expected = textValue(item.expected); - const note = sources > 0 ? " · 근거 " + sources + "개" : expected.trim() === "모름" ? "" : " · 근거 없는 예측"; - return
  • {"가설 " + (index >= 0 ? index + 1 : "?") + ": " + expected + note}
  • ; + const note = sources > 0 ? " · 근거 " + sources + "개" : expected.trim() === "모름" ? "" : " · 자료로 확인되지 않은 예상"; + const statement = index >= 0 ? shortStatement(rows[index].statement.trim()) : ""; + return
  • {"가설 " + (index >= 0 ? index + 1 : "?") + (statement ? "(" + statement + ")" : "") + ": " + expected + note}
  • ; })} 모델이 낸 제안이며 사람이 확인하기 전에는 미확정입니다. 같은 말은 같은 결과로 봅니다. 확률이나 점수가 아닙니다. ; diff --git a/apps/web/src/components/HypothesisCompare.test.tsx b/apps/web/src/components/HypothesisCompare.test.tsx index 99d752b..68610a9 100644 --- a/apps/web/src/components/HypothesisCompare.test.tsx +++ b/apps/web/src/components/HypothesisCompare.test.tsx @@ -47,7 +47,7 @@ it("merges evidence_refs with the older support field and counts each source onc it("shows counts, the counter-evidence search state and no internal ids on the cards", async () => { await mount(result(hyps => { hyps[1].critical_review = { terminal: "UNRESOLVED_NO_RESULTS", reasons: ["x"] }; })); const [first, second] = cards(); - expect(first.textContent).toContain(`뒷받침 3 · 반박 - · 미평가 ${selected.length - 3}`); + expect(first.textContent).toContain(`뒷받침 3 · 반박 - · 아직 대조 안 한 근거 ${selected.length - 3}/${selected.length}`); expect(first.textContent).toContain("반대 근거 탐색 안 함"); expect(second.textContent).toContain("반대 근거 탐색에서 결과가 없어 미해결"); expect(second.textContent).not.toContain("반대 근거 탐색 안 함"); @@ -64,7 +64,10 @@ it("filters the relation table to counter and unevaluated cells by default", asy expect(summary).toBe(`표시 ${selected.length}/전체 ${selected.length}`); const labels = Array.from(table.querySelectorAll("tbody td .bp6-tag, tbody td .relation-cell")).map(item => item.textContent); expect(labels).toContain("지지"); - expect(labels).toContain("미평가"); + expect(labels).toContain("대조 안 함"); + expect(labels).not.toContain("미평가"); + expect(node.textContent).toContain("반박·대조 안 한 것만"); + expect(node.querySelector("table.relation-table")!.closest("section")!.textContent).toContain("선택된 근거 전체 중 이 가설과 아직 견주어 보지 않은 근거의 수"); const all = Array.from(node.querySelectorAll("button")).find(button => button.textContent === "전체 보기")!; await act(async () => all.click()); expect(node.querySelector(".relation-summary")!.textContent).toBe(`표시 ${selected.length}/전체 ${selected.length}`); @@ -108,6 +111,6 @@ it("omits the unevaluated count when the answer's selected evidence list is unkn const value = result(); delete value.selected_evidence_refs; await mount(value); expect(cards()[0].textContent).toContain("뒷받침 3 · 반박 -"); - expect(cards()[0].textContent).not.toContain("미평가"); + expect(cards()[0].textContent).not.toContain("대조 안 한"); expect(node.querySelector("table.relation-table")).toBeNull(); }); diff --git a/apps/web/src/components/HypothesisCompare.tsx b/apps/web/src/components/HypothesisCompare.tsx index e518c48..c53efb2 100644 --- a/apps/web/src/components/HypothesisCompare.tsx +++ b/apps/web/src/components/HypothesisCompare.tsx @@ -24,7 +24,7 @@ import { STANDING_NOTE, standingLine } from "./judgmentRecordText"; import type { SameView } from "../api/hypothesisSame"; import { SameHypothesis } from "./SameHypothesis"; -const relationLabel: Record = { SUPPORT: "지지", COUNTER: "반박", NOT_APPLICABLE: "해당 없음", UNEVALUATED: "미평가" }; +const relationLabel: Record = { SUPPORT: "지지", COUNTER: "반박", NOT_APPLICABLE: "해당 없음", UNEVALUATED: "대조 안 함" }; const relationIntent = { SUPPORT: "success", COUNTER: "danger", NOT_APPLICABLE: "none", UNEVALUATED: "none" } as const; const standingOf = (items: DiscriminationItem[] | undefined, id: string) => items?.find(item => item.hypothesis_id === id)?.standing; @@ -64,7 +64,7 @@ export function HypothesisCompare({ result, review, projectId, links, distributi {row.counterTerminal && row.counter.length === 0 &&

    반박 근거를 찾지 못함 (지지된다는 뜻은 아닙니다)

    }
    뒷받침 {row.support.length}{" · "}반박 {row.counterTerminal ? row.counter.length : "-"} - {row.unevaluated !== null && <>{" · "}미평가 {row.unevaluated}} + {row.unevaluated !== null && <>{" · "}아직 대조 안 한 근거 {row.unevaluated}/{selected?.length}}

    반대 근거를 찾을 질문

    {row.counterQueries.map((query, j) =>

    {query}

    )} {row.aiRefutation.length > 0 &&

    AI 제안(미확정) 기각 조건

    @@ -83,10 +83,10 @@ export function HypothesisCompare({ result, review, projectId, links, distributi
    )} {table && shown &&

    근거와 가설의 관계

    -

    AI가 판단한 관계입니다. 칸을 직접 고치지 않고, 자료 추가나 방향 지시로 다시 조사하게 합니다. 아직 살펴보지 않은 칸은 "미평가"로 보입니다.

    +

    AI가 판단한 관계입니다. 칸을 직접 고치지 않고, 자료 추가나 방향 지시로 다시 조사하게 합니다. 아직 견주어 보지 않은 칸은 "대조 안 함"으로 보입니다. 가설 카드의 "아직 대조 안 한 근거 N/M"은 선택된 근거 전체 중 이 가설과 아직 견주어 보지 않은 근거의 수입니다.

    {counterNote &&

    {counterNote}

    }
    - + {`표시 ${shown.length}/전체 ${table.length}`}
    diff --git a/apps/web/src/components/HypothesisScreen.test.tsx b/apps/web/src/components/HypothesisScreen.test.tsx index 6dc0848..efb3bb7 100644 --- a/apps/web/src/components/HypothesisScreen.test.tsx +++ b/apps/web/src/components/HypothesisScreen.test.tsx @@ -109,9 +109,9 @@ it("writes unevaluated cells as text, marks a row that tells hypotheses apart, a await mount(value); const rows = Array.from(node.querySelectorAll("table.relation-table tbody tr")); expect(rows[0].textContent).toContain("가설을 가름"); - expect(rows[0].textContent).toContain("미평가"); // written as a word, not an empty cell + expect(rows[0].textContent).toContain("대조 안 함"); // written as a word, not an empty cell expect(rows[1].textContent).not.toContain("가설을 가름"); - expect(Array.from(rows[1].querySelectorAll("td")).slice(1).every(cell => cell.textContent!.includes("미평가"))).toBe(true); + expect(Array.from(rows[1].querySelectorAll("td")).slice(1).every(cell => cell.textContent!.includes("대조 안 함"))).toBe(true); }); it("does not mark a row as telling hypotheses apart when only one is evaluated and the rest are not", () => { diff --git a/apps/web/src/components/ModelSettings.test.tsx b/apps/web/src/components/ModelSettings.test.tsx index a44fcdf..7670d10 100644 --- a/apps/web/src/components/ModelSettings.test.tsx +++ b/apps/web/src/components/ModelSettings.test.tsx @@ -279,8 +279,8 @@ it("a late admission cannot replace a different selected thread",async()=>{ it("ordinary navigation only automatically calls the actual nonmutating query surface",async()=>{ await mount(); - // A8: opening a project also reads its latest work (read-only thread queries), and which hypotheses came from a trace row (a read-only query). - expect(fixture.calls.filter(call=>!["project/list","thread/list","project/source/list","evidence/list","model/settings/read","workspace/ready","thread/read","thread/activity/list","hypothesis/link/list"].includes(call.method)).map(call=>call.method)).toEqual([]); + // A8: opening a project also reads its latest work (read-only thread queries), which hypotheses came from a trace row, and whether the trace is demo data for the conversation's synthetic-data line (read-only queries). + expect(fixture.calls.filter(call=>!["project/list","thread/list","project/source/list","evidence/list","model/settings/read","workspace/ready","thread/read","thread/activity/list","hypothesis/link/list","trace/read"].includes(call.method)).map(call=>call.method)).toEqual([]); await act(async()=>{container.querySelector('[role="tab"][data-tab-id="records"]')?.click();}); expect(fixture.calls.some(call=>call.method==="receipt/verify")).toBe(false); }); diff --git a/apps/web/src/components/ResearchEvidenceDetail.test.tsx b/apps/web/src/components/ResearchEvidenceDetail.test.tsx index d21e4f8..62d5237 100644 --- a/apps/web/src/components/ResearchEvidenceDetail.test.tsx +++ b/apps/web/src/components/ResearchEvidenceDetail.test.tsx @@ -58,6 +58,7 @@ it("opens the synthetic partial answer's sources and loads 50/50/49 selected ent expect(node.textContent).toContain(citations[0].exact_text); expect(node.textContent).toContain("p.1 · line 1"); expect(node.textContent).toContain("확인된 원문 25개"); + expect(node.textContent).toContain("‘확인된 원문’은 지금까지 불러온 근거 중 원문을 보여 줄 수 있는 것의 수입니다. 한 번에 일부만 불러오므로 선택된 근거 전체보다 적을 수 있고, 나머지는 ‘다음 근거 보기’로 불러옵니다."); expect(node.textContent).toContain("직접 인용한 개수와 다를 수 있습니다"); expect(node.textContent).not.toContain("활성 근거 0개"); expect(vi.mocked(rpc).mock.calls.map(([method]) => method)).toEqual(Array(4).fill("thread/result/read")); diff --git a/apps/web/src/components/ResearchFollowupSummary.tsx b/apps/web/src/components/ResearchFollowupSummary.tsx index 5bff588..1e20951 100644 --- a/apps/web/src/components/ResearchFollowupSummary.tsx +++ b/apps/web/src/components/ResearchFollowupSummary.tsx @@ -37,7 +37,7 @@ function coverageIntent(status: CoverageMatrixRow["status"]) { /** Server progress keys with a user wording; anything else that looks like an internal id is only counted. */ const progressTerms: Record = { - "result:terminal": "답변이 최종 상태로 기록됐습니다", + "result:terminal": "조사 실행이 끝났습니다", "result:checkpoint": "답변이 중간 점검 상태로 기록됐습니다", requirements_recorded: "평가 조건이 기록됐습니다", coverage_assessed: "조건별 충족 여부를 평가했습니다", @@ -126,7 +126,7 @@ export function ResearchFollowupSummary({ {progress &&

    검토 요약

    {compactList(progress.progress_items, "기록된 진행 요약이 없습니다.")}
    -

    직접 근거

    {evidenceRefs(rows, answerEvidenceCount)}{onOpenEvidence && }
    +

    이 요약에 연결된 근거

    {evidenceRefs(rows, answerEvidenceCount)}{onOpenEvidence && }

    바뀐 판단

    {deltaLabel(delta)}

    {delta?.reason_state === "UNKNOWN_REASON" &&

    변경 이유는 저장된 기록에서 확인되지 않았습니다.

    }

    필요한 다음 행동

    {actionLabel(nextAction)}

    {nextAction &&
    기술 정보{nextAction.label}
    }{nextAction?.requires_permission &&

    권한 결정이 필요한 행동입니다.

    }
    } @@ -138,9 +138,9 @@ export function ResearchFollowupSummary({

    {currentnessLabel(progress.currentness)}

    } - {coverage && 0} label={`평가기준 ${rows.length}개`}> - {coverage.availability === "UNAVAILABLE" ?

    현재 권한으로 평가기준 행을 읽을 수 없습니다.

    : rows.length === 0 ?

    이 답변에 연결된 평가기준 행이 없습니다.

    - :
    + {coverage && 0} label={`답변 점검 항목 ${rows.length}개`}> + {coverage.availability === "UNAVAILABLE" ?

    현재 권한으로 답변 점검 항목을 읽을 수 없습니다.

    : rows.length === 0 ?

    이 답변에 연결된 답변 점검 항목이 없습니다.

    + :
    기준기준 상태판단
    {rows.map(row => { const text = criterionText(row.target, row.question); diff --git a/apps/web/src/components/ResearchHold.test.tsx b/apps/web/src/components/ResearchHold.test.tsx index 1b6ad3c..ac084a7 100644 --- a/apps/web/src/components/ResearchHold.test.tsx +++ b/apps/web/src/components/ResearchHold.test.tsx @@ -20,7 +20,7 @@ const render = (result: Record, extra: Record it("puts two hold lines inside the answer card for a real held IRIS answer", () => { const html = render(excerpt, { coverageMatrix: coverage([row("SATISFIED"), row("SATISFIED"), row("UNRESOLVED"), row("NOT_ASSESSED"), row("NOT_APPLICABLE")]) }); expect(html).toContain("결론 상태: 판단 보류"); - expect(html).toContain("답할 수 있는 범위: 기준 충족 2/5 · 확인 필요 2"); + expect(html).toContain("답할 수 있는 범위: 답변 점검 항목 5개 중 충족 2 · 확인 필요 2"); expect(html.indexOf("결론 상태")).toBeGreaterThan(html.indexOf("answer-text")); expect(html.indexOf("결론 상태")).toBeLessThan(html.indexOf("answer-actions")); }); @@ -28,7 +28,7 @@ it("puts two hold lines inside the answer card for a real held IRIS answer", () it("names the other answer statuses and a missing coverage read", () => { expect(render({ answer: "a", answer_status: "ASSESSED_WITH_OPEN_CHECKS" })).toContain("부분 답변"); expect(render({ answer: "a", answer_status: "ASSESSED_FOR_REQUEST" })).toContain("답변 완료"); - expect(render({ answer: "a", answer_status: "PARTIAL_HOLD" })).toContain("답할 수 있는 범위: 평가기준 정보를 아직 읽지 못했습니다"); + expect(render({ answer: "a", answer_status: "PARTIAL_HOLD" })).toContain("답할 수 있는 범위: 답변 점검 항목 정보를 아직 읽지 못했습니다"); expect(render({ answer: "a" })).not.toContain("결론 상태"); }); @@ -43,6 +43,22 @@ it("splits the criteria table into status and judgment so unresolved is not show expect(visible(html)).not.toContain("RESEARCH_GAP"); }); +it("calls the answer's own checks 답변 점검 항목 so they are not mixed up with the trace table's criteria", () => { + const html = render({ answer: "a", answer_status: "PARTIAL_HOLD" }, { progressSummary: progress(), coverageMatrix: coverage([row("UNRESOLVED"), row("SATISFIED")]) }); + const text = visible(html); + expect(html).toContain("답변 점검 항목 2개"); + expect(html).toContain('aria-label="답변 점검 항목 충족 상태"'); + expect(text).not.toContain("평가기준"); + // The list stays folded when nothing is open, so only its title shows; the empty and unreadable wording sits inside it. + expect(render({ answer: "a" }, { progressSummary: progress(), coverageMatrix: coverage([]) })).toContain("답변 점검 항목 0개"); +}); + +it("names the evidence linked to this summary without reusing the 직접 사용 가능 wording", () => { + const text = visible(render({ answer: "a" }, { progressSummary: progress(), coverageMatrix: coverage([row("SATISFIED", { evidence_refs: ["span:x"] })]) })); + expect(text).toContain("이 요약에 연결된 근거"); + expect(text).not.toContain("직접 근거"); +}); + it("says where the answer's sources are instead of claiming none were recorded", () => { const html = render({ answer: "a", selected_evidence_count: 71 }, { progressSummary: progress(), coverageMatrix: coverage([row("SATISFIED")]) }); expect(html).toContain("이 검토 요약에 따로 연결된 근거는 없습니다. 답변 근거 71개는 '근거 원문'에서 볼 수 있습니다."); @@ -63,7 +79,8 @@ it("words the terminal result marker and keeps other internal codes out of the v "requirement:req-1:UNRESOLVED", "requirement:req-2:SATISFIED", "EVIDENCE_TOO_OLD", "RESULT_NOT_PRODUCED", "자료가 오래됐습니다", ]) }); const text = visible(html); - expect(text).toContain("답변이 최종 상태로 기록됐습니다"); + expect(text).toContain("조사 실행이 끝났습니다"); + expect(text).not.toContain("최종 상태로 기록"); expect(text).toContain("평가 조건이 기록됐습니다"); expect(text).toContain("아직 답변이 만들어지지 않았습니다"); expect(text).toContain("자료가 오래됐습니다"); diff --git a/apps/web/src/components/ResultEvidencePanel.tsx b/apps/web/src/components/ResultEvidencePanel.tsx index 04b1beb..0a39cd6 100644 --- a/apps/web/src/components/ResultEvidencePanel.tsx +++ b/apps/web/src/components/ResultEvidencePanel.tsx @@ -45,6 +45,7 @@ export function ResultEvidencePanel({ selection, result, focusSpanId, citations

    이 답변에 선택된 근거 {first.selected_count}개

    연구에 선택된 원문 목록입니다. 답변 본문에서 직접 인용한 개수와 다를 수 있습니다.

    {first.selected_count === 0 ?

    이 저장 버전에 선택된 근거가 없습니다.

    : <> +

    ‘확인된 원문’은 지금까지 불러온 근거 중 원문을 보여 줄 수 있는 것의 수입니다. 한 번에 일부만 불러오므로 선택된 근거 전체보다 적을 수 있고, 나머지는 ‘다음 근거 보기’로 불러옵니다.

    {focusSpanId && !focusLoaded && !query.hasNextPage &&

    이 근거를 목록에서 찾지 못했습니다. 답변이 인용한 원문이 이 저장 버전의 선택 근거에 없습니다.

    } {focusSpanId && unavailable.some(item => item.span_id === focusSpanId) &&

    이 근거는 원문을 표시할 수 없습니다. 아래 안내를 확인하세요.

    } {available.length > 0 && } diff --git a/apps/web/src/components/ResultScreen.test.tsx b/apps/web/src/components/ResultScreen.test.tsx index c36b5e3..d455e3f 100644 --- a/apps/web/src/components/ResultScreen.test.tsx +++ b/apps/web/src/components/ResultScreen.test.tsx @@ -55,7 +55,7 @@ it("counts a value no rule covers as unclassified instead of guessing", () => { it("shows criteria fulfilled over the total, then the evidence-use groups, each criterion with its own fulfilment state", () => { const html = render(held, { coverageMatrix: coverage(iris) }); - expect(html).toContain("기준 충족 0/7 · 확인 필요 7"); + expect(html).toContain("답변 점검 항목 7개 중 충족 0 · 확인 필요 7"); expect(html).not.toContain("근거 있음"); expect(html).toContain("직접 사용 가능 1"); expect(html).toContain("조건부·참고용 5"); @@ -72,8 +72,8 @@ it("puts the first-screen lines in the agreed order", () => { const at = (text: string) => html.indexOf(text); expect(at("결론 상태")).toBeGreaterThan(-1); expect(at("결론 상태")).toBeLessThan(at("근거 용도")); - expect(at("근거 용도")).toBeLessThan(at("기준 충족")); - expect(at("기준 충족")).toBeLessThan(at("반대 근거 탐색")); + expect(at("근거 용도")).toBeLessThan(at("답변 점검 항목")); + expect(at("답변 점검 항목")).toBeLessThan(at("반대 근거 탐색")); expect(at("반대 근거 탐색")).toBeLessThan(at("다음 확보")); }); diff --git a/apps/web/src/components/SyntheticDataNotice.tsx b/apps/web/src/components/SyntheticDataNotice.tsx new file mode 100644 index 0000000..6b37467 --- /dev/null +++ b/apps/web/src/components/SyntheticDataNotice.tsx @@ -0,0 +1,17 @@ +import { Callout } from "@blueprintjs/core"; +import { useQuery } from "@tanstack/react-query"; +import { readTrace, traceKey } from "../api/trace"; +import { isSyntheticTrace } from "./traceRows"; + +export const SYNTHETIC_LINE = "합성 데이터입니다. 실제 측정값이 아니며 공학 용도로 쓸 수 없습니다."; + +/** + * A thin line above the conversation when the project's trace table was written as demo data. It reads the trace with the + * same query the trace page uses (one existing read, shared cache); no table, an unreadable table, or no marker shows nothing. + */ +export function SyntheticDataNotice({ projectId }: { projectId: string }) { + const query = useQuery({ queryKey: traceKey(projectId), enabled: Boolean(projectId), retry: false, staleTime: 30_000, queryFn: ({ signal }) => readTrace(projectId, signal) }); + const view = query.data; + if (!view || !Array.isArray(view.items) || !isSyntheticTrace(view)) return null; + return {SYNTHETIC_LINE}; +} diff --git a/apps/web/src/components/statusLabels.ts b/apps/web/src/components/statusLabels.ts index f74c968..20e9a64 100644 --- a/apps/web/src/components/statusLabels.ts +++ b/apps/web/src/components/statusLabels.ts @@ -71,7 +71,7 @@ export function actionStateLabel(value: string | null | undefined): string | nul const coverageTerms: Record = { RESEARCH_GAP: "조사 공백", APPLIED: "판단 반영됨", NOT_ASSESSED: "미평가", INCONCLUSIVE: "결론을 내지 못함", QUALIFIES: "조건부로 해당", SUPPORTS: "뒷받침", CONTRADICTS: "충돌", "answer:HOLD": "답변을 보류시킨 요인", - APPLICABLE: "적용됨", COVERAGE_UNAVAILABLE: "평가기준 정보 없음", ASSESSMENT_UNAVAILABLE: "평가 결과 없음", + APPLICABLE: "적용됨", COVERAGE_UNAVAILABLE: "답변 점검 항목 정보 없음", ASSESSMENT_UNAVAILABLE: "평가 결과 없음", INSUFFICIENT: "근거 부족", REFUTES: "반박", IRRELEVANT: "관련 없음", }; export function coverageTermLabel(value: string): string { return coverageTerms[value] ?? value; } diff --git a/apps/web/src/workstation.css b/apps/web/src/workstation.css index 94e8c0d..8447925 100644 --- a/apps/web/src/workstation.css +++ b/apps/web/src/workstation.css @@ -341,3 +341,4 @@ button:focus-visible, summary:focus-visible, a:focus-visible { outline: 2px soli .trace-evidence-dialog .bp6-dialog-body{max-height:calc(100vh - 230px);overflow-y:auto} .trace-evidence-dialog .evidence-item.focused{border-color:#8abbff;background:rgba(76,144,240,.16);box-shadow:0 0 0 2px rgba(76,144,240,.45)} .trace-import-dialog{width:min(720px,94vw)} +.conversation-synthetic-notice{flex:none;margin:0 0 8px;padding:4px 10px;min-height:0;background:#3a2a10 !important;font-size:12px} From 5e66f2157c11d2458f45eec9e0586cd80981c010 Mon Sep 17 00:00:00 2001 From: THOTH Date: Sun, 11 Oct 2026 04:05:55 +0900 Subject: [PATCH 3/4] Show counter-evidence counts whenever counter sources exist, even before a counter search is recorded --- SOURCE_MANIFEST.json | 18 ++++++++--------- .../src/components/HypothesisCompare.test.tsx | 20 +++++++++++++++++++ apps/web/src/components/HypothesisCompare.tsx | 2 +- apps/web/src/components/ResultScreen.test.tsx | 16 +++++++++++++++ apps/web/src/components/hypothesisView.ts | 10 +++++++--- 5 files changed, 53 insertions(+), 13 deletions(-) diff --git a/SOURCE_MANIFEST.json b/SOURCE_MANIFEST.json index 5f4f226..78ff81f 100644 --- a/SOURCE_MANIFEST.json +++ b/SOURCE_MANIFEST.json @@ -1,6 +1,6 @@ { "schema_version": "thoth-source-preview-manifest-v1", - "created_at": "2026-10-11T12:00:50.638112+00:00", + "created_at": "2026-10-11T12:00:51.533659+00:00", "license": "MIT", "git_history_included": false, "full_suite_verified": false, @@ -643,13 +643,13 @@ }, { "path": "apps/web/src/components/HypothesisCompare.test.tsx", - "sha256": "244d2a05a9cf656a840230fa670ced4f1e6a91952d2fc083c0575b7212366802", - "bytes": 7220 + "sha256": "fcce6c8fd20a89b844518a0395a66cd8afc8f334bb60d6fbfe2cd61234d8de8f", + "bytes": 8536 }, { "path": "apps/web/src/components/HypothesisCompare.tsx", - "sha256": "e12e92e2c125ae7628ec599e12515b9d5dd34078096a65e3a17334445e7f3d70", - "bytes": 9372 + "sha256": "ce858c804b4a1b266320f812270bb6efc6628ab39456ff8d162254fe0bc9490d", + "bytes": 9398 }, { "path": "apps/web/src/components/HypothesisCompareDraft.test.tsx", @@ -888,8 +888,8 @@ }, { "path": "apps/web/src/components/ResultScreen.test.tsx", - "sha256": "0933e4c6d988d3955de85e76118049fa2bd5f313c6e9acaa91101d6b9cb0245c", - "bytes": 11706 + "sha256": "1794413e19a4604e2a614d6b9f91123c177a17a91d82334ab131e3d02be05a2a", + "bytes": 12825 }, { "path": "apps/web/src/components/ReviewDialog.tsx", @@ -1208,8 +1208,8 @@ }, { "path": "apps/web/src/components/hypothesisView.ts", - "sha256": "b44f62a2a2f4fea84b3c515770172f8c83243b78f24a6b425dbb4eed733890b3", - "bytes": 4319 + "sha256": "a98e0ff4289c89f57fb7f599b137f77f7d7a197d974657aca1b567b4dc7f6d9a", + "bytes": 4530 }, { "path": "apps/web/src/components/judgmentRecordText.test.ts", diff --git a/apps/web/src/components/HypothesisCompare.test.tsx b/apps/web/src/components/HypothesisCompare.test.tsx index 68610a9..4f13d90 100644 --- a/apps/web/src/components/HypothesisCompare.test.tsx +++ b/apps/web/src/components/HypothesisCompare.test.tsx @@ -114,3 +114,23 @@ it("omits the unevaluated count when the answer's selected evidence list is unkn expect(cards()[0].textContent).not.toContain("대조 안 한"); expect(node.querySelector("table.relation-table")).toBeNull(); }); +it("shows the counter-evidence count when sources exist even though no search was recorded, and keeps the search state apart", async () => { + await mount(result(hyps => { hyps[0].counterevidence_refs = extra.slice(0, 3); })); + const text = cards()[0].textContent!; + expect(text).toContain("반박 3"); + expect(text).not.toContain("반박 -"); + expect(text).toContain("반대 근거 탐색 안 함"); // whether it was searched is a separate fact + expect(text).not.toContain("반박 근거를 찾지 못함"); + expect(cards()[1].textContent).toContain("반박 -"); // nothing found and nothing searched stays a dash +}); + +it("shows a real zero and the not-found note for a finished search, and the count when it found some", async () => { + await mount(result(hyps => { + hyps[0].critical_review = { terminal: "UNRESOLVED_NO_RESULTS", reasons: ["x"] }; + hyps[1].critical_review = { terminal: "UNRESOLVED_NO_RESULTS", reasons: ["x"] }; hyps[1].counterevidence_refs = extra.slice(0, 2); + })); + expect(cards()[0].textContent).toContain("반박 0"); + expect(cards()[0].textContent).toContain("반박 근거를 찾지 못함 (지지된다는 뜻은 아닙니다)"); + expect(cards()[1].textContent).toContain("반박 2"); + expect(cards()[1].textContent).not.toContain("반박 근거를 찾지 못함"); +}); diff --git a/apps/web/src/components/HypothesisCompare.tsx b/apps/web/src/components/HypothesisCompare.tsx index c53efb2..0fc813c 100644 --- a/apps/web/src/components/HypothesisCompare.tsx +++ b/apps/web/src/components/HypothesisCompare.tsx @@ -63,7 +63,7 @@ export function HypothesisCompare({ result, review, projectId, links, distributi

    {row.counterTerminal ? `반대 근거 탐색: ${counterReviewLabel(row.counterTerminal)}` : "반대 근거 탐색 안 함"}

    {row.counterTerminal && row.counter.length === 0 &&

    반박 근거를 찾지 못함 (지지된다는 뜻은 아닙니다)

    }
    - 뒷받침 {row.support.length}{" · "}반박 {row.counterTerminal ? row.counter.length : "-"} + 뒷받침 {row.support.length}{" · "}반박 {row.counter.length > 0 || row.counterTerminal ? row.counter.length : "-"} {row.unevaluated !== null && <>{" · "}아직 대조 안 한 근거 {row.unevaluated}/{selected?.length}}

    반대 근거를 찾을 질문

    {row.counterQueries.map((query, j) =>

    {query}

    )} diff --git a/apps/web/src/components/ResultScreen.test.tsx b/apps/web/src/components/ResultScreen.test.tsx index d455e3f..4070086 100644 --- a/apps/web/src/components/ResultScreen.test.tsx +++ b/apps/web/src/components/ResultScreen.test.tsx @@ -91,6 +91,22 @@ it("shows refutation 0 only for a finished search", () => { expect(html).not.toContain("반박 -"); }); +it("counts the counter-evidence of every hypothesis on the answer line, searched or not", () => { + const withCounter = (searchedFirst: boolean) => { + const copy = structuredClone(held) as { portfolio: { hypotheses: Record[] } }; + copy.portfolio.hypotheses[0].counterevidence_refs = ["span:c1", "span:c2", "span:c3"]; + copy.portfolio.hypotheses[1].counterevidence_refs = ["span:c4"]; + if (searchedFirst) copy.portfolio.hypotheses[0].critical_review = { terminal: "UNRESOLVED_NO_RESULTS" }; + return copy as unknown as Record; + }; + const none = render(withCounter(false), { coverageMatrix: coverage(iris) }); + expect(none).toContain("반대 근거 탐색: 안 함 · 반박 4"); + expect(none).not.toContain("반박 -"); + const some = render(withCounter(true), { coverageMatrix: coverage(iris) }); + const total = (held as unknown as { portfolio: { hypotheses: unknown[] } }).portfolio.hypotheses.length; + expect(some).toContain(`반대 근거 탐색: 1/${total}개 가설 · 반박 4`); // the count covers every hypothesis, the N/M part only the searched ones +}); + it("picks the Korean next-step text from the action type and keeps the English server label out of view", () => { const html = render(held, { coverageMatrix: coverage(iris), progressSummary: progress(action("REVIEW_GAPS", "Review unresolved evidence gaps")) }); const visible = html.replace(//g, ""); diff --git a/apps/web/src/components/hypothesisView.ts b/apps/web/src/components/hypothesisView.ts index 3bc2435..8ea1c03 100644 --- a/apps/web/src/components/hypothesisView.ts +++ b/apps/web/src/components/hypothesisView.ts @@ -62,13 +62,17 @@ export function hypothesisRows(result: Record): HypothesisRow[] }); } -/** One first-screen line about the counter-evidence search; "-" (not 0) while no search was recorded. */ +/** + * One first-screen line about the counter-evidence search. The count covers the counter sources of every hypothesis, searched or not; + * "-" (not 0) only when no search was recorded and no counter source exists. The "N/M" part counts only the searched hypotheses. + */ export function counterSearchLine(result: Record): string | null { const rows = hypothesisRows(result); if (!rows.length) return null; const searched = rows.filter(row => row.counterTerminal); - if (!searched.length) return "반대 근거 탐색: 안 함 · 반박 -"; - return `반대 근거 탐색: ${searched.length}/${rows.length}개 가설 · 반박 ${searched.reduce((sum, row) => sum + row.counter.length, 0)}`; + const total = rows.reduce((sum, row) => sum + row.counter.length, 0); + if (!searched.length) return `반대 근거 탐색: 안 함 · 반박 ${total > 0 ? total : "-"}`; + return `반대 근거 탐색: ${searched.length}/${rows.length}개 가설 · 반박 ${total}`; } /** A source the review cited for this hypothesis and judged "not supporting" is shown as not applicable; a source never looked at stays unevaluated. */ From 8d6fb3332c43ab8592f02a8b1c0bf6ed4a6268ca Mon Sep 17 00:00:00 2001 From: THOTH Date: Sun, 11 Oct 2026 06:06:30 +0900 Subject: [PATCH 4/4] Let the sidebar New project button open the create form anywhere, and restore archived projects to their previous lifecycle --- SOURCE_MANIFEST.json | 80 ++++--- apps/web/src/components/ArchivedProjects.tsx | 32 +++ .../src/components/LiveProjectWorkspace.tsx | 15 +- .../web/src/components/ModelSettings.test.tsx | 2 +- .../components/ProjectArchiveRestore.test.tsx | 115 ++++++++++ apps/web/src/workstation.css | 1 + config/atomicity-paths.json | 201 +++++++++++++---- docs/architecture/rpc-method-catalog.md | 1 + schemas/protocol/public-method-catalog.json | 20 +- src/thoth/adapters/storage/projects.py | 16 ++ src/thoth/application/commands/__init__.py | 2 + .../application/commands/project_restore.py | 76 +++++++ src/thoth/apps/management_routes.py | 10 +- src/thoth/apps/runtime.py | 2 +- src/thoth/ports/project.py | 5 + src/thoth/protocol/notification_catalog.py | 1 + src/thoth/protocol/registry.py | 1 + .../test_public_protocol_exact_parity.py | 8 +- .../fixtures/runtime_registration_events.json | 10 + tests/integration/test_project_restore_rpc.py | 204 ++++++++++++++++++ 20 files changed, 718 insertions(+), 84 deletions(-) create mode 100644 apps/web/src/components/ArchivedProjects.tsx create mode 100644 apps/web/src/components/ProjectArchiveRestore.test.tsx create mode 100644 src/thoth/application/commands/project_restore.py create mode 100644 tests/integration/test_project_restore_rpc.py diff --git a/SOURCE_MANIFEST.json b/SOURCE_MANIFEST.json index 78ff81f..c887988 100644 --- a/SOURCE_MANIFEST.json +++ b/SOURCE_MANIFEST.json @@ -1,6 +1,6 @@ { "schema_version": "thoth-source-preview-manifest-v1", - "created_at": "2026-10-11T12:00:51.533659+00:00", + "created_at": "2026-10-11T12:00:52.485237+00:00", "license": "MIT", "git_history_included": false, "full_suite_verified": false, @@ -481,6 +481,11 @@ "sha256": "60e8441c6af7d4971832fb1cc6eebb18b0fa2d603028644b974d86c01982cace", "bytes": 3590 }, + { + "path": "apps/web/src/components/ArchivedProjects.tsx", + "sha256": "0acefdc9d1ba41463f2b7017240971291e85f050b1fec813946437b7b3afcce4", + "bytes": 2392 + }, { "path": "apps/web/src/components/AuditTrail.tsx", "sha256": "91e6d770cd55fe85c687dde67d4b92c125454b11f8e7ca07d8e2303cab989904", @@ -718,8 +723,8 @@ }, { "path": "apps/web/src/components/LiveProjectWorkspace.tsx", - "sha256": "09114b62bea55d41764f811fa38932b92bb97d5e6a8b4f330afb99db50fed028", - "bytes": 42864 + "sha256": "24764098e6ce17bcd6441a41b8d40894cc67f5bff95baca651dd09e8d06dfd91", + "bytes": 43374 }, { "path": "apps/web/src/components/LiveProjectWorkspaceConsent.test.tsx", @@ -753,8 +758,8 @@ }, { "path": "apps/web/src/components/ModelSettings.test.tsx", - "sha256": "78d20aa3c8126b7f4a978826bc1119213b13615345a95d1bcfc6f24bd477d711", - "bytes": 23406 + "sha256": "da9d04a0670973eb67d99da7cf1b4a4c59ce824be5160dd59395ce1489219ce5", + "bytes": 23343 }, { "path": "apps/web/src/components/ModelSettings.tsx", @@ -766,6 +771,11 @@ "sha256": "a94bc861fcb5403c5575f0424bcdf9f6548feb87d1b8eac090415d6a31829293", "bytes": 19518 }, + { + "path": "apps/web/src/components/ProjectArchiveRestore.test.tsx", + "sha256": "0c4a5e09c15eebc5e65b228b9cf35eb0827a7421976add249b2ff0457ae75d6e", + "bytes": 7820 + }, { "path": "apps/web/src/components/ProjectLanding.test.tsx", "sha256": "7320a8b86b880d32d0bf66d06452c0ca1ecaf72b743fc5c5c76786b9c1758cea", @@ -1433,8 +1443,8 @@ }, { "path": "apps/web/src/workstation.css", - "sha256": "8f543e6734c2ab140a5b551cb7477fb0cd43a2844da67c1398dcc67b0f18acd5", - "bytes": 58339 + "sha256": "267ede96732993f45f0e2e790555d5645322065580145536ca81cbc9a2a10256", + "bytes": 58447 }, { "path": "apps/web/timeline-preview.html", @@ -1468,8 +1478,8 @@ }, { "path": "config/atomicity-paths.json", - "sha256": "7942ea74409013ca29f7cfd7bb4eddeda8e24486af54465a9b93cc585741e1f3", - "bytes": 3325795 + "sha256": "bf9e73eebbaf8293b9afdcbb938eaa039afb2eeefa3e4ae63b9375ec2b7cabb5", + "bytes": 3331085 }, { "path": "config/atomicity-requirements.json", @@ -1708,8 +1718,8 @@ }, { "path": "docs/architecture/rpc-method-catalog.md", - "sha256": "fbbf54d025d85e73ae93a32dc071de988ea87c5e554ea7461d58cdd434d06a85", - "bytes": 14166 + "sha256": "c4fa7fb1183ff70cd5c1c79a7fc3099caaddbb5cac5ffd4f51e63524f008ec33", + "bytes": 14182 }, { "path": "docs/architecture/user-activity-projection.md", @@ -2458,8 +2468,8 @@ }, { "path": "schemas/protocol/public-method-catalog.json", - "sha256": "9c8e14688ccd2bc92bc2487213c7dd4fbe2cd625572a0673f1f2282a7b29e95a", - "bytes": 157952 + "sha256": "8a74465cef7448dd9315aec0d496cfba39685e6f5e693b059655cf079689e5e4", + "bytes": 158337 }, { "path": "schemas/protocol/restore-apply-input.schema.json", @@ -3638,8 +3648,8 @@ }, { "path": "src/thoth/adapters/storage/projects.py", - "sha256": "c71c056c3a4937adc624f7b14c6f814f678ce908025eef8a9be1cae2d2d46505", - "bytes": 5271 + "sha256": "1ca1ba845696b29ea386a88cde1e826008a40791ed552c9a3e2c405a21668ca1", + "bytes": 5871 }, { "path": "src/thoth/adapters/storage/receipt_dag.py", @@ -3748,8 +3758,8 @@ }, { "path": "src/thoth/application/commands/__init__.py", - "sha256": "4690d507f942d1fa997d6704c4f36bf935a5e1b2bbf94776088b9d4f88c6aa1c", - "bytes": 2462 + "sha256": "2487dec0f6120ed611c5d15185192dbc00c48610aec0d588b3de6d2ff610100e", + "bytes": 2570 }, { "path": "src/thoth/application/commands/action_create_input.py", @@ -3926,6 +3936,11 @@ "sha256": "067cfcdcfbe62c6a0b059d7f820d14f1254d0c896e5d308da57a303a9a32c520", "bytes": 20158 }, + { + "path": "src/thoth/application/commands/project_restore.py", + "sha256": "b85ebb31635bf9a90f87c955106bb13eb61dc903fb47d04ca2c065b69bacb41a", + "bytes": 3391 + }, { "path": "src/thoth/application/commands/projections.py", "sha256": "0ca4838a787612d0e4b94de790bfdb89fa8a64af70f1fa7b68e84d3dff1b165a", @@ -5218,8 +5233,8 @@ }, { "path": "src/thoth/apps/management_routes.py", - "sha256": "7454abd0c5187480b00090a3c31c078a4760735fb28897d25bad02297354b321", - "bytes": 2949 + "sha256": "e17ef04ae90adc48b944f0eb0529ca2a4264ba4235467f70b68ac5534f255a84", + "bytes": 3272 }, { "path": "src/thoth/apps/memory_composition.py", @@ -5288,8 +5303,8 @@ }, { "path": "src/thoth/apps/runtime.py", - "sha256": "fe6fafe731562a6d0b301d4f48d6154c3ffc40e59030f28144b243da7b7a8f63", - "bytes": 26648 + "sha256": "81f2718bbb0698f6a1c1cc3c83ac6be6f4d46306574052eb6af725b9018fa1d9", + "bytes": 26656 }, { "path": "src/thoth/apps/runtime_types.py", @@ -6143,8 +6158,8 @@ }, { "path": "src/thoth/ports/project.py", - "sha256": "bae4b572d7adce68496411aea718d0392ee3d8fcc892c26f5e847fd293d9f39e", - "bytes": 458 + "sha256": "200d58cb8d244aea66d1fe980f8f52d94992cf6f28e571bacdffd68026c466ba", + "bytes": 678 }, { "path": "src/thoth/ports/projectpack.py", @@ -6268,8 +6283,8 @@ }, { "path": "src/thoth/protocol/notification_catalog.py", - "sha256": "122cfaa053962ecc6ca21e44fe7c14e402efcfc637b406ac98d6e6a7aa394234", - "bytes": 17850 + "sha256": "da3c37d824d3636a1c3c2c1582a0cdea5cc750eb8ebe6b21fc790bd8d384ce38", + "bytes": 17901 }, { "path": "src/thoth/protocol/notification_rules.py", @@ -6288,8 +6303,8 @@ }, { "path": "src/thoth/protocol/registry.py", - "sha256": "ae6ee4f18638e6a0fa4b21aff2fed4ce2eaad50059b9fc38710cb05b8a65cce8", - "bytes": 13225 + "sha256": "413c7b4ac5079afac8e0cadc33126bfdd26c198ccdee6974a14b5be94608cee2", + "bytes": 13249 }, { "path": "src/thoth/protocol/schema_export.py", @@ -6443,7 +6458,7 @@ }, { "path": "tests/architecture/test_public_protocol_exact_parity.py", - "sha256": "2cbc2a3db6641a5207120047e864a7649c98447e09fcb7720a5cfaef7c802f0e", + "sha256": "c228a0d87dcf771049d24a021737002013645f824d596ec5e8f06dbe8c7ea44b", "bytes": 5631 }, { @@ -6823,8 +6838,8 @@ }, { "path": "tests/fixtures/runtime_registration_events.json", - "sha256": "937f5bdf6a1861031537acc2afb592dad64f1ba45095d4e3fb1366b432b3e84b", - "bytes": 112131 + "sha256": "18b4236f9f0d283985bbfc15b170d9c62bfaa328248ea0f7974bbc76b9bf39d3", + "bytes": 112407 }, { "path": "tests/fixtures/structured_source_fixture.py", @@ -7676,6 +7691,11 @@ "sha256": "c767beb6bff4187d94ec7cd8fc5effd76fea81ad566f85f2ee835e99d07e979b", "bytes": 10230 }, + { + "path": "tests/integration/test_project_restore_rpc.py", + "sha256": "5ea3b65a3355d88a42351b48990d183a25fe6fc1cbc323d8e1543e25fbfba79e", + "bytes": 7902 + }, { "path": "tests/integration/test_projectpack_rpc.py", "sha256": "28d4704b5dcdec45b4e76c5314429cc54f6273ac910ae2b82b452168f68fe841", diff --git a/apps/web/src/components/ArchivedProjects.tsx b/apps/web/src/components/ArchivedProjects.tsx new file mode 100644 index 0000000..3db7fb7 --- /dev/null +++ b/apps/web/src/components/ArchivedProjects.tsx @@ -0,0 +1,32 @@ +import { Button, Callout } from "@blueprintjs/core"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { rpc } from "../api/rpcClient"; +import type { ProjectSummary } from "../types"; + +/** Archived projects are read-only and hidden from the list; this shows them on demand so one can be restored. */ +export function ArchivedProjects({ scopeKey }: { scopeKey: string }) { + const client = useQueryClient(); + const [open, setOpen] = useState(false); + const archived = useQuery({ queryKey: ["projects", scopeKey, "archived"], enabled: open, + queryFn: ({ signal }) => rpc<{ projects: ProjectSummary[] }>("project/list", { project_id: "system:projects", include_archived: true }, crypto.randomUUID(), signal) }); + const restore = useMutation({ + mutationFn: (target: ProjectSummary) => rpc("project/restore", { project_id: target.project_id, expected_revision: target.revision }, crypto.randomUUID()), + onSuccess: () => client.invalidateQueries({ queryKey: ["projects"] }), + }); + const rows = (archived.data?.value.projects ?? []).filter(item => item.lifecycle === "ARCHIVED_READ_ONLY"); + return
    + + {open &&
    + {archived.isPending &&

    보관한 프로젝트를 읽는 중…

    } + {archived.error && 보관한 프로젝트를 읽지 못했습니다. 없다는 뜻은 아닙니다.} + {archived.isSuccess && rows.length === 0 &&

    보관한 프로젝트가 없습니다.

    } + {rows.map(item =>
    + {item.name} + +
    )} + {restore.error && 되살리지 못했습니다. {restore.error.message}} +
    } +
    ; +} diff --git a/apps/web/src/components/LiveProjectWorkspace.tsx b/apps/web/src/components/LiveProjectWorkspace.tsx index a5e10cb..e8dea2f 100644 --- a/apps/web/src/components/LiveProjectWorkspace.tsx +++ b/apps/web/src/components/LiveProjectWorkspace.tsx @@ -30,6 +30,7 @@ import { isHttpUrl, sourceCardHost, sourceCardTitle } from "../sourceDisplay"; import { eyebrowLabel } from "./statusLabels"; import { WorkspaceErrorBoundary } from "./WorkspaceErrorBoundary"; import { ConversationTimeline } from "./ConversationTimeline"; +import { ArchivedProjects } from "./ArchivedProjects"; import { ResearchComposer } from "./ResearchComposer"; import { ResearchSidePanel, type SidePanelTab } from "./ResearchSidePanel"; import { TracePage } from "./TracePage"; @@ -187,16 +188,17 @@ function ProjectNavItem({ project, active, removing, onOpen, onRemove }: {
    ; } -export function ProjectLanding({ projects, loading = false, contextRestoreIssue = null, setupIssue = null, onOpen, onCreated }: { +export function ProjectLanding({ projects, loading = false, contextRestoreIssue = null, setupIssue = null, onOpen, onCreated, openCreate = false, onOpenCreateHandled }: { projects: ProjectSummary[]; loading?: boolean; contextRestoreIssue?: "CORRUPT" | "UNAVAILABLE" | null; setupIssue?: WorkspaceSetupIssue | null; onOpen: (projectId: string) => void; - onCreated: (projectId: string) => void; + onCreated: (projectId: string) => void; openCreate?: boolean; onOpenCreateHandled?: () => void; }) { const [creating,setCreating] = useState(false); useEffect(()=>{if(!loading&&projects.length===0)setCreating(true);},[loading,projects.length]); + useEffect(()=>{if(openCreate){setCreating(true);onOpenCreateHandled?.();}},[openCreate,onOpenCreateHandled]); const restoreNotice = contextRestoreIssue && {contextRestoreIssue === "CORRUPT" ? "마지막 작업 위치 기록을 읽지 못했습니다. 원본을 자동 삭제하지 않았습니다." : "이 브라우저의 마지막 작업 위치를 읽지 못했습니다. 저장소 접근과 작업 공간 식별자를 확인하세요."} @@ -223,6 +225,7 @@ export function LiveProjectWorkspace() { const [selectionVersion,setSelectionVersion] = useState(0); const [rowOrigin,setRowOrigin] = useState(null); const [traceFocus,setTraceFocus] = useState(null); + const [openCreate,setOpenCreate] = useState(false); const epoch = useRef(0); const previousScope = useRef(null); const pendingAdmission = useRef(null); @@ -316,7 +319,7 @@ export function LiveProjectWorkspace() { }, }); const removeProject = (target: ProjectSummary) => { - if (!window.confirm(`프로젝트 "${target.name}"을 목록에서 제거할까요? 데이터는 보관되고 기본 목록에서 숨겨집니다.`)) return; + if (!window.confirm(`프로젝트 "${target.name}"을 보관할까요? 보관하면 읽기 전용이 되고 목록에서 숨겨집니다. '보관한 프로젝트 보기'에서 되살릴 수 있습니다.`)) return; archiveProject.mutate(target); }; if (readyQuery.isPending || (readyQuery.isSuccess && !ready)) { @@ -356,7 +359,7 @@ export function LiveProjectWorkspace() { } return
    : null, ])} - {!projectsQuery.isPending&&!projectsQuery.error&&projects.length===0&&

    아직 프로젝트가 없습니다.

    } + {!projectsQuery.isPending&&!projectsQuery.error&&projects.length===0&&

    아직 프로젝트가 없습니다.

    }
    @@ -383,7 +386,7 @@ export function LiveProjectWorkspace() { onUserInput={cancelOpenLatest} onAdmitted={threadId=>{pendingAdmission.current=threadId;setContext(current=>({...current,threadId}));}} rowOrigin={rowOrigin} onOriginDone={()=>setRowOrigin(null)} onInvestigate={investigate} traceFocus={traceFocus} onOpenTrace={openTraceRow}/> : isTimelineExampleRequested() && page === "research" ? : -
    {missingSelection&&{missingSelection==="project"?"저장된 프로젝트를 현재 목록에서 확인하지 못해 선택을 비웠습니다.":"저장된 작업 선택 확인 필요"} 저장된 초안은 자동 제출하지 않습니다.}{contextStoreFailed&&!hosted&&이 브라우저에 마지막 작업 위치를 저장하지 못했습니다. 재시작 후 자동 복원이 제한될 수 있습니다.}{!hosted&&!validWorkspaceId(workspaceId)&&워크스페이스 식별자를 확인하지 못해 브라우저 재시작용 초안 저장을 사용할 수 없습니다.}{page==="settings"?<>{hosted?운영 정보와 사용 현황은 프로젝트를 선택한 뒤 확인할 수 있습니다.:

    모델 연결

    프로젝트별 기본 모델은 프로젝트를 선택한 뒤 설정합니다.

    }:{if(renderedEpoch===epoch.current)chooseProject(projectId,"resources");}}/>}
    } +
    {missingSelection&&{missingSelection==="project"?"저장된 프로젝트를 현재 목록에서 확인하지 못해 선택을 비웠습니다.":"저장된 작업 선택 확인 필요"} 저장된 초안은 자동 제출하지 않습니다.}{contextStoreFailed&&!hosted&&이 브라우저에 마지막 작업 위치를 저장하지 못했습니다. 재시작 후 자동 복원이 제한될 수 있습니다.}{!hosted&&!validWorkspaceId(workspaceId)&&워크스페이스 식별자를 확인하지 못해 브라우저 재시작용 초안 저장을 사용할 수 없습니다.}{page==="settings"?<>{hosted?운영 정보와 사용 현황은 프로젝트를 선택한 뒤 확인할 수 있습니다.:

    모델 연결

    프로젝트별 기본 모델은 프로젝트를 선택한 뒤 설정합니다.

    }:setOpenCreate(false)} contextRestoreIssue={contextRestoreIssue} setupIssue={setupIssue} projects={visibleProjects} loading={projectsQuery.isPending||projectsQuery.isFetching} onOpen={openProject} onCreated={projectId=>{if(renderedEpoch===epoch.current)chooseProject(projectId,"resources");}}/>}
    } ; } diff --git a/apps/web/src/components/ModelSettings.test.tsx b/apps/web/src/components/ModelSettings.test.tsx index 7670d10..55018d1 100644 --- a/apps/web/src/components/ModelSettings.test.tsx +++ b/apps/web/src/components/ModelSettings.test.tsx @@ -288,7 +288,7 @@ it("ordinary navigation only automatically calls the actual nonmutating query su it("project-create retry preserves project ID and key without a globally shared policy binding",async()=>{ await mount(); await act(async()=>{container.querySelector("button.new-project-button")!.click();}); - await act(async()=>{Array.from(container.querySelectorAll(".project-home-list button")).find(button=>button.textContent==="새 프로젝트")!.click();}); + // the left button now opens the create screen by itself (it used to need a second click on the list's button) await act(async()=>{ const input=container.querySelector('input[placeholder="연구할 문제 또는 프로젝트 이름"]')!; Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,"value")!.set!.call(input,"second project"); diff --git a/apps/web/src/components/ProjectArchiveRestore.test.tsx b/apps/web/src/components/ProjectArchiveRestore.test.tsx new file mode 100644 index 0000000..afcbdc9 --- /dev/null +++ b/apps/web/src/components/ProjectArchiveRestore.test.tsx @@ -0,0 +1,115 @@ +// @vitest-environment jsdom +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, expect, it, vi } from "vitest"; +import { saveWorkspaceContext } from "../api/research"; +import { LiveProjectWorkspace } from "./LiveProjectWorkspace"; + +const WORKSPACE = `workspace:${"a".repeat(32)}`; +const project = (id: string, name: string, lifecycle: string, revision: number) => + ({ project_id: id, name, overlay: "test", lifecycle, revision, cutoff_at: "2026-09-26T00:00:00Z" }); +const fixture = vi.hoisted(() => ({ projects: [] as Record[], calls: [] as { method: string; input: Record }[] })); +vi.mock("../api/rpcClient", async importOriginal => ({ ...(await importOriginal()), + rpc: async (method: string, input: Record) => { + fixture.calls.push({ method, input }); + let value: unknown = {}; + if (method === "workspace/ready") value = { ready: true, execution_ready: true, workspace_readable: true, setup_complete: true, workspace_id: WORKSPACE, + deployment_mode: "LOCAL", model_connected: true, setup: { internet_consent: "ALLOWED" } }; + if (method === "project/list") value = { projects: fixture.projects.filter(item => input.include_archived === true || item.lifecycle !== "ARCHIVED_READ_ONLY") }; + if (method === "project/restore") { + const target = fixture.projects.find(item => item.project_id === input.project_id)!; + Object.assign(target, { lifecycle: "DRAFT", revision: Number(input.expected_revision) + 1 }); + value = target; + } + if (method === "thread/list") value = { threads: [{ project_id: input.project_id, thread_id: "t", updated_at: "2026-09-20T00:00:00Z", problem: "stored work", execution_state: "IDLE", lifecycle: "OPEN" }] }; + if (method === "thread/read") value = { project_id: input.project_id, thread_id: input.thread_id, problem: "stored work", lifecycle: "OPEN", execution_state: "IDLE", current_object_ids: [], operation_state: "SUCCEEDED", current_result: null }; + if (method === "thread/activity/list") value = { conversation: { turns: [], next_before_epoch: null, history_limited: false } }; + if (method === "project/source/list") value = { artifacts: [] }; + if (method === "evidence/list") value = { evidence: [] }; + if (method === "model/settings/read") value = { settings_digest: null, selection: { provider: null, model: null, reasoning_effort: null }, effective_settings: null, availability: "UNAVAILABLE", reason_code: "MODEL_CAPABILITY_UNKNOWN", model_options: [] }; + if (method === "model/credential/list") value = { accounts: [] }; + return { value, state: "SUCCEEDED", operation_id: "op" }; + }, +})); + +let root: Root | undefined; +let container: HTMLDivElement; +let client: QueryClient; +async function settle(check: () => boolean) { + for (let i = 0; i < 40; i++) { + await act(async () => { await new Promise(resolve => setTimeout(resolve, 15)); }); + if (check()) return; + } + throw new Error(`did not settle: ${container.textContent?.slice(0, 260)}`); +} +const button = (label: string) => [...container.querySelectorAll("button")].find(item => item.textContent === label) as HTMLButtonElement | undefined; +async function mount(selected: boolean) { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ status: "ok", version: "synthetic" })))); + if (selected) saveWorkspaceContext("p", "t", { mode: "LOCAL", workspaceId: WORKSPACE }); + container = document.createElement("div"); document.body.append(container); + root = createRoot(container); + client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + await act(async () => root!.render()); + await settle(() => Boolean(container.querySelector(selected ? "#live-problem" : ".project-home"))); +} +afterEach(async () => { + if (root) await act(async () => root!.unmount()); + root = undefined; client?.clear(); container?.remove(); vi.restoreAllMocks(); localStorage.clear(); sessionStorage.clear(); vi.unstubAllGlobals(); + fixture.projects = []; fixture.calls = []; +}); + +const newProject = () => container.querySelector("button.new-project-button") as HTMLButtonElement; +const nameInput = () => [...container.querySelectorAll("label")].find(item => item.textContent?.startsWith("프로젝트 이름")); + +it("opens the create screen from the left button on the first screen, where it used to do nothing", async () => { + fixture.projects = [project("p", "old project", "ACTIVE", 1)]; + await mount(false); + expect(nameInput()).toBeUndefined(); + await act(async () => newProject().click()); + await settle(() => nameInput() !== undefined); + // pressing it again after going back to the list opens it again + await act(async () => button("프로젝트 선택으로 돌아가기")!.click()); + await settle(() => nameInput() === undefined); + await act(async () => newProject().click()); + await settle(() => nameInput() !== undefined); +}); + +it("opens the create screen from the left button while a project is open", async () => { + fixture.projects = [project("p", "old project", "ACTIVE", 1)]; + await mount(true); + expect(nameInput()).toBeUndefined(); + await act(async () => newProject().click()); + await settle(() => nameInput() !== undefined); + expect(container.querySelector("#live-problem")).toBeNull(); +}); + +it("lists archived projects on demand and restores one, then shows it in the normal list", async () => { + fixture.projects = [project("p", "old project", "ACTIVE", 1), project("q", "archived demo", "ARCHIVED_READ_ONLY", 4)]; + await mount(false); + expect(container.textContent).not.toContain("archived demo"); + expect(fixture.calls.filter(call => call.method === "project/list").every(call => call.input.include_archived !== true)).toBe(true); + await act(async () => button("보관한 프로젝트 보기")!.click()); + await settle(() => container.textContent?.includes("archived demo") === true); + expect(fixture.calls.some(call => call.method === "project/list" && call.input.include_archived === true)).toBe(true); + expect(container.querySelector(".archived-projects")!.textContent).not.toContain("old project"); + await act(async () => button("되살리기")!.click()); + await settle(() => container.querySelector(".archived-projects")?.textContent?.includes("보관한 프로젝트가 없습니다") === true); + expect(fixture.calls.filter(call => call.method === "project/restore")).toEqual([{ method: "project/restore", input: { project_id: "q", expected_revision: 4 } }]); + await settle(() => container.querySelector(".navigation-rail")?.textContent?.includes("archived demo") === true); +}); + +it("tells the user that archiving makes a project read-only and that it can be restored", async () => { + fixture.projects = [project("p", "old project", "ACTIVE", 1)]; + const confirm = vi.spyOn(window, "confirm").mockReturnValue(false); + await mount(false); + await act(async () => (container.querySelector('[aria-label="old project 프로젝트 메뉴"]') as HTMLButtonElement).click()); + await act(async () => button("프로젝트 제거")!.click()); + expect(confirm).toHaveBeenCalledOnce(); + const message = String(confirm.mock.calls[0][0]); + expect(message).toContain("보관하면 읽기 전용이 되고 목록에서 숨겨집니다."); + expect(message).toContain("'보관한 프로젝트 보기'에서 되살릴 수 있습니다."); + expect(message).not.toContain("데이터는 보관되고"); + expect(fixture.calls.some(call => call.method === "project/archive")).toBe(false); +}); diff --git a/apps/web/src/workstation.css b/apps/web/src/workstation.css index 8447925..a96b5c9 100644 --- a/apps/web/src/workstation.css +++ b/apps/web/src/workstation.css @@ -342,3 +342,4 @@ button:focus-visible, summary:focus-visible, a:focus-visible { outline: 2px soli .trace-evidence-dialog .evidence-item.focused{border-color:#8abbff;background:rgba(76,144,240,.16);box-shadow:0 0 0 2px rgba(76,144,240,.45)} .trace-import-dialog{width:min(720px,94vw)} .conversation-synthetic-notice{flex:none;margin:0 0 8px;padding:4px 10px;min-height:0;background:#3a2a10 !important;font-size:12px} +.archived-project-row{display:flex;align-items:center;justify-content:space-between;gap:6px;font-size:12px} diff --git a/config/atomicity-paths.json b/config/atomicity-paths.json index 4a65918..82a7446 100644 --- a/config/atomicity-paths.json +++ b/config/atomicity-paths.json @@ -2,7 +2,7 @@ "schema_version": "1.0.0", "purpose": "DEVELOPMENT_VERIFICATION_LEDGER_NOT_RUNTIME_POLICY", "baseline_head": "f0262732a0e79235f3839c3393f459b90bd53783", - "catalog_sha256": "9c8e14688ccd2bc92bc2487213c7dd4fbe2cd625572a0673f1f2282a7b29e95a", + "catalog_sha256": "8a74465cef7448dd9315aec0d496cfba39685e6f5e693b059655cf079689e5e4", "public_entries": [ { "name": "action/audit/read", @@ -7486,6 +7486,32 @@ ], "mutation_observed_in_code": false }, + { + "name": "project/restore", + "namespace": "project", + "surface": "COMMAND", + "canonical_owner": "PROJECT_GOVERNANCE", + "policy": "project:versioned-policy", + "normal_entrypoint": "project/create", + "behavioral_evidence": [ + "A01" + ], + "canonical": true, + "alias_target": null, + "runtime_status": "IMPLEMENTED", + "method": "project/restore", + "actual_callable": "thoth.application.commands.project_restore.ProjectRestoreHandlers.restore", + "source": "src/thoth/application/commands/project_restore.py", + "line": 32, + "callable_sha256": "16298c9b366b9835610c72ffe4c352bcffdf211262b65cefc6651274b6e6179d", + "entry_kind": "public", + "path_id": "rpc:project/restore", + "owner_scope": "PROJECT_GOVERNANCE", + "phase_ids": [ + "rpc:project/restore" + ], + "mutation_observed_in_code": true + }, { "name": "project/review/list", "namespace": "project", @@ -35316,7 +35342,7 @@ "caller_refs": [ { "source": "src/thoth/apps/management_routes.py", - "line": 51, + "line": 59, "binding": "handlers.audit_read" } ], @@ -35412,7 +35438,7 @@ "caller_refs": [ { "source": "src/thoth/apps/management_routes.py", - "line": 50, + "line": 58, "binding": "handlers.list" } ], @@ -35508,7 +35534,7 @@ "caller_refs": [ { "source": "src/thoth/apps/management_routes.py", - "line": 54, + "line": 62, "binding": "handlers.pause" } ], @@ -35666,7 +35692,7 @@ "caller_refs": [ { "source": "src/thoth/apps/management_routes.py", - "line": 49, + "line": 57, "binding": "handlers.read" } ], @@ -35763,7 +35789,7 @@ "caller_refs": [ { "source": "src/thoth/apps/management_routes.py", - "line": 55, + "line": 63, "binding": "handlers.resume" } ], @@ -35921,7 +35947,7 @@ "caller_refs": [ { "source": "src/thoth/apps/management_routes.py", - "line": 52, + "line": 60, "binding": "handlers.start" } ], @@ -36080,7 +36106,7 @@ "caller_refs": [ { "source": "src/thoth/apps/management_routes.py", - "line": 56, + "line": 64, "binding": "handlers.stop" } ], @@ -36238,7 +36264,7 @@ "caller_refs": [ { "source": "src/thoth/apps/management_routes.py", - "line": 53, + "line": 61, "binding": "handlers.update" } ], @@ -48497,7 +48523,7 @@ "caller_refs": [ { "source": "src/thoth/apps/management_routes.py", - "line": 15, + "line": 22, "binding": "handlers.activate" } ], @@ -48663,7 +48689,7 @@ "caller_refs": [ { "source": "src/thoth/apps/management_routes.py", - "line": 16, + "line": 23, "binding": "handlers.archive" } ], @@ -48768,7 +48794,7 @@ }, { "source": "src/thoth/apps/management_routes.py", - "line": 12, + "line": 19, "binding": "handlers.create" } ], @@ -48938,7 +48964,7 @@ "caller_refs": [ { "source": "src/thoth/apps/management_routes.py", - "line": 27, + "line": 35, "binding": "handlers.cutoff_impact" } ], @@ -49045,7 +49071,7 @@ "caller_refs": [ { "source": "src/thoth/apps/management_routes.py", - "line": 28, + "line": 36, "binding": "handlers.cutoff_update" } ], @@ -49225,7 +49251,7 @@ "caller_refs": [ { "source": "src/thoth/apps/management_routes.py", - "line": 17, + "line": 24, "binding": "handlers.archive" } ], @@ -49324,7 +49350,7 @@ "caller_refs": [ { "source": "src/thoth/apps/management_routes.py", - "line": 13, + "line": 20, "binding": "handlers.list" } ], @@ -49404,7 +49430,7 @@ "caller_refs": [ { "source": "src/thoth/apps/management_routes.py", - "line": 18, + "line": 26, "binding": "handlers.metadata_update" } ], @@ -49570,7 +49596,7 @@ "caller_refs": [ { "source": "src/thoth/apps/management_routes.py", - "line": 19, + "line": 27, "binding": "handlers.overlay_update" } ], @@ -49736,7 +49762,7 @@ "caller_refs": [ { "source": "src/thoth/apps/management_routes.py", - "line": 20, + "line": 28, "binding": "handlers.policy_read" } ], @@ -49815,7 +49841,7 @@ "caller_refs": [ { "source": "src/thoth/apps/management_routes.py", - "line": 21, + "line": 29, "binding": "handlers.policy_update" } ], @@ -49978,7 +50004,7 @@ "caller_refs": [ { "source": "src/thoth/apps/management_routes.py", - "line": 14, + "line": 21, "binding": "handlers.read" } ], @@ -50058,7 +50084,7 @@ "caller_refs": [ { "source": "src/thoth/apps/management_routes.py", - "line": 26, + "line": 34, "binding": "handlers.reference_import" } ], @@ -50228,7 +50254,7 @@ "caller_refs": [ { "source": "src/thoth/apps/management_routes.py", - "line": 25, + "line": 33, "binding": "handlers.reference_list" } ], @@ -50293,6 +50319,105 @@ } } }, + { + "path_id": "rpc:project/restore", + "owner": "PROJECT_GOVERNANCE", + "catalog_owner": "PROJECT_GOVERNANCE", + "entry_kind": "public", + "method_or_trigger": "project/restore", + "actual_callable": "thoth.application.commands.project_restore.ProjectRestoreHandlers.restore", + "caller_refs": [ + { + "source": "src/thoth/apps/management_routes.py", + "line": 25, + "binding": "restore.restore" + } + ], + "producer_refs": [ + "thoth.adapters.storage.governance_history.SqliteGovernanceHistory._insert_record", + "thoth.adapters.storage.governance_history.SqliteGovernanceHistory.stage", + "thoth.adapters.storage.projects.SqliteProjectStore.update" + ], + "phase_id": [ + "LIFECYCLE_PUBLICATION" + ], + "composite_not_one_transaction": false, + "canonical_records_and_projections": [ + "governance_heads", + "governance_revisions", + "projects" + ], + "head_selector": "Project revision CAS and governance history head for the selected project.", + "audit_receipt_refs": [ + "governance_revisions" + ], + "expected_basis": "Expected Project revision and ARCHIVED_READ_ONLY lifecycle; a project that is not archived is returned unchanged. The lifecycle comes from the governance history before the archive, or the activation rule when the history cannot say.", + "uow_participants": [ + "thoth.adapters.storage.governance_history.SqliteGovernanceHistory._insert_record", + "thoth.adapters.storage.governance_history.SqliteGovernanceHistory.stage", + "thoth.adapters.storage.projects.SqliteProjectStore.update" + ], + "irreversible_effect_boundary": "Project/lifecycle and governance history publish inside the short ledger transaction; operation claim/terminal is separate.", + "allowed_phase_deltas": [ + "Accepted restore moves lifecycle and project revision together; stale expected revision or audit failure leaves the archived project state." + ], + "downstream_reader": [ + "project/read", + "project/list", + "project/policy/read" + ], + "existing_tests": [ + "tests/integration/test_project_restore_rpc.py" + ], + "fault_points": "Expected-revision conflict, not-archived and unreadable-history cases need distinct assertions.", + "disposition": "OWNER_EVIDENCE_PENDING", + "evidence_refs": [], + "source_refs": [ + "src/thoth/adapters/storage/governance_history.py", + "src/thoth/adapters/storage/projects.py", + "src/thoth/application/commands/project_restore.py", + "src/thoth/apps/management_routes.py" + ], + "atomic_regions": [ + { + "callable": "thoth.adapters.storage.sqlite.SqliteLedger.transaction", + "source": "src/thoth/adapters/storage/sqlite.py", + "line": 242, + "contexts": [ + "SqliteAtomicUnitOfWork(self._engine, immediate=True).transaction()" + ] + }, + { + "callable": "thoth.adapters.storage.transaction.SqliteAtomicUnitOfWork.transaction", + "source": "src/thoth/adapters/storage/transaction.py", + "line": 77, + "contexts": [ + "resource_use_transaction()" + ] + }, + { + "callable": "thoth.application.commands.project_restore.ProjectRestoreHandlers.restore", + "source": "src/thoth/application/commands/project_restore.py", + "line": 32, + "contexts": [ + "self._ledger.transaction()" + ] + } + ], + "entry_is_not_a_transaction": true, + "region_semantics": "Listed source-level regions are reachable candidates; the whole RPC, external I/O and operation terminal are not certified as one transaction.", + "verification": { + "classification": "MUTATION", + "required_callables": [ + "thoth.application.commands.project_restore.ProjectRestoreHandlers.restore", + "thoth.adapters.storage.governance_history.SqliteGovernanceHistory._insert_record", + "thoth.adapters.storage.governance_history.SqliteGovernanceHistory.stage", + "thoth.adapters.storage.projects.SqliteProjectStore.update" + ], + "cases": [], + "status": "CURRENT_SOURCE_EXECUTION_EVIDENCE_PENDING" + } + }, { "path_id": "rpc:project/review/list", "owner": "PROJECT_GOVERNANCE", @@ -50410,7 +50535,7 @@ "caller_refs": [ { "source": "src/thoth/apps/management_routes.py", - "line": 23, + "line": 31, "binding": "handlers.role_assign" } ], @@ -50580,7 +50705,7 @@ "caller_refs": [ { "source": "src/thoth/apps/management_routes.py", - "line": 22, + "line": 30, "binding": "handlers.role_list" } ], @@ -50655,7 +50780,7 @@ "caller_refs": [ { "source": "src/thoth/apps/management_routes.py", - "line": 24, + "line": 32, "binding": "handlers.role_revoke" } ], @@ -59147,7 +59272,7 @@ "caller_refs": [ { "source": "src/thoth/apps/management_routes.py", - "line": 35, + "line": 43, "binding": "handlers.activity_list" }, { @@ -59277,7 +59402,7 @@ "caller_refs": [ { "source": "src/thoth/apps/management_routes.py", - "line": 36, + "line": 44, "binding": "handlers.checkpoint_list" } ], @@ -59360,7 +59485,7 @@ "caller_refs": [ { "source": "src/thoth/apps/management_routes.py", - "line": 37, + "line": 45, "binding": "handlers.checkpoint_read" } ], @@ -59443,7 +59568,7 @@ "caller_refs": [ { "source": "src/thoth/apps/management_routes.py", - "line": 42, + "line": 50, "binding": "handlers.fork" } ], @@ -61071,7 +61196,7 @@ "caller_refs": [ { "source": "src/thoth/apps/management_routes.py", - "line": 33, + "line": 41, "binding": "handlers.list" } ], @@ -61149,7 +61274,7 @@ "caller_refs": [ { "source": "src/thoth/apps/management_routes.py", - "line": 43, + "line": 51, "binding": "handlers.metadata_update" } ], @@ -61281,7 +61406,7 @@ "caller_refs": [ { "source": "src/thoth/apps/management_routes.py", - "line": 39, + "line": 47, "binding": "handlers.pause" }, { @@ -61478,7 +61603,7 @@ "caller_refs": [ { "source": "src/thoth/apps/management_routes.py", - "line": 34, + "line": 42, "binding": "handlers.read" }, { @@ -61918,7 +62043,7 @@ "caller_refs": [ { "source": "src/thoth/apps/management_routes.py", - "line": 40, + "line": 48, "binding": "handlers.resume" }, { @@ -63453,7 +63578,7 @@ }, { "source": "src/thoth/apps/management_routes.py", - "line": 32, + "line": 40, "binding": "handlers.start" }, { @@ -65027,7 +65152,7 @@ }, { "source": "src/thoth/apps/management_routes.py", - "line": 38, + "line": 46, "binding": "handlers.steer" }, { @@ -66548,7 +66673,7 @@ "caller_refs": [ { "source": "src/thoth/apps/management_routes.py", - "line": 41, + "line": 49, "binding": "handlers.stop" }, { diff --git a/docs/architecture/rpc-method-catalog.md b/docs/architecture/rpc-method-catalog.md index 7af57ba..46ffde8 100644 --- a/docs/architecture/rpc-method-catalog.md +++ b/docs/architecture/rpc-method-catalog.md @@ -490,6 +490,7 @@ project/metadata/update project/overlay/update project/policy/update project/reference/import +project/restore project/role/assign project/role/revoke project/source/connect diff --git a/schemas/protocol/public-method-catalog.json b/schemas/protocol/public-method-catalog.json index 6faa389..f05f6a4 100644 --- a/schemas/protocol/public-method-catalog.json +++ b/schemas/protocol/public-method-catalog.json @@ -1,9 +1,9 @@ { "schema_version": "1.0.0", "authority": "docs/architecture/rpc-method-catalog.md", - "canonical_method_count": 362, + "canonical_method_count": 363, "compatibility_alias_count": 6, - "runtime_method_count": 368, + "runtime_method_count": 369, "notification_count": 221, "methods": [ { @@ -4104,6 +4104,20 @@ "alias_target": null, "runtime_status": "IMPLEMENTED" }, + { + "name": "project/restore", + "namespace": "project", + "surface": "COMMAND", + "canonical_owner": "PROJECT_GOVERNANCE", + "policy": "project:versioned-policy", + "normal_entrypoint": "project/create", + "behavioral_evidence": [ + "A01" + ], + "canonical": true, + "alias_target": null, + "runtime_status": "IMPLEMENTED" + }, { "name": "project/review/list", "namespace": "project", @@ -5532,5 +5546,5 @@ "thread/waitingForInput", "thread/waitingForProtectedAction" ], - "catalog_digest": "1c7d4799ca9b778a5ba023f66a79b60671e68ffc1e8f57ba295144a403a35798" + "catalog_digest": "ca56eaf9f686e31a5c58640745c1dacbb14978bc50efc47f92bc8755714aee91" } diff --git a/src/thoth/adapters/storage/projects.py b/src/thoth/adapters/storage/projects.py index bda8cb1..603fd72 100644 --- a/src/thoth/adapters/storage/projects.py +++ b/src/thoth/adapters/storage/projects.py @@ -127,3 +127,19 @@ def update(self, project: Project, *, expected_revision: int) -> bool: connection, "PROJECT", dict(after), before=dict(before) ) return changed == 1 + + def prior_lifecycle(self, project_id: str) -> ProjectLifecycle | None: + try: + recorded = SqliteGovernanceHistory(self._engine).history( + project_id, "PROJECT", project_id + ) + except ValueError: + return None + for revision in reversed(recorded): + try: + lifecycle = ProjectLifecycle(str(revision.projection.get("lifecycle"))) + except ValueError: + return None + if lifecycle != ProjectLifecycle.ARCHIVED_READ_ONLY: + return lifecycle + return None diff --git a/src/thoth/application/commands/__init__.py b/src/thoth/application/commands/__init__.py index 10252dc..dbed89b 100644 --- a/src/thoth/application/commands/__init__.py +++ b/src/thoth/application/commands/__init__.py @@ -11,6 +11,7 @@ from thoth.application.commands.objects_full import DecisionObjectHandlers from thoth.application.commands.operations import OperationCommandHandlers from thoth.application.commands.outcomes_full import OutcomeHandlers +from thoth.application.commands.project_restore import ProjectRestoreHandlers from thoth.application.commands.projections import ProjectionQueryHandlers from thoth.application.commands.projectpacks import ProjectPackCommandHandlers from thoth.application.commands.projects import ProjectCommandHandlers @@ -39,6 +40,7 @@ "OutcomeHandlers", "ProjectCommandHandlers", "ProjectPackCommandHandlers", + "ProjectRestoreHandlers", "ProjectionQueryHandlers", "ReceiptHandlers", "RevisionCommandHandlers", diff --git a/src/thoth/application/commands/project_restore.py b/src/thoth/application/commands/project_restore.py new file mode 100644 index 0000000..85e7f85 --- /dev/null +++ b/src/thoth/application/commands/project_restore.py @@ -0,0 +1,76 @@ +"""Bring an archived project back to the lifecycle it had before it was archived.""" + +from __future__ import annotations + +from pydantic import JsonValue + +from thoth.application.commands.projects import ProjectCommandHandlers, ProjectExpectedRevisionInput +from thoth.domain.enums import ProjectLifecycle +from thoth.domain.project import Project +from thoth.ports.governance import GovernanceStorePort +from thoth.ports.ledger import LedgerPort +from thoth.ports.project import ProjectStorePort +from thoth.protocol.jsonrpc import RpcApplicationError, RpcErrorCode + + +class ProjectRestoreHandlers: + """project/restore: one lifecycle step and one revision, recorded like archive.""" + + def __init__( + self, + *, + store: ProjectStorePort, + governance: GovernanceStorePort, + ledger: LedgerPort, + projects: ProjectCommandHandlers, + ) -> None: + self._store = store + self._governance = governance + self._ledger = ledger + self._projects = projects + + async def restore(self, value: dict[str, JsonValue]) -> dict[str, JsonValue]: + request = ProjectExpectedRevisionInput.model_validate(value) + with self._ledger.transaction(): + project = self._store.read(request.project_id) + if project is None: + raise RpcApplicationError( + RpcErrorCode.PROJECT_NOT_FOUND, + "project was not found", + data={"project_id": request.project_id}, + ) + if project.lifecycle == ProjectLifecycle.ARCHIVED_READ_ONLY: + if project.revision != request.expected_revision: + raise RpcApplicationError( + RpcErrorCode.DOMAIN_REJECTED, + "project revision does not match expected_revision", + data={ + "expected_revision": request.expected_revision, + "current_revision": project.revision, + }, + ) + updated = project.model_copy( + update={ + "lifecycle": self._lifecycle_before_archive(project), + "revision": project.revision + 1, + } + ) + if not self._store.update(updated, expected_revision=request.expected_revision): + raise RpcApplicationError( + RpcErrorCode.DOMAIN_REJECTED, + "project revision changed concurrently", + data={"expected_revision": request.expected_revision}, + ) + return await self._projects.read({"project_id": request.project_id}) + + def _lifecycle_before_archive(self, project: Project) -> ProjectLifecycle: + """The recorded lifecycle before the archive; if the record cannot say, activation rules.""" + recorded = self._store.prior_lifecycle(project.project_id) + if recorded is not None: + return recorded + active_source = any( + item.state == "ACTIVE" + for item in self._governance.list_source_bindings(project.project_id) + ) + has_policy = self._governance.read_policy(project.project_id) is not None + return ProjectLifecycle.ACTIVE if active_source and has_policy else ProjectLifecycle.DRAFT diff --git a/src/thoth/apps/management_routes.py b/src/thoth/apps/management_routes.py index 39f9d9b..b1d270c 100644 --- a/src/thoth/apps/management_routes.py +++ b/src/thoth/apps/management_routes.py @@ -3,18 +3,26 @@ from thoth.application.commands import ( InvestigationQueryHandlers, ProjectCommandHandlers, + ProjectRestoreHandlers, ThreadCommandHandlers, ) +from thoth.ports.store_bundle import StoreBundlePort from thoth.protocol.registry import MethodRegistry -def register_project_methods(registry: MethodRegistry, handlers: ProjectCommandHandlers) -> None: +def register_project_methods( + registry: MethodRegistry, handlers: ProjectCommandHandlers, stores: StoreBundlePort +) -> None: + restore = ProjectRestoreHandlers( + store=stores.projects, governance=stores.governance, ledger=stores.ledger, projects=handlers + ) registry.register("project/create", handlers.create) registry.register("project/list", handlers.list) registry.register("project/read", handlers.read) registry.register("project/activate", handlers.activate) registry.register("project/archive", handlers.archive) registry.register("project/delete", handlers.archive) + registry.register("project/restore", restore.restore) registry.register("project/metadata/update", handlers.metadata_update) registry.register("project/overlay/update", handlers.overlay_update) registry.register("project/policy/read", handlers.policy_read) diff --git a/src/thoth/apps/runtime.py b/src/thoth/apps/runtime.py index cd9a881..5e37584 100644 --- a/src/thoth/apps/runtime.py +++ b/src/thoth/apps/runtime.py @@ -570,7 +570,7 @@ def workspace_setup(): registry.register("field/export/build", field_measurement_handlers.export_build) registry.register("projectpack/list", projectpack_handlers.list) registry.register("projectpack/run", projectpack_handlers.run) - register_project_methods(registry, project_handlers) + register_project_methods(registry, project_handlers, stores) register_closure_and_export_methods( registry, lifecycle_handlers, closure_handlers, export_handlers ) diff --git a/src/thoth/ports/project.py b/src/thoth/ports/project.py index 38a934c..c487f00 100644 --- a/src/thoth/ports/project.py +++ b/src/thoth/ports/project.py @@ -2,6 +2,7 @@ from typing import Protocol +from thoth.domain.enums import ProjectLifecycle from thoth.domain.project import Project @@ -17,3 +18,7 @@ def read(self, project_id: str) -> Project | None: ... def list(self) -> tuple[Project, ...]: ... def update(self, project: Project, *, expected_revision: int) -> bool: ... + + def prior_lifecycle(self, project_id: str) -> ProjectLifecycle | None: + """Lifecycle recorded before the archive; None if the record cannot say.""" + ... diff --git a/src/thoth/protocol/notification_catalog.py b/src/thoth/protocol/notification_catalog.py index c04d985..00754f4 100644 --- a/src/thoth/protocol/notification_catalog.py +++ b/src/thoth/protocol/notification_catalog.py @@ -7,6 +7,7 @@ "project/activate": ("project/activated", "project/statusChanged"), "project/archive": ("project/archived", "project/statusChanged"), "project/delete": ("project/archived", "project/statusChanged"), + "project/restore": ("project/statusChanged",), "project/metadata/update": ("project/metadata/updated",), "project/source/connect": ("project/source/connected",), "project/role/assign": ("project/role/assigned",), diff --git a/src/thoth/protocol/registry.py b/src/thoth/protocol/registry.py index 4ab76cb..46ae69e 100644 --- a/src/thoth/protocol/registry.py +++ b/src/thoth/protocol/registry.py @@ -56,6 +56,7 @@ "project/activate", "project/archive", "project/delete", + "project/restore", "project/metadata/update", "project/list", "project/overlay/update", diff --git a/tests/architecture/test_public_protocol_exact_parity.py b/tests/architecture/test_public_protocol_exact_parity.py index ca64ee7..8ca1bda 100644 --- a/tests/architecture/test_public_protocol_exact_parity.py +++ b/tests/architecture/test_public_protocol_exact_parity.py @@ -17,11 +17,11 @@ def test_public_method_manifest_exactly_matches_runtime_and_notifications() -> N manifest = json.loads(MANIFEST.read_text(encoding="utf-8")) methods = manifest["methods"] by_name = {item["name"]: item for item in methods} - assert len(methods) == len(by_name) == 368 + assert len(methods) == len(by_name) == 369 assert set(by_name) == set(PUBLIC_METHODS) - assert sum(item["canonical"] for item in methods) == 362 - assert manifest["runtime_method_count"] == 368 - assert manifest["canonical_method_count"] == 362 + assert sum(item["canonical"] for item in methods) == 363 + assert manifest["runtime_method_count"] == 369 + assert manifest["canonical_method_count"] == 363 assert manifest["compatibility_alias_count"] == 6 assert { f"project/source/scope/{name}" for name in ("read", "grant", "revoke", "assign", "update") diff --git a/tests/fixtures/runtime_registration_events.json b/tests/fixtures/runtime_registration_events.json index 8c8644e..f450e19 100644 --- a/tests/fixtures/runtime_registration_events.json +++ b/tests/fixtures/runtime_registration_events.json @@ -95,6 +95,11 @@ "project/delete", "thoth.application.commands.projects.ProjectCommandHandlers.archive" ], + [ + "register", + "project/restore", + "thoth.application.commands.project_restore.ProjectRestoreHandlers.restore" + ], [ "register", "project/metadata/update", @@ -2047,6 +2052,11 @@ "project/delete", "thoth.application.commands.projects.ProjectCommandHandlers.archive" ], + [ + "register", + "project/restore", + "thoth.application.commands.project_restore.ProjectRestoreHandlers.restore" + ], [ "register", "project/metadata/update", diff --git a/tests/integration/test_project_restore_rpc.py b/tests/integration/test_project_restore_rpc.py new file mode 100644 index 0000000..8c01a86 --- /dev/null +++ b/tests/integration/test_project_restore_rpc.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from pathlib import Path + +import pytest +from sqlalchemy import text +from tests.integration.storage_coverage_helpers import ( + domain_snapshot, + prepare_project, + request, + value, +) + +from thoth.adapters.storage.governance import SqliteGovernanceStore +from thoth.adapters.storage.projects import SqliteProjectStore +from thoth.apps.runtime import AppRuntime +from thoth.domain.enums import ProjectLifecycle +from thoth.domain.governance import SourceBinding +from thoth.protocol.jsonrpc import RpcErrorCode + + +async def _archive(runtime: AppRuntime, project: str, key: str) -> dict[str, object]: + current = value( + await runtime.bus.query(request("project/read", key + "-read", {"project_id": project})) + ) + return value( + await runtime.bus.dispatch( + request( + "project/archive", + key, + {"project_id": project, "expected_revision": current["revision"]}, + ) + ) + ) + + +async def _restore(runtime: AppRuntime, project: str, key: str, revision: object): + return await runtime.bus.dispatch( + request("project/restore", key, {"project_id": project, "expected_revision": revision}) + ) + + +def _no_record(self: SqliteProjectStore, project_id: str) -> None: + """A history that cannot say what the project was before the archive.""" + return None + + +def _move_to(runtime: AppRuntime, project: str, lifecycle: ProjectLifecycle) -> None: + """Move the project through the store, so the move lands in the governance history.""" + store = SqliteProjectStore(runtime.ledger.engine) + current = store.read(project) + assert current is not None + moved = current.model_copy(update={"lifecycle": lifecycle, "revision": current.revision + 1}) + assert store.update(moved, expected_revision=current.revision) + + +def _lifecycles(runtime: AppRuntime, project: str) -> list[str]: + with runtime.ledger.engine.connect() as connection: + rows = connection.execute( + text( + "SELECT json_extract(content_json, '$.projection.lifecycle') " + "FROM governance_revisions " + "WHERE project_id=:project AND record_kind='PROJECT' ORDER BY revision" + ), + {"project": project}, + ).scalars() + return [str(item) for item in rows] + + +async def test_restore_brings_back_the_lifecycle_before_the_archive_and_keeps_the_project_usable( + tmp_path: Path, +) -> None: + runtime, project = await prepare_project(tmp_path) + try: + archived = await _archive(runtime, project, "archive-1") + restored = value(await _restore(runtime, project, "restore-1", archived["revision"])) + assert restored["lifecycle"] == "DRAFT" + assert restored["revision"] == archived["revision"] + 1 # type: ignore[operator] + # the history shows the whole path, so the restore is visible as its own revision + assert _lifecycles(runtime, project) == ["DRAFT", "ARCHIVED_READ_ONLY", "DRAFT"] + listed = value( + await runtime.bus.query( + request("project/list", "list", {"project_id": "system:projects"}) + ) + ) + assert [item["project_id"] for item in listed["projects"]] == [project] + renamed = value( + await runtime.bus.dispatch( + request( + "project/metadata/update", + "rename-after-restore", + { + "project_id": project, + "expected_revision": restored["revision"], + "name": "Back again", + }, + ) + ) + ) + assert renamed["name"] == "Back again" + finally: + runtime.close() + + +@pytest.mark.parametrize("before", [ProjectLifecycle.ACTIVE, ProjectLifecycle.CLOSING]) +async def test_restore_returns_to_the_recorded_active_or_closing_state( + tmp_path: Path, before: ProjectLifecycle +) -> None: + runtime, project = await prepare_project(tmp_path) + try: + _move_to(runtime, project, before) + archived = await _archive(runtime, project, "archive-1") + restored = value(await _restore(runtime, project, "restore-1", archived["revision"])) + assert restored["lifecycle"] == before.value + assert _lifecycles(runtime, project)[-3:] == [ + before.value, + "ARCHIVED_READ_ONLY", + before.value, + ] + # archive again and restore again: the newest earlier lifecycle is used, not the first one + again = await _archive(runtime, project, "archive-2") + assert ( + value(await _restore(runtime, project, "restore-2", again["revision"]))["lifecycle"] + == before.value + ) + finally: + runtime.close() + + +async def test_restore_without_a_readable_record_uses_the_activation_rule( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(SqliteProjectStore, "prior_lifecycle", _no_record) + runtime, project = await prepare_project(tmp_path) + try: + archived = await _archive(runtime, project, "archive-1") + # a policy exists but no active source is connected: draft + assert ( + value(await _restore(runtime, project, "restore-1", archived["revision"]))["lifecycle"] + == "DRAFT" + ) + moment = datetime(2026, 9, 5, tzinfo=UTC) + SqliteGovernanceStore(runtime.ledger.engine).put_source_binding( + SourceBinding( + binding_id="binding:1", + project_id=project, + artifact_id="artifact:1", + created_at=moment, + updated_at=moment, + ) + ) + again = await _archive(runtime, project, "archive-2") + assert ( + value(await _restore(runtime, project, "restore-2", again["revision"]))["lifecycle"] + == "ACTIVE" + ) + finally: + runtime.close() + + +async def test_restore_of_a_project_that_is_not_archived_returns_it_unchanged( + tmp_path: Path, +) -> None: + runtime, project = await prepare_project(tmp_path) + try: + before = domain_snapshot(runtime.ledger.engine) + current = value( + await runtime.bus.query(request("project/read", "read", {"project_id": project})) + ) + same = value(await _restore(runtime, project, "restore-not-archived", 999)) + assert same["lifecycle"] == "DRAFT" and same["revision"] == current["revision"] + assert domain_snapshot(runtime.ledger.engine) == before + finally: + runtime.close() + + +async def test_restore_with_a_stale_revision_is_rejected_and_changes_nothing( + tmp_path: Path, +) -> None: + runtime, project = await prepare_project(tmp_path) + try: + archived = await _archive(runtime, project, "archive-1") + before = domain_snapshot(runtime.ledger.engine) + rejected = await _restore(runtime, project, "restore-stale", int(archived["revision"]) - 1) # type: ignore[call-overload] + assert rejected.error is not None + assert rejected.error.message == "project revision does not match expected_revision" + assert domain_snapshot(runtime.ledger.engine) == before + readback = value( + await runtime.bus.query(request("project/read", "readback", {"project_id": project})) + ) + assert readback["lifecycle"] == "ARCHIVED_READ_ONLY" + finally: + runtime.close() + + +async def test_restore_of_an_unknown_project_is_not_found(tmp_path: Path) -> None: + runtime, _project = await prepare_project(tmp_path) + try: + missing = await _restore(runtime, "project:missing", "restore-missing", 0) + assert missing.error is not None + assert missing.error.code == RpcErrorCode.PROJECT_NOT_FOUND + finally: + runtime.close()