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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,8 @@ pending user validation and item-usage rights.
| Web app (`apps/web`) | 🟡 Placeholder | Vite + React static intro page; not a product UI |
| API (`apps/api`) | 🟡 Minimal but runnable | Fastify server with `/healthz`, `/readyz`, graceful shutdown, and a demo route proving runtime package resolution; no product endpoints |
| Database (`prisma/`, `packages/db`) | ✅ Wired | Prisma 7 (`prisma-client` generator, PostgreSQL driver adapter), first migration, idempotent seed, `.env.example`, docker-compose Postgres; CI applies the migration and smoke-tests the built client against a real database |
| LLM / PDF / storage | ❌ Missing | No model calls, PDF parser, or object storage |
| Summary generation (`@study-os/summary`) | ✅ Implemented | Korean-first `SummaryProvider` contract with provenance (`GenerationRun` info: model, prompt version, input hash, tokens); Claude-backed provider (structured outputs, adaptive thinking) when `ANTHROPIC_API_KEY` is set, deterministic offline mock otherwise; **fail-closed** on missing/insufficient evidence, refusals, and malformed output; demo route `POST /api/demo/summary` |
| PDF / storage | ❌ Missing | No PDF parser or object storage (M3) |
| Tests / lint / CI | ✅ Implemented | Biome lint, Vitest unit tests, GitHub Actions with a frozen-lockfile install and a runtime smoke test of the built API |

> Note on the build: the original scaffold compiled while the built API crashed
Expand All @@ -142,9 +143,10 @@ apps/
packages/
core/ # shared TypeScript domain types
db/ # Prisma 7 client factory (PostgreSQL driver adapter)
ingestion/ # text-split stub
ingestion/ # Korean-aware segmentation with resolvable citation offsets
quiz-engine/ # placeholder quiz generation + exact-match grading
scheduler/ # fixed-interval review stub
summary/ # Korean summary provider: Claude-backed or deterministic mock
prisma/
schema.prisma # data model
migrations/ # SQL migrations (applied in CI against real Postgres)
Expand Down
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"@study-os/ingestion": "workspace:*",
"@study-os/quiz-engine": "workspace:*",
"@study-os/scheduler": "workspace:*",
"@study-os/summary": "workspace:*",
"fastify": "^5.10.0"
},
"devDependencies": {
Expand Down
51 changes: 51 additions & 0 deletions apps/api/src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,54 @@ describe("demo study-loop pipeline", () => {
expect(new Date(body.reviewTask.scheduledAt).getTime()).toBeGreaterThan(Date.now());
});
});

describe("POST /api/demo/summary", () => {
const validBody = {
title: "프로세스와 스레드",
content:
"프로세스는 실행 중인 프로그램이다. 스레드는 프로세스 내부의 실행 단위이며 같은 주소 공간을 공유한다.",
};

it("returns a Korean summary card via the default (mock) provider", async () => {
const res = await app.inject({
method: "POST",
url: "/api/demo/summary",
payload: validBody,
});
expect(res.statusCode).toBe(200);

const body = res.json();
expect(body.provider).toBe("mock");
expect(body.card.shortSummary).toContain("프로세스는 실행 중인 프로그램이다");
expect(body.card.keyConcepts.length).toBeGreaterThan(0);
expect(body.card.generation.inputSha256).toMatch(/^[0-9a-f]{64}$/);
});

it("rejects missing fields with 400", async () => {
const res = await app.inject({
method: "POST",
url: "/api/demo/summary",
payload: { title: "제목만" },
});
expect(res.statusCode).toBe(400);
});

it("rejects an unknown tone preset with 400", async () => {
const res = await app.inject({
method: "POST",
url: "/api/demo/summary",
payload: { ...validBody, tonePreset: "poet" },
});
expect(res.statusCode).toBe(400);
});

it("fails closed (400) when content cannot ground a summary", async () => {
const res = await app.inject({
method: "POST",
url: "/api/demo/summary",
payload: { title: "t", content: "짧다" },
});
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/too short/);
});
});
42 changes: 42 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,24 @@ import type { ErrorNotebookEntry, StudyUnit } from "@study-os/core";
import { buildIngestionResult } from "@study-os/ingestion";
import { generateQuizDraft } from "@study-os/quiz-engine";
import { buildReviewTask } from "@study-os/scheduler";
import {
createDefaultSummaryProvider,
SummaryGenerationError,
type SummaryProvider,
SummaryValidationError,
type TonePreset,
} from "@study-os/summary";
import Fastify, { type FastifyInstance } from "fastify";

export interface BuildAppOptions {
logger?: boolean;
/** Injectable for tests; defaults to Anthropic when ANTHROPIC_API_KEY is set, else mock. */
summaryProvider?: SummaryProvider;
}

