diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 52b1bab..95ad30e 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -28,6 +28,7 @@ pnpm dev # FE (Vite) | `app` | React 18과 Vite 기반 프론트엔드. FSD 구조(`app`, `pages`, `widgets`, `features`, `entities`, `shared`) | | `functions` | Firebase Cloud Functions. GitHub 연동, AI 카드 생성, 알림 스케줄, 결제 | | `design-system` | 디자인 가이드 문서 | +| `wiki` | 설계 의도와 제약을 모은 LLM Wiki. 작성 규칙은 루트 [AGENTS.md](../AGENTS.md) | | `scripts` | 환경 변수 생성 등 보조 스크립트 | ## 코드 규칙 diff --git a/.github/workflows/wiki-lint.yml b/.github/workflows/wiki-lint.yml new file mode 100644 index 0000000..570e897 --- /dev/null +++ b/.github/workflows/wiki-lint.yml @@ -0,0 +1,35 @@ +name: Wiki Lint + +on: + pull_request: + paths: + - "wiki/**" + - "scripts/wiki-lint*.mjs" + - "package.json" + - ".github/workflows/wiki-lint.yml" + push: + branches: [main] + paths: + - "wiki/**" + - "scripts/wiki-lint*.mjs" + - "package.json" + - ".github/workflows/wiki-lint.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v7 + with: + node-version: 22 + cache: pnpm + # 위키 검사는 루트의 yaml만 쓰므로 app과 functions 의존성은 설치하지 않음 + - run: pnpm install --frozen-lockfile --filter "{.}" + - run: pnpm wiki:test + - run: pnpm wiki:lint diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d69c6b2 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,37 @@ +# CodeRecall 에이전트 규칙 + +이 파일은 이 저장소에서 일하는 코딩 에이전트의 정본 규칙입니다. 에이전트별 설정 파일(`CLAUDE.md` 등)은 이 파일을 불러오기만 하고 내용을 따로 두지 않습니다. + +## 저장소 + +| 경로 | 내용 | +|------|------| +| `app` | React 18과 Vite 기반 프론트엔드. FSD 구조 | +| `functions` | Firebase Cloud Functions | +| `firestore.rules` | Firestore 접근 경계 | +| `wiki` | LLM Wiki. 코드만 읽어서는 알기 어려운 설계 의도와 제약 | +| `design-system` | 디자인 가이드 | + +로컬 셋팅과 코드 규칙, 브랜치와 커밋 규칙은 [CONTRIBUTING.md](.github/CONTRIBUTING.md)를 따릅니다. + +## 위키 + +`wiki/`는 [OKF v0.2](https://github.com/GoogleCloudPlatform/open-knowledge-format) 형식의 LLM Wiki입니다. + +### 항상 지키는 원칙 + +- 작업과 관련된 문서를 [wiki/index.md](wiki/index.md)에서 먼저 찾아 읽습니다. 원본 코드 전체를 다시 훑기 전에 위키부터 봅니다. +- 위키 내용과 코드가 다르면 코드가 현재 동작의 정본입니다. 어긋남을 작업 보고에 적고 위키를 고칩니다. +- `status: draft`이거나 `verified`가 없는 문서는 사람이 확인하지 않은 내용입니다. 근거(`sources`)를 직접 열어 확인한 뒤 의존합니다. +- 코드나 규칙을 바꾸는 PR에서, 바뀐 사실을 다루는 위키 문서가 있으면 같은 PR에서 함께 고칩니다. 코드 변경을 자동으로 감지하지는 않습니다. + +### 위키를 쓰거나 고칠 때 + +frontmatter 형식, 신뢰 규칙, Ingest와 Query와 Lint 절차는 `chucoding:okf-wiki` 스킬을 따릅니다. 이 저장소에서는 아래 값을 씁니다. + +| 항목 | 값 | +|------|------| +| 문서 유형(`type`) | `Architecture`, `Workflow`, `Data Rule`, `Security Boundary`, `Decision` | +| `stale_after` 기본 기간 | 코드 파생 문서는 작성일로부터 3개월, 결정 기록은 6개월 | +| 정적 검사 | `pnpm wiki:lint`. CI는 위키와 검사 스크립트를 고친 PR과 main 푸시에서 실행 | +| 쓰지 않는 내용 | 저장소가 public이므로 비밀 값, 비용과 매출 수치, 사용자 개인정보, 운영 계정 정보. 배경은 [위키를 코드 저장소에 두는 결정](wiki/decisions/wiki-location.md) | diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/package.json b/package.json index 0c5eb8e..18a5b35 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,11 @@ "serve": "pnpm --filter functions build && firebase emulators:start --only functions", "dev": "pnpm --filter app dev", "build": "pnpm -r build", - "push": "firebase deploy" + "push": "firebase deploy", + "wiki:lint": "node scripts/wiki-lint.mjs", + "wiki:test": "node --test scripts/wiki-lint.test.mjs" + }, + "devDependencies": { + "yaml": "^2.9.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d518f7d..43289bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,11 @@ settings: importers: - .: {} + .: + devDependencies: + yaml: + specifier: ^2.9.1 + version: 2.9.1 app: dependencies: @@ -2347,6 +2351,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@unrs/resolver-binding-android-arm-eabi@1.11.1': resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} @@ -6054,6 +6059,11 @@ packages: yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yaml@2.9.1: + resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -13253,6 +13263,8 @@ snapshots: yallist@4.0.0: {} + yaml@2.9.1: {} + yargs-parser@21.1.1: {} yargs@17.7.2: diff --git a/scripts/wiki-lint.mjs b/scripts/wiki-lint.mjs new file mode 100644 index 0000000..a75fba6 --- /dev/null +++ b/scripts/wiki-lint.mjs @@ -0,0 +1,322 @@ +import {existsSync, readdirSync, readFileSync, statSync} from "node:fs"; +import {dirname, join, relative, resolve, sep} from "node:path"; +import {fileURLToPath} from "node:url"; +import {parse} from "yaml"; + +/** + * 위키 정적 검사 + * + * `wiki/`는 OKF v0.2 번들이다. OKF 스펙은 `type`만 필수로 두지만, 이 저장소는 나중에 어떤 + * 도구로 옮겨도 신뢰 신호를 잃지 않도록 출처와 수명 필드를 처음부터 강제한다. 모순이나 오래된 + * 주장처럼 의미를 봐야 하는 점검은 LLM Lint가 맡고, 여기서는 기계적으로 판정할 수 있는 것만 본다. + */ + +/** OKF 예약 파일. 개념 문서가 아니라 목록과 이력이므로 frontmatter 검사에서 뺌 */ +const RESERVED_FILES = new Set(["index.md", "log.md"]); + +/** 개념 문서에 반드시 있어야 하는 frontmatter 키 */ +const REQUIRED_KEYS = ["type", "title", "description", "status", "sources", "generated", "stale_after"]; + +/** OKF v0.2 수명 상태 */ +const STATUS_VALUES = new Set(["draft", "stable", "deprecated"]); + +/** OKF 행위자 표기. `<도구>/<버전>`, `human:`, `process:` */ +const ACTOR_PATTERN = /^(human:[^\s]+|process:[^\s]+|[^\s:/]+\/[^\s]+)$/; + +/** `stale_after` 만료 전에 경고를 띄우는 기간 */ +const STALE_WARNING_DAYS = 14; + +/** 마크다운 인라인 링크. 이미지와 코드 안 링크는 호출부에서 걸러냄 */ +const LINK_PATTERN = /!?\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g; + +/** + * 검사 결과 한 건 + * + * @typedef {{level: "error" | "warning", file: string, message: string}} WikiLintIssue + */ + +/** + * 디렉터리 아래 마크다운 파일 전체를 찾음 + * + * @param {string} dir - 탐색할 디렉터리 + * @return {string[]} 마크다운 파일 절대 경로 목록 + */ +function listMarkdownFiles(dir) { + return readdirSync(dir).flatMap((name) => { + const path = join(dir, name); + if (statSync(path).isDirectory()) return listMarkdownFiles(path); + return name.endsWith(".md") ? [path] : []; + }); +} + +/** + * 문서를 frontmatter와 본문으로 나눔 + * + * @param {string} text - 문서 전체 + * @return {{frontmatter: string | null, body: string}} frontmatter가 없으면 null + */ +function splitFrontmatter(text) { + const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text); + if (!match) return {frontmatter: null, body: text}; + return {frontmatter: match[1], body: text.slice(match[0].length)}; +} + +/** + * 코드 블록과 인라인 코드를 지운 본문. 예시로 적은 링크를 검사하지 않기 위함 + * + * @param {string} body - 문서 본문 + * @return {string} 코드를 지운 본문 + */ +function stripCode(body) { + return body.replace(/```[\s\S]*?```/g, "").replace(/`[^`\n]*`/g, ""); +} + +/** + * OKF v0.2 시각 표기. UTC 오프셋이 명시된 ISO 8601 datetime + * + * 날짜만 쓰면 타임존마다 다른 시각을 가리켜, 같은 문서가 어디서는 만료되고 어디서는 유효해짐 + */ +const OKF_DATETIME_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/; + +/** 시각 형식 오류 안내 문구 */ +const DATETIME_HINT = "`2026-12-31T00:00:00Z`처럼 UTC 오프셋을 붙인 ISO 8601 시각이어야 함"; + +/** + * 값이 오프셋을 명시한 ISO 8601 datetime 문자열이면 Date로 바꿈 + * + * @param {unknown} value - frontmatter 값 + * @return {Date | null} 형식이 맞지 않거나 해석할 수 없으면 null + */ +function toDate(value) { + if (typeof value !== "string" || !OKF_DATETIME_PATTERN.test(value)) return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} + +/** + * 문서 안 링크가 가리키는 로컬 경로 + * + * `/`로 시작하면 번들 루트 기준, 그 밖은 문서 위치 기준이다(OKF 6.2). + * + * @param {string} target - 링크 대상 + * @param {string} file - 링크가 있는 문서 + * @param {string} bundleRoot - 번들 루트 + * @return {string | null} 외부 링크나 문서 내 앵커면 null + */ +function resolveLocalTarget(target, file, bundleRoot) { + if (/^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith("#")) return null; + const path = decodeURI(target.split("#")[0]); + if (!path) return null; + return path.startsWith("/") ? join(bundleRoot, path) : resolve(dirname(file), path); +} + +/** + * 개념 문서 frontmatter 검사 + * + * @param {Record} data - 해석한 frontmatter + * @param {string} file - 문서 경로 + * @param {string} bundleRoot - 번들 루트 + * @param {Date} now - 기준 시각 + * @return {{level: "error" | "warning", message: string}[]} 발견한 문제 + */ +function checkConcept(data, file, bundleRoot, now) { + const problems = []; + const error = (message) => problems.push({level: "error", message}); + + for (const key of REQUIRED_KEYS) { + if (data[key] === undefined || data[key] === null || data[key] === "") { + error(`frontmatter에 \`${key}\`가 없음`); + } + } + + if (data.status !== undefined && !STATUS_VALUES.has(data.status)) { + error(`\`status\`는 draft, stable, deprecated 중 하나여야 함: ${data.status}`); + } + + if (data.sources !== undefined) { + if (!Array.isArray(data.sources) || data.sources.length === 0) { + error("`sources`는 항목이 하나 이상인 목록이어야 함"); + } else { + data.sources.forEach((source, index) => { + const resource = source?.resource; + if (typeof resource !== "string" || !resource) { + error(`\`sources[${index}].resource\`가 없음`); + return; + } + const local = resolveLocalTarget(resource, file, bundleRoot); + if (local && !existsSync(local)) { + error(`\`sources[${index}].resource\` 경로가 없음: ${resource}`); + } + }); + } + } + + if (data.generated !== undefined) { + const {by, at} = data.generated ?? {}; + if (typeof by !== "string" || !ACTOR_PATTERN.test(by)) { + error("`generated.by`는 `<도구>/<버전>` 형식이어야 함"); + } + if (!toDate(at)) error(`\`generated.at\`은 ${DATETIME_HINT}`); + } + + const verified = data.verified === undefined ? [] : [data.verified].flat(); + verified.forEach((entry, index) => { + if (typeof entry?.by !== "string" || !ACTOR_PATTERN.test(entry.by)) { + error(`\`verified[${index}].by\`는 \`human:\`, \`process:\`, \`<도구>/<버전>\` 중 하나여야 함`); + } + if (!toDate(entry?.at)) error(`\`verified[${index}].at\`은 ${DATETIME_HINT}`); + }); + + // stable은 사람이 확인한 문서에만 붙임. 기계 확인만으로 올리면 신뢰 단계가 섞임 + if (data.status === "stable" && !verified.some((entry) => String(entry?.by).startsWith("human:"))) { + error("`status: stable`은 `verified`에 `human:` 항목이 있어야 함"); + } + + if (data.stale_after !== undefined) { + const staleAfter = toDate(data.stale_after); + if (!staleAfter) { + error(`\`stale_after\`는 ${DATETIME_HINT}`); + } else if (data.status !== "deprecated") { + const daysLeft = (staleAfter.getTime() - now.getTime()) / (24 * 60 * 60 * 1000); + if (daysLeft <= 0) { + error(`\`stale_after\`(${toIsoDate(staleAfter)})가 지남. 내용을 다시 확인하고 날짜를 갱신해야 함`); + } else if (daysLeft <= STALE_WARNING_DAYS) { + problems.push({level: "warning", message: `\`stale_after\`(${toIsoDate(staleAfter)})까지 ${Math.ceil(daysLeft)}일 남음`}); + } + } + } + + return problems; +} + +/** + * Date를 `YYYY-MM-DD`로 표기 + * + * @param {Date} date - 날짜 + * @return {string} ISO 날짜 + */ +function toIsoDate(date) { + return date.toISOString().slice(0, 10); +} + +/** + * `log.md` 검사. 날짜 제목이 `YYYY-MM-DD`이고 최신순인지 봄 + * + * @param {string} text - log.md 내용 + * @return {string[]} 발견한 문제 + */ +function checkLog(text) { + const problems = []; + const headings = [...stripCode(text).matchAll(/^##\s+(.+)$/gm)].map((match) => match[1].trim()); + + headings.forEach((heading) => { + if (!/^\d{4}-\d{2}-\d{2}$/.test(heading)) problems.push(`날짜 제목은 \`## YYYY-MM-DD\` 형식이어야 함: ${heading}`); + }); + + for (let i = 1; i < headings.length; i += 1) { + if (headings[i] >= headings[i - 1]) { + problems.push(`날짜 제목은 최신순이어야 함: ${headings[i - 1]} 다음에 ${headings[i]}`); + } + } + + return problems; +} + +/** + * 위키 번들 전체 검사 + * + * @param {string} bundleRoot - 번들 루트(`wiki/`) + * @param {{now?: Date}} [options] - 기준 시각. 테스트에서 고정할 때 씀 + * @return {WikiLintIssue[]} 발견한 문제 + */ +export function lintWiki(bundleRoot, {now = new Date()} = {}) { + /** @type {WikiLintIssue[]} */ + const issues = []; + const report = (level, file, message) => issues.push({level, file: relative(bundleRoot, file).split(sep).join("/"), message}); + + if (!existsSync(bundleRoot)) { + report("error", bundleRoot, "위키 디렉터리가 없음"); + return issues; + } + + const files = listMarkdownFiles(bundleRoot); + /** 각 문서가 링크한 로컬 경로. index 누락 검사에 씀 */ + const linkedFrom = new Map(); + + for (const file of files) { + const name = file.split(sep).pop(); + const text = readFileSync(file, "utf8"); + const {frontmatter, body} = splitFrontmatter(text); + + if (!RESERVED_FILES.has(name)) { + if (frontmatter === null) { + report("error", file, "YAML frontmatter가 없음"); + } else { + let data; + try { + data = parse(frontmatter); + } catch (parseError) { + report("error", file, `frontmatter YAML 해석 실패: ${parseError.message}`); + } + if (data && typeof data === "object") { + checkConcept(data, file, bundleRoot, now).forEach(({level, message}) => report(level, file, message)); + } else if (data !== undefined) { + report("error", file, "frontmatter가 키-값 형식이 아님"); + } + } + } + + if (name === "log.md") checkLog(text).forEach((message) => report("error", file, message)); + + const targets = []; + for (const match of stripCode(body).matchAll(LINK_PATTERN)) { + const local = resolveLocalTarget(match[1], file, bundleRoot); + if (!local) continue; + if (!existsSync(local)) { + report("error", file, `깨진 링크: ${match[1]}`); + continue; + } + targets.push(resolve(local)); + } + linkedFrom.set(resolve(file), targets); + } + + // 각 문서는 같은 디렉터리 index.md에서, 하위 index.md는 상위 index.md에서 찾을 수 있어야 함 + for (const file of files) { + const absolute = resolve(file); + const dir = dirname(absolute); + const name = absolute.split(sep).pop(); + if (name === "log.md" || (name === "index.md" && dir === resolve(bundleRoot))) continue; + + const parentIndex = name === "index.md" ? join(dirname(dir), "index.md") : join(dir, "index.md"); + if (!existsSync(parentIndex)) { + report("error", file, `${relative(bundleRoot, parentIndex).split(sep).join("/")}가 없어 이 문서를 찾을 수 없음`); + continue; + } + if (!(linkedFrom.get(resolve(parentIndex)) ?? []).includes(absolute)) { + report("error", file, `${relative(bundleRoot, parentIndex).split(sep).join("/")}에 이 문서 링크가 없음`); + } + } + + return issues; +} + +/** CLI 진입점. 오류가 하나라도 있으면 종료 코드 1 */ +function main() { + const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + const bundleRoot = resolve(repoRoot, process.argv[2] ?? "wiki"); + const issues = lintWiki(bundleRoot); + + for (const {level, file, message} of issues) { + const line = `${level === "error" ? "✖" : "⚠"} ${file}: ${message}`; + if (level === "error") console.error(line); + else console.warn(line); + } + + const errorCount = issues.filter((issue) => issue.level === "error").length; + const warningCount = issues.length - errorCount; + console.log(`위키 검사: 오류 ${errorCount}건, 경고 ${warningCount}건`); + if (errorCount > 0) process.exitCode = 1; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(); diff --git a/scripts/wiki-lint.test.mjs b/scripts/wiki-lint.test.mjs new file mode 100644 index 0000000..1c5db60 --- /dev/null +++ b/scripts/wiki-lint.test.mjs @@ -0,0 +1,137 @@ +import assert from "node:assert/strict"; +import {mkdirSync, mkdtempSync, rmSync, writeFileSync} from "node:fs"; +import {tmpdir} from "node:os"; +import {dirname, join} from "node:path"; +import {afterEach, beforeEach, describe, it} from "node:test"; +import {lintWiki} from "./wiki-lint.mjs"; + +/** 테스트 기준 시각 */ +const NOW = new Date("2026-10-02T00:00:00Z"); + +/** 검사를 통과하는 개념 문서 frontmatter */ +const VALID_FRONTMATTER = `--- +type: Architecture +title: 예시 +description: 예시 문서 +status: draft +sources: + - resource: ../code.ts +generated: { by: claude-code/claude-opus-5-5, at: 2026-10-02T00:00:00Z } +stale_after: 2027-01-01T00:00:00Z +--- +`; + +describe("lintWiki", () => { + let root; + let bundle; + + /** + * 번들 안에 파일 작성 + * + * @param {string} path - 번들 루트 기준 경로 + * @param {string} content - 파일 내용 + */ + const write = (path, content) => { + const file = join(bundle, path); + mkdirSync(dirname(file), {recursive: true}); + writeFileSync(file, content); + }; + + /** + * 오류 메시지만 모음 + * + * @return {string[]} `파일: 메시지` 목록 + */ + const errors = () => lintWiki(bundle, {now: NOW}) + .filter((issue) => issue.level === "error") + .map((issue) => `${issue.file}: ${issue.message}`); + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "wiki-lint-")); + bundle = join(root, "wiki"); + writeFileSync(join(root, "code.ts"), ""); + write("index.md", "# 위키\n\n- [예시](concept.md)\n"); + write("log.md", "# 로그\n\n## 2026-10-02\n\n- 생성\n\n## 2026-09-30\n\n- 이전\n"); + write("concept.md", `${VALID_FRONTMATTER}\n# 본문\n`); + }); + + afterEach(() => rmSync(root, {recursive: true, force: true})); + + it("규칙을 지킨 번들은 오류가 없음", () => { + assert.deepEqual(errors(), []); + }); + + it("frontmatter 필수 키 누락을 잡음", () => { + write("concept.md", "---\ntype: Architecture\n---\n# 본문\n"); + const found = errors(); + for (const key of ["title", "description", "status", "sources", "generated", "stale_after"]) { + assert.ok(found.some((message) => message.includes(`\`${key}\``)), key); + } + }); + + it("frontmatter 없는 개념 문서를 잡음", () => { + write("concept.md", "# 본문\n"); + assert.deepEqual(errors(), ["concept.md: YAML frontmatter가 없음"]); + }); + + it("만료된 stale_after를 오류로, 임박한 stale_after를 경고로 봄", () => { + write("concept.md", VALID_FRONTMATTER.replace("2027-01-01T", "2026-10-01T")); + assert.ok(errors().some((message) => message.includes("stale_after") && message.includes("지남"))); + + write("concept.md", VALID_FRONTMATTER.replace("2027-01-01T", "2026-10-10T")); + const warnings = lintWiki(bundle, {now: NOW}).filter((issue) => issue.level === "warning"); + assert.equal(warnings.length, 1); + }); + + it("UTC 오프셋 없는 시각을 잡음", () => { + write("concept.md", VALID_FRONTMATTER + .replace("2027-01-01T00:00:00Z", "2027-01-01") + .replace("at: 2026-10-02T00:00:00Z", "at: 2026-10-02T00:00:00")); + const found = errors(); + assert.ok(found.some((message) => message.includes("`stale_after`") && message.includes("UTC 오프셋"))); + assert.ok(found.some((message) => message.includes("`generated.at`") && message.includes("UTC 오프셋"))); + + write("concept.md", VALID_FRONTMATTER.replace("2027-01-01T00:00:00Z", "2027-01-01T09:00:00+09:00")); + assert.deepEqual(errors(), []); + }); + + it("deprecated 문서는 stale_after 만료를 보지 않음", () => { + write("concept.md", VALID_FRONTMATTER.replace("2027-01-01T", "2026-01-01T").replace("draft", "deprecated")); + assert.deepEqual(errors(), []); + }); + + it("사람 확인 없는 stable을 잡음", () => { + write("concept.md", VALID_FRONTMATTER.replace("draft", "stable")); + assert.ok(errors().some((message) => message.includes("human:"))); + + write("concept.md", VALID_FRONTMATTER + .replace("draft", "stable") + .replace("stale_after", "verified:\n - { by: human:chucoding, at: 2026-10-02T00:00:00Z }\nstale_after")); + assert.deepEqual(errors(), []); + }); + + it("없는 source 경로를 잡음", () => { + write("concept.md", VALID_FRONTMATTER.replace("../code.ts", "../missing.ts")); + assert.ok(errors().some((message) => message.includes("missing.ts"))); + }); + + it("깨진 상대 링크와 번들 절대 링크를 잡고 코드 블록 안 링크는 무시함", () => { + write("concept.md", `${VALID_FRONTMATTER}\n[없음](nope.md) [절대](/nope.md)\n\n\`\`\`md\n[예시](ignored.md)\n\`\`\`\n`); + assert.deepEqual(errors(), ["concept.md: 깨진 링크: nope.md", "concept.md: 깨진 링크: /nope.md"]); + }); + + it("index.md에서 찾을 수 없는 문서를 잡음", () => { + write("orphan.md", `${VALID_FRONTMATTER}\n# 고아\n`); + write("sub/child.md", `${VALID_FRONTMATTER.replace("../code.ts", "../../code.ts")}\n# 자식\n`); + const found = errors(); + assert.ok(found.includes("orphan.md: index.md에 이 문서 링크가 없음")); + assert.ok(found.includes("sub/child.md: sub/index.md가 없어 이 문서를 찾을 수 없음")); + }); + + it("log.md 날짜 형식과 순서를 검사함", () => { + write("log.md", "# 로그\n\n## 2026-09-30\n\n## 2026-10-02\n\n## 어제\n"); + const found = errors(); + assert.ok(found.some((message) => message.includes("최신순"))); + assert.ok(found.some((message) => message.includes("어제"))); + }); +}); diff --git a/wiki/architecture/ai-call-guard.md b/wiki/architecture/ai-call-guard.md new file mode 100644 index 0000000..f303ab8 --- /dev/null +++ b/wiki/architecture/ai-call-guard.md @@ -0,0 +1,41 @@ +--- +type: Security Boundary +title: 공개 AI 함수 호출 경계 +description: 랜딩 데모 때문에 공개로 열린 AI 함수의 호출자별 한도와 비로그인 전체 상한 +tags: [ai, security, cost] +status: draft +sources: + - resource: ../../functions/src/ai-guard.ts + - resource: ../../functions/src/openai.ts + - resource: ../../functions/src/translateFlashcards.ts +generated: { by: claude-code/claude-opus-5-5, at: 2026-10-02T00:00:00Z } +stale_after: 2027-01-02T00:00:00Z +--- + +# 왜 필요한가 + +랜딩 데모는 비로그인 방문자가 써야 해서 `openaiChatCompletions`와 `translateFlashcards`는 `invoker: "public"`을 유지합니다. 함수 URL은 빌드 산출물에 문자열로 들어가고 형식만 알면 조합도 되므로, 주소를 감추는 방식으로는 보호되지 않습니다. 그래서 모델을 호출하기 전에 `consumeAiQuota`가 서버 소유 카운터로 막습니다. + +# 두 겹의 경계 + +| 경계 | 키 | 상한 | +|------|------|------| +| 호출자별 일일 한도 | 로그인은 uid, 비로그인은 IP 해시 | 비로그인 30, Free 100, Pro 300 | +| 비로그인 전체 일일 상한 | 단일 카운터 | 500 | + +- 비로그인 키로 쓰는 IP는 `X-Forwarded-For`에서 읽어 위조할 수 있습니다. 위조하면 호출자별 한도는 우회되지만 전체 상한은 키와 무관해 우회되지 않습니다. 하루 모델 비용의 천장이 전체 상한입니다. +- 로그인 호출에는 전체 상한을 걸지 않습니다. 걸면 비로그인 호출을 쏟아부어 로그인 사용자의 카드 생성까지 멈출 수 있습니다. +- 원본 IP는 저장하지 않고 해시만 문서 ID로 씁니다. +- 날짜 경계는 KST 기준입니다. + +# 입력 길이 상한 + +| 상수 | 값 | 적용 | +|------|------|------| +| `MAX_FLASHCARD_PROMPT_CHARS` | 30,000자 | 카드 생성 프롬프트 본문. 넘으면 거부하지 않고 자름 | +| `MAX_TRANSLATE_CARDS` | 200장 | 번역 요청 한 건 | +| `MAX_TRANSLATE_PROMPT_CHARS` | 120,000자 | 번역 프롬프트 본문 | + +# 남은 것 + +위조 요청을 말단에서 거부하려면 App Check가 필요합니다. Firebase 콘솔의 reCAPTCHA 등록과 enforcement 설정이 함께 있어야 해서 이 경계와 별도 작업입니다. diff --git a/wiki/architecture/firestore-data-boundary.md b/wiki/architecture/firestore-data-boundary.md new file mode 100644 index 0000000..9089498 --- /dev/null +++ b/wiki/architecture/firestore-data-boundary.md @@ -0,0 +1,39 @@ +--- +type: Security Boundary +title: Firestore 데이터 경계 +description: 클라이언트가 쓸 수 있는 users 필드와 Admin SDK만 쓰는 서버 전용 컬렉션을 나눈 이유 +tags: [firestore, security, rules] +status: draft +sources: + - resource: ../../firestore.rules + - resource: ../../functions/src/ai-guard.ts + - resource: ../../functions/src/regenerateQuestion.ts + - id: pr-81 + title: "fix(77): users 문서 쓰기를 소유 필드로 한정하고 사용량 카운터 분리" + resource: https://github.com/chucoding/code-recall/pull/81 +generated: { by: claude-code/claude-opus-5-5, at: 2026-10-02T00:00:00Z } +stale_after: 2027-01-02T00:00:00Z +--- + +# 규칙 요약 + +- `users/{uid}`는 본인만 읽고, 쓰기는 `userWritableFields()` 목록 안의 필드만 허용합니다. +- 구독 등급(`subscriptionTier`, `subscriptionPeriodEnd`, `stripeCustomerId`)은 서버가 한도를 고를 때 쓰는 값이라 Stripe 웹훅만 씁니다. +- 갱신 검사는 이번 쓰기가 바꾸는 키(`affectedKeys`)만 봅니다. 문서 전체를 검사하면 과거 버전이 남긴 값 하나 때문에 그 사용자의 모든 쓰기가 막히기 때문입니다. +- `updatedAt`이 문자열과 timestamp를 모두 받는 이유는 PWA 서비스 워커 캐시로 과거 번들이 배포 뒤에도 남기 때문입니다. + +# 서버 전용 컬렉션 + +규칙에 `match` 블록이 없으면 기본 거부라 Admin SDK만 읽고 씁니다. + +| 컬렉션 | 용도 | +|------|------| +| `aiUsage` | AI 호출 일일 카운터. [공개 AI 함수 호출 경계](ai-call-guard.md) | +| `demoRegenerateCounts` | 비로그인 데모의 질문 재생성 카운터 | +| `flashcardPregeneration` | 사용자별 사전 생성 완료 기록 | + +`regenerateCounts`는 남은 횟수 표시에 필요해 본인 읽기만 열고 쓰기는 열지 않습니다. + +# 사용량을 users 문서에 두지 않는 이유 + +`users/{uid}`는 본인 삭제가 열려 있습니다. 카운터를 거기 두면 쓸 수 있는 필드를 좁혀도 문서를 지웠다가 다시 만드는 것만으로 한도가 초기화됩니다. 그래서 사용량은 사용자가 손댈 수 없는 컬렉션에 둡니다. 새 서버 전용 컬렉션을 만들 때도 `firestore.rules`에 `match` 블록을 추가하지 않습니다. diff --git a/wiki/architecture/index.md b/wiki/architecture/index.md new file mode 100644 index 0000000..d62e254 --- /dev/null +++ b/wiki/architecture/index.md @@ -0,0 +1,5 @@ +# 아키텍처 + +- [시스템 구성](system-overview.md): 프론트엔드, Cloud Functions, Firestore가 나눠 맡는 일과 스케줄 작업 목록 +- [Firestore 데이터 경계](firestore-data-boundary.md): 클라이언트가 쓸 수 있는 필드와 서버 전용 컬렉션을 나눈 이유 +- [공개 AI 함수 호출 경계](ai-call-guard.md): 비로그인 방문자에게 열린 AI 함수의 비용 상한 diff --git a/wiki/architecture/system-overview.md b/wiki/architecture/system-overview.md new file mode 100644 index 0000000..1d1e938 --- /dev/null +++ b/wiki/architecture/system-overview.md @@ -0,0 +1,47 @@ +--- +type: Architecture +title: 시스템 구성 +description: 프론트엔드, Cloud Functions, Firestore가 나눠 맡는 일과 스케줄 작업 목록 +tags: [architecture, firebase, functions] +status: draft +sources: + - resource: ../../functions/src/index.ts + - resource: ../../firebase.json + - resource: ../../functions/src/schedule.ts + - resource: ../../functions/src/trending.ts + - resource: ../../functions/src/flashcard-pregeneration.ts +generated: { by: claude-code/claude-opus-5-5, at: 2026-10-02T00:00:00Z } +stale_after: 2027-01-02T00:00:00Z +--- + +# 구성 + +```mermaid +flowchart LR + Browser["app (React PWA)"] -->|HTTP onRequest| Functions["functions (Cloud Functions v2)"] + Browser -->|읽기, 소유 필드 쓰기| Firestore[(Firestore)] + Functions -->|Admin SDK| Firestore + Functions --> GitHub[GitHub API] + Functions --> OpenAI[OpenAI] + Scheduler[Cloud Scheduler] --> Functions + Functions -->|FCM| Browser +``` + +| 영역 | 경로 | 맡는 일 | +|------|------|------| +| 프론트엔드 | `app` | 랜딩(`/`)과 앱(`/app`)을 별도 HTML 진입점으로 빌드해 Firebase Hosting에 배포 | +| 서버 | `functions` | GitHub 조회, AI 카드 생성과 번역, 질문 재생성, 스케줄 작업. 리전은 `asia-northeast3` | +| 데이터 | `firestore.rules` | 클라이언트 접근 경계. 자세한 내용은 [Firestore 데이터 경계](firestore-data-boundary.md) | + +`functions/src/index.ts`가 `setGlobalOptions({maxInstances: 10})`로 함수별 최대 컨테이너 수를 묶어 비용을 제한합니다. Stripe 결제 함수는 아직 export하지 않습니다. + +# 스케줄 작업 + +| 함수 | 주기 | 하는 일 | +|------|------|------| +| `sendDaily8amPush` | 매시 정각 | 사용자 타임존의 현재 시가 `preferredPushHour`(기본 8)와 같은 사용자에게 FCM 발송. 알림 클릭 시 `/app`을 연다 | +| `scheduleFlashcardPregeneration` | 매시 정각 | 로컬 0시부터 6시 사이 사용자의 오늘 덱 생성 태스크 등록. [오늘의 플래시카드 사전 생성](../flashcard/pregeneration.md) | +| `refreshTrendingRepos` | 매일 09시 KST | GitHub Trending 10곳 수집, 랜딩 데모 카드 사전 생성 | +| `cleanupDeletedUsers` | 매일 12시 KST | 오늘 이전의 탈퇴 기록 삭제. 탈퇴 당일만 재가입을 막음 | + +매시 정각 작업이 전 타임존을 하루 24회로 덮는 방식이라, 사용자별 시각 판정은 각 함수가 타임존을 직접 계산합니다. diff --git a/wiki/decisions/index.md b/wiki/decisions/index.md new file mode 100644 index 0000000..606902b --- /dev/null +++ b/wiki/decisions/index.md @@ -0,0 +1,3 @@ +# 결정 기록 + +- [위키를 코드 저장소에 두는 결정](wiki-location.md): LLM Wiki를 별도 저장소가 아닌 이 저장소 `wiki/`에 둔 이유 diff --git a/wiki/decisions/wiki-location.md b/wiki/decisions/wiki-location.md new file mode 100644 index 0000000..c38457a --- /dev/null +++ b/wiki/decisions/wiki-location.md @@ -0,0 +1,56 @@ +--- +type: Decision +title: 위키를 코드 저장소에 두는 결정 +description: LLM Wiki를 별도 저장소가 아닌 이 저장소 wiki/에 OKF v0.2 번들로 둔 이유와 감수한 점 +tags: [wiki, decision, agents] +status: draft +sources: + - id: issue-94 + title: LLM Wiki 구축 + resource: https://github.com/chucoding/code-recall/issues/94 + - id: karpathy-llm-wiki + title: Karpathy, LLM Wiki + resource: https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f + - id: okf-spec + title: Open Knowledge Format v0.2 + resource: https://github.com/GoogleCloudPlatform/open-knowledge-format + - id: openwiki + title: LangChain OpenWiki + resource: https://github.com/langchain-ai/openwiki + - id: docs-as-code-topologies + title: Docs-as-code topologies + resource: https://passo.uno/docs-as-code-topologies/ +generated: { by: claude-code/claude-opus-5-5, at: 2026-10-02T00:00:00Z } +stale_after: 2027-04-02T00:00:00Z +--- + +# 결정 + +LLM Wiki를 이 저장소의 `wiki/` 디렉터리에 OKF v0.2 번들로 둡니다. 에이전트 규칙(카파시 패턴의 schema 계층)은 루트 `AGENTS.md`가 정본이고, `CLAUDE.md`는 그 파일을 불러오기만 합니다. `AGENTS.md`에는 항상 지킬 원칙과 이 저장소 전용 값만 두고, 위키를 쓸 때만 필요한 OKF 작성 절차는 다른 프로젝트에서도 쓸 수 있게 개인 스킬 `chucoding:okf-wiki`로 분리합니다. `AGENTS.md`는 위키와 관계없는 작업에서도 매번 읽히기 때문입니다. + +# 검토한 선택지 + +| 선택지 | 장점 | 단점 | +|------|------|------| +| 이 저장소 `wiki/` (채택) | 코드와 문서를 한 PR에서 고쳐 어긋남을 근원에서 막음. 에이전트가 파일을 직접 읽어 MCP가 필요 없음 | public 저장소라 위키 내용이 공개됨 | +| 별도 private 저장소 | 비공개 기획과 운영 지식을 넣을 수 있음 | 코드 변경과 문서 갱신이 다른 PR로 갈라짐. 에이전트 연결에 경로 지정이나 MCP가 필요 | +| 혼합 | 공개와 비공개를 나눌 수 있음 | 처음부터 관리할 정본이 둘 | + +# 근거 + +- 카파시 원문은 위키를 "마크다운 파일로 된 git 저장소"로 설명하고 개인 지식 베이스를 전제합니다. 위치를 강제하지 않습니다. +- OKF v0.2는 번들의 위치를 정하지 않고, 상대 경로와 번들 루트 기준 경로를 모두 링크로 인정합니다. 코드 파일을 `sources`로 가리킬 수 있습니다. +- OpenWiki는 코드 저장소 안 `openwiki/`에 문서와 근거 메타데이터를 두는 것이 기본입니다. 나중에 3단계로 붙여도 같은 저장소에서 동작합니다. +- 같은 저장소에 문서를 두는 sidecar 방식은 문서가 코드와 함께 버전 관리되어, 변경이 잦은 초기 제품에 맞습니다. +- 이 프로젝트는 혼자 운영해 기획자가 따로 관리하는 정책 문서가 없습니다. 별도 저장소로 나눌 이유였던 "기획자 소유 지식"이 아직 없습니다. + +# 감수한 점과 지킬 규칙 + +- 저장소가 public이므로 비밀 값, 비용과 매출 수치, 사용자 개인정보, 운영 계정 정보는 위키에 쓰지 않습니다. 이런 지식이 필요해지면 그때 별도 private 저장소를 만들고 이 결정을 갱신합니다. +- 학습 기록(TIL)은 이 위키가 아니라 `today-i-learned` 저장소에 둡니다. 이 위키는 CodeRecall 제품 지식만 다룹니다. + +# 다시 검토할 시점 + +- 비공개로 다뤄야 할 정책이나 운영 지식이 생길 때 +- 저장소가 여러 개로 나뉠 때. 그때는 위키 MCP 서버와 코드 그래프 도입을 함께 검토합니다 +- 문서가 수백 개를 넘어 `index.md`만으로 찾기 어려울 때. 그때는 qmd 검색을 붙입니다 diff --git a/wiki/flashcard/deck-size-budget.md b/wiki/flashcard/deck-size-budget.md new file mode 100644 index 0000000..255cb62 --- /dev/null +++ b/wiki/flashcard/deck-size-budget.md @@ -0,0 +1,39 @@ +--- +type: Data Rule +title: 덱 문서 크기 예산 +description: 언어별 덱 두 개를 Firestore 문서 상한 1MB 안에 함께 넣기 위한 크기 규칙 +tags: [flashcard, firestore, limits] +status: draft +sources: + - resource: ../../functions/src/flashcard-deck-size.ts + - resource: ../../app/src/features/flashcard/lib/deck-size.ts + - id: pr-83 + title: "fix(82): 카드 덱을 문서 크기 예산 안으로 줄여 저장" + resource: https://github.com/chucoding/code-recall/pull/83 +generated: { by: claude-code/claude-opus-5-5, at: 2026-10-02T00:00:00Z } +stale_after: 2027-01-02T00:00:00Z +--- + +# 규칙 + +`users/{uid}/flashcards/{YYYY-MM-DD}` 문서 하나에 `data_ko`와 `data_en` 두 덱이 함께 들어갑니다. 카드마다 원문 diff를 담으면 큰 커밋 하나로 문서가 Firestore 상한 1MB를 넘어 저장이 실패합니다. + +| 상수 | 값 | 이유 | +|------|------|------| +| `MAX_FLASHCARD_DOC_BYTES` | 900,000 | 1MB 대비 필드 이름과 인덱스 여유 | +| 덱 하나의 예산 | 450,000 | 두 언어 덱이 한 문서에 들어가므로 절반 | +| `MAX_RAW_DIFF_CHARS` | 30,000 | 생성 프롬프트 본문 상한과 같음 | +| `MIN_RAW_DIFF_CHARS` | 1,000 | 이보다 짧게 줄여야 하면 diff를 저장하지 않음 | +| `MAX_FILES_PER_CARD` | 20 | 파일 보기 탭 목록에만 쓰임 | + +# 줄이는 순서 + +1. `files[].patch`는 `rawDiff`와 겹쳐 항상 뺍니다. +2. diff 길이를 30,000자에서 시작해 절반씩 줄이며 예산에 들어오는 첫 길이를 씁니다. +3. 1,000자까지 줄여도 넘치면 diff 없이 저장합니다. + +diff를 자를 때 코드 블록 중간이면 블록을 닫고 `…(truncated)`를 붙여 마크다운이 깨지지 않게 합니다. + +# 주의 + +같은 규칙이 앱(`app/src/features/flashcard/lib/deck-size.ts`)과 서버(`functions/src/flashcard-deck-size.ts`)에 따로 있습니다. 두 경로가 같은 문서에 언어별 덱을 나눠 쓰므로 한쪽 상한을 바꾸면 다른 쪽도 같은 PR에서 바꿉니다. diff --git a/wiki/flashcard/generation-paths.md b/wiki/flashcard/generation-paths.md new file mode 100644 index 0000000..7b4ec0f --- /dev/null +++ b/wiki/flashcard/generation-paths.md @@ -0,0 +1,49 @@ +--- +type: Architecture +title: 카드 생성 경로 +description: 플래시카드를 만드는 세 경로와 공용 모듈로 묶은 단계 +tags: [flashcard, ai, github] +status: draft +sources: + - resource: ../../functions/src/flashcard-generation.ts + - resource: ../../functions/src/openai.ts + - resource: ../../functions/src/demo-flashcards.ts + - resource: ../../functions/src/flashcard-pregeneration.ts + - resource: ../../app/src/features/flashcard/model/useTodayFlashcards.ts +generated: { by: claude-code/claude-opus-5-5, at: 2026-10-02T00:00:00Z } +stale_after: 2027-01-02T00:00:00Z +--- + +# 세 경로 + +| 경로 | 진입점 | 실행 시점 | 저장 위치 | +|------|------|------|------| +| 실시간 생성 | 앱 `useTodayFlashcards`가 `openaiChatCompletions` 호출 | 사용자가 앱을 열었는데 오늘 덱이 없을 때 | `users/{uid}/flashcards/{YYYY-MM-DD}` | +| 사용자 사전 생성 | `pregenerateUserFlashcards` | 사용자 타임존 새벽 | 같은 문서 | +| 랜딩 데모 사전 생성 | `refreshTrendingRepos`가 `pregenerateDemoFlashcards` 호출 | 매일 09시 KST | `demoFlashcards` | + +세 경로는 실행 시점과 커밋 선택, 저장 위치가 달라 그 부분은 각자 둡니다. GitHub 조회, diff 구성, AI 호출처럼 같아야 하는 단계만 `functions/src/flashcard-generation.ts`에 모았습니다. 따로 두면 모델이나 프롬프트를 바꿀 때 한 경로만 바뀐 채 남기 때문입니다. + +# 공용 단계 + +```mermaid +flowchart LR + A[fetchGitHub / fetchCommitDetail] --> B[formatCommitDiff] + B --> C[requestFlashcardCompletion] + C --> D[parseFlashcardItems] +``` + +- `formatCommitDiff`는 앱 `formatCodeDiff`와 같은 마크다운 형식을 만듭니다. 카드 뒷면 Diff 보기와 AI 입력에 함께 씁니다. +- `requestFlashcardCompletion`은 OpenAI Structured Outputs(`FLASHCARD_RESPONSE_SCHEMA`)로 `question`, `answer`, `highlights` 형식을 보장합니다. 이 스키마는 앱 타입과 함께 바꿔야 합니다. +- 추론 강도는 `low`, 출력 상한은 8,192토큰입니다. 실시간 생성은 앱이 응답을 기다리고, 데모 사전 생성은 저장소 10곳을 540초 안에 처리해야 하기 때문입니다. +- GitHub 401은 `GitHubAuthError`로 구분합니다. 토큰 무효는 재시도해도 바뀌지 않아 호출부가 다르게 처리합니다. + +# 앱과 서버가 함께 바꿔야 하는 값 + +app과 functions는 따로 빌드되는 패키지라 코드를 공유하지 못합니다. 아래 값은 양쪽에 복사되어 있어 한쪽만 바꾸면 어긋납니다. + +| 값 | 앱 | 서버 | +|------|------|------| +| 등급별 복습 간격 (Free 1, 7일 전 / Pro 1, 7, 30일 전) | `useTodayFlashcards.ts` | `flashcard-pregeneration.ts` | +| 덱 크기 예산 | `features/flashcard/lib/deck-size.ts` | `flashcard-deck-size.ts`. [덱 문서 크기 예산](deck-size-budget.md) | +| 카드 응답 스키마 | 앱 types | `FLASHCARD_RESPONSE_SCHEMA` | diff --git a/wiki/flashcard/index.md b/wiki/flashcard/index.md new file mode 100644 index 0000000..b7d37dd --- /dev/null +++ b/wiki/flashcard/index.md @@ -0,0 +1,5 @@ +# 플래시카드 + +- [카드 생성 경로](generation-paths.md): 카드를 만드는 세 경로와 공용 모듈로 묶은 단계 +- [오늘의 플래시카드 사전 생성](pregeneration.md): 사용자 자정에 서버가 그날 덱을 미리 만드는 흐름 +- [덱 문서 크기 예산](deck-size-budget.md): 언어별 덱을 Firestore 문서 상한 안에 넣는 규칙 diff --git a/wiki/flashcard/pregeneration.md b/wiki/flashcard/pregeneration.md new file mode 100644 index 0000000..06530cb --- /dev/null +++ b/wiki/flashcard/pregeneration.md @@ -0,0 +1,60 @@ +--- +type: Workflow +title: 오늘의 플래시카드 사전 생성 +description: 사용자 타임존 새벽에 서버가 그날 덱을 미리 만들어 앱 첫 화면 대기를 없애는 흐름 +tags: [flashcard, scheduler, cloud-tasks] +status: draft +sources: + - resource: ../../functions/src/flashcard-pregeneration.ts + - resource: ../../functions/src/timezone.ts + - id: pr-88 + title: "feat(86): 오늘의 플래시카드를 사용자 자정에 서버에서 미리 생성" + resource: https://github.com/chucoding/code-recall/pull/88 +generated: { by: claude-code/claude-opus-5-5, at: 2026-10-02T00:00:00Z } +stale_after: 2027-01-02T00:00:00Z +--- + +# 왜 만들었나 + +앱을 켤 때 브라우저가 저장소와 날짜마다 GitHub 조회와 AI 생성을 순차로 기다려 첫 화면이 느렸습니다. 사용자 타임존의 자정이 지나면 서버가 그날 덱을 미리 만들어 두고, 앱은 그 문서를 읽기만 합니다. 사전 생성이 없거나 실패하면 앱이 기존처럼 [실시간 생성](generation-paths.md)으로 이어갑니다. + +# 흐름 + +```mermaid +sequenceDiagram + participant S as scheduleFlashcardPregeneration (매시 정각) + participant Q as Cloud Tasks 큐 + participant T as pregenerateUserFlashcards + participant F as Firestore + S->>F: users 중 language, timezone, repositories가 있는 사용자 조회 + S->>S: 로컬 0~6시, 미완료, 최근 7일 활동 사용자로 좁힘 + S->>Q: 태스크 등록 (ID: uid-날짜-h시각) + Q->>T: 사용자 한 명씩 실행 (동시 5개) + T->>F: 오늘 덱이 이미 있으면 exists로 기록하고 종료 + T->>T: 저장소 × 복습 간격 병렬 생성 + T->>F: 트랜잭션에서 덱이 없을 때만 저장 + T->>F: flashcardPregeneration에 완료 기록 +``` + +# 설계 판단 + +| 판단 | 이유 | +|------|------| +| 스케줄러와 태스크 두 단계 | 사용자가 늘어도 스케줄러는 대상 선정만 하고, OpenAI 동시 호출은 큐의 동시 실행 상한(5)으로 조절 | +| 대상을 자정 한 시각이 아닌 0시부터 6시 구간으로 잡음 | 스케줄러가 몇 시각 실패해도 사용자가 일어나기 전에 따라잡게 함 | +| 태스크 ID에 시각을 넣음 | 같은 시각의 재시도는 ID가 겹쳐 중복 등록되지 않고, 다음 시각에는 새 ID로 놓친 사용자를 다시 잡음 | +| 최근 7일 활동 사용자만 대상 | 유휴 계정의 AI 비용을 막음. 활동 시각은 Auth의 토큰 갱신 시각(`lastRefreshTime`)을 씀 | +| `timezone`이나 `language`가 없으면 제외 | 새 앱을 한 번도 열지 않은 사용자라 언어를 추정해 틀린 덱을 만들지 않음 | +| 완료 기록 컬렉션을 따로 둠 | 덱 문서만으로는 "커밋이 없어 카드가 없음"과 "아직 안 만듦"을 구분할 수 없음 | +| 저장을 트랜잭션에서 다시 확인 | 생성 중 사용자가 앱을 열어 앱이 먼저 저장할 수 있음 | + +# 완료 사유 + +| 값 | 뜻 | +|------|------| +| `saved` | 덱을 저장함 | +| `exists` | 앱이 먼저 덱을 만들어 둠 | +| `empty` | 해당 날짜 커밋이 없어 만들 카드가 없음 | +| `unavailable` | GitHub 토큰 무효나 설정 없음처럼 재시도해도 바뀌지 않는 문제 | + +일시 오류로 카드를 하나도 못 만들면 완료 기록 없이 예외를 던집니다. 태스크 큐가 최대 3회 재시도하고, 그래도 실패하면 구간 안의 다음 정각에 새 태스크로 다시 시도합니다. 일부 조합만 실패해 덱이 비었을 때도 `empty`로 확정하지 않습니다. 실패한 조합에 커밋이 있었을 수 있기 때문입니다. diff --git a/wiki/index.md b/wiki/index.md new file mode 100644 index 0000000..b18de73 --- /dev/null +++ b/wiki/index.md @@ -0,0 +1,23 @@ +--- +okf_version: "0.2" +--- + +# CodeRecall 위키 + +코드만 읽어서는 알기 어려운 "왜 이렇게 만들었는가"를 모아 두는 지식 위키입니다. 에이전트는 작업 전에 이 목록에서 관련 문서를 먼저 찾고, 작업으로 사실이 바뀌면 같은 PR에서 문서를 고칩니다. 운영 규칙은 저장소 루트의 `AGENTS.md`에 있습니다. + +## 아키텍처 + +- [아키텍처](architecture/index.md): 시스템 구성과 데이터 경계 + +## 플래시카드 + +- [플래시카드](flashcard/index.md): 카드 생성 경로, 서버 사전 생성, 문서 크기 예산 + +## 결정 기록 + +- [결정 기록](decisions/index.md): 되돌리기 어려운 구조 결정과 그 근거 + +## 이력 + +- [log.md](log.md): 위키 변경 이력 diff --git a/wiki/log.md b/wiki/log.md new file mode 100644 index 0000000..68b4d4f --- /dev/null +++ b/wiki/log.md @@ -0,0 +1,15 @@ +# 위키 변경 이력 + +새 항목은 맨 위에 추가합니다. 날짜 제목은 `## YYYY-MM-DD` 형식이고, 항목은 `**Creation**`, `**Update**`, `**Deprecation**` 중 하나로 시작합니다. + +## 2026-10-05 + +- **Update** [위키를 코드 저장소에 두는 결정](decisions/wiki-location.md)에 위키 작성 절차를 개인 스킬로 분리한 내용 추가 (#94) +- **Update** 개념 문서 7건의 `stale_after`를 UTC 오프셋을 붙인 시각으로 변경. OKF v0.2 스펙의 시각 표기 규칙을 따름 (#94) + +## 2026-10-02 + +- **Creation** 위키 번들 골격과 초기 개념 문서 7건 작성 (#94) + - [시스템 구성](architecture/system-overview.md), [Firestore 데이터 경계](architecture/firestore-data-boundary.md), [공개 AI 함수 호출 경계](architecture/ai-call-guard.md) + - [카드 생성 경로](flashcard/generation-paths.md), [오늘의 플래시카드 사전 생성](flashcard/pregeneration.md), [덱 문서 크기 예산](flashcard/deck-size-budget.md) + - [위키를 코드 저장소에 두는 결정](decisions/wiki-location.md)