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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 94 additions & 64 deletions SOURCE_MANIFEST.json

Large diffs are not rendered by default.

24 changes: 24 additions & 0 deletions apps/web/src/api/resultEvidence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("연결을 확인하지 못했습니다");
});
14 changes: 10 additions & 4 deletions apps/web/src/api/resultEvidence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>)[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<string, unknown>, stored: Record<string, unknown>): 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<string, unknown>, thread: string, epoch: unknown, terminal: unknown) {
Expand All @@ -56,12 +61,13 @@ async function read(selection: ResultSelection, displayed: Record<string, unknow
if (!value.manifest || !value.result_revision_digest) throw new Error("이 답변의 저장 버전과 근거 목록을 확인하지 못했습니다.");
if (value.request.project_id !== scope.projectId || value.request.thread_id !== scope.threadId || value.request.operation_id !== selection.operationId || !value.result) mismatch();
const manifest = manifestSchema.parse(value.manifest);
const displayedContent = resultContentKey(answerContent(displayed, scope.threadId, value.request.request_epoch, value.manifest.terminal_reason));
const resultContent = resultContentKey(answerContent(value.result, scope.threadId, value.request.request_epoch, value.manifest.terminal_reason));
const manifestContent = resultContentKey(answerContent(manifest.result, scope.threadId, value.request.request_epoch, value.manifest.terminal_reason));
const displayedContent = answerContent(displayed, scope.threadId, value.request.request_epoch, value.manifest.terminal_reason);
const resultBody = answerContent(value.result, scope.threadId, value.request.request_epoch, value.manifest.terminal_reason);
const manifestBody = answerContent(manifest.result, scope.threadId, value.request.request_epoch, value.manifest.terminal_reason);
if (manifest.operation_id !== selection.operationId || manifest.request_ref.project_id !== scope.projectId ||
manifest.request_ref.entity_type !== "THREAD" || manifest.request_ref.entity_id !== `request:${scope.threadId}` ||
manifest.request_ref.revision_digest !== scope.requestDigest || resultContent !== displayedContent || manifestContent !== displayedContent) mismatch();
manifest.request_ref.revision_digest !== scope.requestDigest ||
resultContentKey(resultBody) !== resultContentKey(manifestBody) || !isShownPartOf(displayedContent, manifestBody)) mismatch();
return { value, manifest };
}

Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/AnswerHold.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export function AnswerHold({ status, coverage, facts, counter, next }: {
<p>결론 상태: <strong>{label}</strong></p>
{facts}
{rows.length > 0 && <EvidenceUseSummary rows={rows}/>}
<p>답할 수 있는 범위: {rows.length ? `기준 충족 ${supported}/${rows.length} · 확인 필요 ${needsCheck}` : "평가기준 정보를 아직 읽지 못했습니다"}</p>
<p>답할 수 있는 범위: {rows.length ? `답변 점검 항목 ${rows.length}개 중 충족 ${supported} · 확인 필요 ${needsCheck}` : "답변 점검 항목 정보를 아직 읽지 못했습니다"}</p>
{counter && <p>{counter}</p>}
{next && <p>다음 확보: {next}</p>}
</Callout>;
Expand Down
32 changes: 32 additions & 0 deletions apps/web/src/components/ArchivedProjects.tsx
Original file line number Diff line number Diff line change
@@ -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<ProjectSummary>("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 <div className="archived-projects">
<Button small minimal icon={open ? "chevron-down" : "box"} aria-expanded={open} onClick={() => setOpen(value => !value)}>보관한 프로젝트 보기</Button>
{open && <div className="project-sessions">
{archived.isPending && <p className="empty-copy" role="status">보관한 프로젝트를 읽는 중…</p>}
{archived.error && <Callout compact intent="danger" role="alert">보관한 프로젝트를 읽지 못했습니다. 없다는 뜻은 아닙니다.</Callout>}
{archived.isSuccess && rows.length === 0 && <p className="empty-copy">보관한 프로젝트가 없습니다.</p>}
{rows.map(item => <div className="archived-project-row" key={item.project_id}>
<span>{item.name}</span>
<Button small minimal icon="reset" aria-label={`${item.name} 되살리기`} loading={restore.isPending && restore.variables?.project_id === item.project_id}
disabled={restore.isPending} onClick={() => restore.mutate(item)}>되살리기</Button>
</div>)}
{restore.error && <Callout compact intent="danger" role="alert">되살리지 못했습니다. {restore.error.message}</Callout>}
</div>}
</div>;
}
29 changes: 22 additions & 7 deletions apps/web/src/components/ContractV3Screens.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ const result = (tests: Json[], conditions?: string[]) => {
return copy as unknown as Record<string, unknown>;
};
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<string, unknown>, 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<string, unknown>) => renderToStaticMarkup(<TestOrderSection rows={hypothesisRows(value)} actions={[]}/>);

Expand Down Expand Up @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/ConversationTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 <section className="conversation-timeline" aria-label="연구 대화">
{!example && <SyntheticDataNotice projectId={projectId}/>}
{query.hasNextPage && <Button minimal loading={query.isFetchingNextPage} onClick={() => void query.fetchNextPage()}>이전 대화 불러오기</Button>}
{query.error && <Callout intent="warning" role="alert">{query.data ? "대화를 다시 확인하지 못했습니다. 마지막으로 불러온 답변을 표시하며, 최신 상태는 확인되지 않았습니다. " : "대화를 불러오지 못했습니다. "}<Button small onClick={() => void query.refetch()}>다시 읽기</Button></Callout>}
{query.data?.pages.some(page => page.limited) && <Callout compact>조회 범위를 넘는 과거 이력이 있습니다. 기록에서 확인할 수 있습니다.</Callout>}
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/ConversationTimelineRefetch.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
74 changes: 74 additions & 0 deletions apps/web/src/components/ConversationTimelineSynthetic.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof import("../api/rpcClient")>(),
rpc: vi.fn(),
}));

let root: Root | undefined;
let container: HTMLDivElement;
let client: QueryClient;

const item = (title: string, fields: Record<string, string> = {}): 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(<QueryClientProvider client={client}>
<ConversationTimeline projectId="p" threadId="t" onDetail={() => undefined}/></QueryClientProvider>));
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();
});
Loading
Loading