export function buildApp(options: BuildAppOptions = {}): FastifyInstance {
const app = Fastify({ logger: options.logger ?? false });
const summaryProvider = options.summaryProvider ?? createDefaultSummaryProvider();

app.get("/", async () => ({
name: "study-os-api",
Expand Down Expand Up @@ -63,5 +73,37 @@ export function buildApp(options: BuildAppOptions = {}): FastifyInstance {
return { ingestion, quizDraft, reviewTask };
});

// Korean summary generation for a study unit (issues #10/#4). Uses the
// configured SummaryProvider — deterministic mock without ANTHROPIC_API_KEY,
// Claude-backed with it. Fail-closed: invalid input → 400, generation
// failure or insufficient evidence → 502; never a fabricated summary.
app.post<{
Body: { title?: string; content?: string; tonePreset?: TonePreset };
}>("/api/demo/summary", async (request, reply) => {
const { title, content, tonePreset } = request.body ?? {};
if (typeof title !== "string" || typeof content !== "string") {
return reply.status(400).send({ error: "title and content are required strings" });
}
if (tonePreset !== undefined && !["teacher", "tutor", "concise-exam"].includes(tonePreset)) {
return reply.status(400).send({ error: "tonePreset must be teacher|tutor|concise-exam" });
}

try {
const card = await summaryProvider.generateSummary({
unit: { title, content },
tonePreset,
});
return { provider: summaryProvider.name, card };
} catch (err) {
if (err instanceof SummaryValidationError) {
return reply.status(400).send({ error: err.message });
}
if (err instanceof SummaryGenerationError) {
return reply.status(502).send({ error: err.message });
}
throw err;
}
});

return app;
}
3 changes: 2 additions & 1 deletion apps/api/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
{ "path": "../../packages/core" },
{ "path": "../../packages/ingestion" },
{ "path": "../../packages/quiz-engine" },
{ "path": "../../packages/scheduler" }
{ "path": "../../packages/scheduler" },
{ "path": "../../packages/summary" }
]
}
29 changes: 29 additions & 0 deletions packages/summary/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"name": "@study-os/summary",
"private": true,
"version": "0.1.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc -b",
"test": "vitest run"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.111.0"
},
"devDependencies": {
"@types/node": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
}
}
128 changes: 128 additions & 0 deletions packages/summary/src/anthropic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import type Anthropic from "@anthropic-ai/sdk";
import { describe, expect, it, vi } from "vitest";
import {
ANTHROPIC_PROMPT_VERSION,
AnthropicSummaryProvider,
DEFAULT_SUMMARY_MODEL,
} from "./anthropic.js";
import { SummaryGenerationError } from "./types.js";

const request = {
unit: {
title: "가상 메모리",
content: "가상 메모리는 물리 메모리보다 큰 주소 공간을 제공한다. 페이지 단위로 관리된다.",
},
} as const;

function fakeClient(response: unknown): { client: Anthropic; create: ReturnType<typeof vi.fn> } {
const create = vi.fn().mockResolvedValue(response);
return { client: { messages: { create } } as unknown as Anthropic, create };
}

function successResponse(overrides: Record<string, unknown> = {}) {
return {
model: "claude-opus-4-8",
stop_reason: "end_turn",
content: [
{
type: "text",
text: JSON.stringify({
evidence_sufficient: true,
short_summary: "가상 메모리는 물리 메모리보다 큰 주소 공간을 제공하는 기법이다.",
key_concepts: ["가상 메모리", "페이지"],
confusion_points: ["가상 메모리와 물리 메모리의 크기 관계"],
}),
},
],
usage: { input_tokens: 321, output_tokens: 87 },
...overrides,
};
}

describe("AnthropicSummaryProvider", () => {
it("maps a structured response onto a summary card with provenance", async () => {
const { client, create } = fakeClient(successResponse());
const provider = new AnthropicSummaryProvider({ client });

const card = await provider.generateSummary(request);

expect(card.shortSummary).toContain("가상 메모리");
expect(card.keyConcepts).toEqual(["가상 메모리", "페이지"]);
expect(card.generation).toMatchObject({
provider: "anthropic",
model: "claude-opus-4-8",
promptVersion: ANTHROPIC_PROMPT_VERSION,
inputTokens: 321,
outputTokens: 87,
});
expect(card.generation.inputSha256).toMatch(/^[0-9a-f]{64}$/);

// Request shape: current model, adaptive thinking, structured output.
const params = create.mock.calls[0]?.[0];
expect(params.model).toBe(DEFAULT_SUMMARY_MODEL);
expect(params.thinking).toEqual({ type: "adaptive" });
expect(params.output_config.format.type).toBe("json_schema");
// The source content must be wrapped as untrusted data.
expect(params.messages[0].content).toContain("<자료>");
});

it("fails closed when the model reports insufficient evidence", async () => {
const { client } = fakeClient(
successResponse({
content: [
{
type: "text",
text: JSON.stringify({
evidence_sufficient: false,
short_summary: "",
key_concepts: [],
confusion_points: [],
}),
},
],
}),
);
const provider = new AnthropicSummaryProvider({ client });
await expect(provider.generateSummary(request)).rejects.toThrow(/insufficient evidence/);
});

it("fails closed on a refusal stop reason", async () => {
const { client } = fakeClient(successResponse({ stop_reason: "refusal", content: [] }));
const provider = new AnthropicSummaryProvider({ client });
await expect(provider.generateSummary(request)).rejects.toThrow(/refused/);
});

it("fails closed on truncated output", async () => {
const { client } = fakeClient(successResponse({ stop_reason: "max_tokens" }));
const provider = new AnthropicSummaryProvider({ client });
await expect(provider.generateSummary(request)).rejects.toThrow(/truncated/);
});

it("fails closed on non-JSON output", async () => {
const { client } = fakeClient(
successResponse({ content: [{ type: "text", text: "요약: 이건 JSON이 아님" }] }),
);
const provider = new AnthropicSummaryProvider({ client });
await expect(provider.generateSummary(request)).rejects.toThrow(SummaryGenerationError);
});

it("fails closed when the generated card violates output bounds", async () => {
const { client } = fakeClient(
successResponse({
content: [
{
type: "text",
text: JSON.stringify({
evidence_sufficient: true,
short_summary: "요약",
key_concepts: [], // empty — violates 1..10
confusion_points: [],
}),
},
],
}),
);
const provider = new AnthropicSummaryProvider({ client });
await expect(provider.generateSummary(request)).rejects.toThrow(/keyConcepts/);
});
});
Loading
Loading