<?xml version="1.0"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="ko">
	<id>https://s3wiki.yonsei.ac.kr/api.php?action=feedcontributions&amp;feedformat=atom&amp;user=S3Admin</id>
	<title>S3 연구 메모리 - 사용자 기여 [ko]</title>
	<link rel="self" type="application/atom+xml" href="https://s3wiki.yonsei.ac.kr/api.php?action=feedcontributions&amp;feedformat=atom&amp;user=S3Admin"/>
	<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php/%ED%8A%B9%EC%88%98:%EA%B8%B0%EC%97%AC/S3Admin"/>
	<updated>2026-09-08T05:48:19Z</updated>
	<subtitle>사용자 기여</subtitle>
	<generator>MediaWiki 1.43.9</generator>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC/%EB%B2%A1%ED%84%B0%C2%B7%ED%95%98%EC%9D%B4%EB%B8%8C%EB%A6%AC%EB%93%9C_%EA%B2%80%EC%83%89_%EC%84%A4%EA%B3%84&amp;diff=2774</id>
		<title>도움말:S3 연구 메모리/벡터·하이브리드 검색 설계</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC/%EB%B2%A1%ED%84%B0%C2%B7%ED%95%98%EC%9D%B4%EB%B8%8C%EB%A6%AC%EB%93%9C_%EA%B2%80%EC%83%89_%EC%84%A4%EA%B3%84&amp;diff=2774"/>
		<updated>2026-07-20T05:08:47Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 저장소 문서 동기화&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;!-- S3RM managed repository document; edit the Markdown source, not this page. --&amp;gt;&lt;br /&gt;
&#039;&#039;이 페이지는 저장소 문서에서 자동으로 동기화됩니다. 위키에서 직접 편집하지 마세요.&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
=== 문서 상태 ===&lt;br /&gt;
&lt;br /&gt;
이 문서는 구현된 1단계 검색 계층과 이후 확장 경계를 함께 설명합니다. 현재 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에는 교체 가능한 임베딩 제공자, 세 가지 facet projection, copy-on-write exact vector index와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;legacy&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;·&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;hybrid_v1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; mode가 들어 있습니다. 기본 mode와 기본 provider는 계속 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;legacy&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;disabled&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;입니다. 선택적 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;embedding&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; Compose profile에는 이 서버의 Vega GPU에서 확인한 Qwen3 F16 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;llama.cpp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; Vulkan 서비스와 검증 downloader가 들어 있습니다.&lt;br /&gt;
&lt;br /&gt;
recent changes poll, 지속형 checkpoint·tombstone, 별도 sidecar, 여러 MCP 인스턴스가 공유하는 generation, cursor v2와 자동 품질 cutover는 아직 후속 단계입니다. 아래 설계에서 이 항목은 구현 목표로 읽습니다.&lt;br /&gt;
&lt;br /&gt;
=== 결정 요약 ===&lt;br /&gt;
&lt;br /&gt;
* Semantic MediaWiki를 유일한 기준 저장소로 유지합니다.&lt;br /&gt;
* 벡터 인덱스는 후보 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Lesson:&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; ID만 반환하는 파생 데이터로 둡니다.&lt;br /&gt;
* 최종 결과는 항상 MediaWiki 최신 리비전을 다시 읽어 만듭니다.&lt;br /&gt;
* 외부 MCP 도구 열 개와 중앙 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 진입점은 그대로 유지합니다.&lt;br /&gt;
* 전체 텍스트, 의미 유사도, 명시적 relation을 각각 찾은 뒤 결정적인 규칙으로 합칩니다.&lt;br /&gt;
* 1단계에서는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 프로세스 안에서 exact vector search를 사용합니다.&lt;br /&gt;
* 재구축 시간, 메모리 또는 지연 시간이 목표를 넘을 때만 내부 검색 서비스로 옮깁니다.&lt;br /&gt;
* 임베딩 모델, 입력 구성 또는 거리 계산이 바뀌면 새 인덱스 세대를 만듭니다.&lt;br /&gt;
* 의미 검색이 실패해도 전체 텍스트와 relation 검색은 계속 제공합니다.&lt;br /&gt;
벡터 저장 제품은 이 경계를 구현하는 선택 사항입니다. 검색 품질과 운영 규모를 확인하기 전에 제품별 API가 도메인 계층으로 퍼지지 않게 합니다.&lt;br /&gt;
&lt;br /&gt;
=== 1단계 구현 범위 ===&lt;br /&gt;
&lt;br /&gt;
현재 exact index는 다음처럼 제한해서 구현했습니다.&lt;br /&gt;
&lt;br /&gt;
* 서비스 시작 시 단 하나의 백그라운드 작업으로 전체 Lesson snapshot을 읽어 프리웜합니다. MCP Lesson 저장의 dirty hint는 즉시 같은 단일 갱신을 시작합니다. 설정 간격이 지나면 다음 hybrid 요청이 전체 대조를 시작하며, 어느 요청도 refresh 완료를 기다리지 않습니다. 별도 주기 poll은 없습니다.&lt;br /&gt;
* 전체 embedding이 성공했을 때만 새 snapshot을 원자적으로 교체합니다. 한 Lesson만 실패해 성공 항목을 부분 반영하는 재시도 큐는 아직 없습니다.&lt;br /&gt;
* 프로세스마다 active generation 하나를 메모리에 둡니다. 재시작하면 MediaWiki에서 시작한 백그라운드 작업으로 다시 만듭니다.&lt;br /&gt;
* relation, lexical과 semantic 순위를 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;rrf_v2&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 결합합니다. 같은 점수대에서는 검증된 evidence coverage를 작은 prior로 반영하고 record origin이 한 종류로 몰리지 않도록 bounded diversity 보정을 적용합니다.&lt;br /&gt;
* 기존 v1 cursor envelope를 유지하고 mode, ranking version과 generation을 fingerprint에 포함합니다.&lt;br /&gt;
recent changes checkpoint, tombstone, 항목별 재시도, active/rollback generation, cursor v2와 sidecar는 아래에 설명한 확장 단계에서 추가합니다.&lt;br /&gt;
&lt;br /&gt;
=== 목표 ===&lt;br /&gt;
&lt;br /&gt;
다음 문제를 해결합니다.&lt;br /&gt;
&lt;br /&gt;
# 표현이 달라도 증상과 제약이 비슷한 Lesson을 찾습니다.&lt;br /&gt;
# 비슷한 문제에 쓰인 서로 다른 해결 원리를 함께 보여 줍니다.&lt;br /&gt;
# 한국어, 영어와 두 언어가 섞인 메모를 같은 검색 흐름에서 다룹니다.&lt;br /&gt;
# 새 Lesson은 기존 전체 텍스트 검색으로 바로 찾을 수 있게 합니다.&lt;br /&gt;
# 임베딩 제공자와 인덱스 구현을 중단 없이 바꿀 수 있게 합니다.&lt;br /&gt;
# 여러 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 인스턴스로 확장해도 같은 검색 세대를 사용하게 합니다.&lt;br /&gt;
다음 기능은 이 설계의 범위에 넣지 않습니다.&lt;br /&gt;
&lt;br /&gt;
* 벡터 유사도를 사실, 인과관계 또는 relation으로 자동 저장&lt;br /&gt;
* 별도 Lesson 데이터베이스 또는 사용자별 지식 사본&lt;br /&gt;
* 벡터 저장 제품의 범용 API를 MCP 도구로 공개&lt;br /&gt;
* 검색 결과를 근거 없이 요약하거나 해결책으로 확정&lt;br /&gt;
* 다중 테넌트 저장소와 사용자별 비공개 인덱스&lt;br /&gt;
검색 scope는 별도 Lesson이나 데이터베이스가 아닙니다. 서버 설정의 이름 있는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;SearchFilters&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 묶음이며 호출자의 필터와 교집합으로 적용됩니다. 현재 scope는 relevance 범위만 정하고 접근 제어를 대신하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
=== 전체 구조 ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
flowchart LR&lt;br /&gt;
    C[&amp;quot;MCP 클라이언트&amp;quot;] --&amp;gt; M[&amp;quot;중앙 mcp-http&amp;quot;]&lt;br /&gt;
    M --&amp;gt; R[&amp;quot;검색 조정 계층&amp;quot;]&lt;br /&gt;
    R --&amp;gt; L[&amp;quot;MediaWiki 전체 텍스트 후보&amp;quot;]&lt;br /&gt;
    R --&amp;gt; G[&amp;quot;MediaWiki relation 후보&amp;quot;]&lt;br /&gt;
    R --&amp;gt; V[&amp;quot;의미 검색 후보&amp;quot;]&lt;br /&gt;
    L --&amp;gt; F[&amp;quot;후보 결합과 다양성 정렬&amp;quot;]&lt;br /&gt;
    G --&amp;gt; F&lt;br /&gt;
    V --&amp;gt; F&lt;br /&gt;
    F --&amp;gt; H[&amp;quot;MediaWiki 최신 리비전 일괄 조회&amp;quot;]&lt;br /&gt;
    H --&amp;gt; O[&amp;quot;필터·페이지 나누기·출력 제한&amp;quot;]&lt;br /&gt;
&lt;br /&gt;
    W[&amp;quot;Semantic MediaWiki 리비전&amp;quot;] --&amp;gt; I[&amp;quot;비공개 색인기&amp;quot;]&lt;br /&gt;
    I --&amp;gt; P[&amp;quot;버전이 있는 입력 구성&amp;quot;]&lt;br /&gt;
    P --&amp;gt; E[&amp;quot;교체 가능한 임베딩 제공자&amp;quot;]&lt;br /&gt;
    E --&amp;gt; X[&amp;quot;세대별 파생 벡터 인덱스&amp;quot;]&lt;br /&gt;
    X --&amp;gt; V&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
읽기 경로와 색인 경로를 분리합니다. 검색 요청이 벡터 인덱스를 직접 읽더라도 최종 Lesson 본문과 필터 판정은 MediaWiki에서 가져옵니다. 색인 실패는 위키 쓰기를 되돌리지 않습니다.&lt;br /&gt;
&lt;br /&gt;
=== 내부 경계 ===&lt;br /&gt;
&lt;br /&gt;
현재 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;WikiAdapter&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에는 기준 저장소 작업과 후보 검색이 함께 있습니다. 동작을 바꾸기 전에 다음 경계로 나눕니다.&lt;br /&gt;
&lt;br /&gt;
==== Lesson 저장소 ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;LessonRepository&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 MediaWiki 읽기와 허용된 쓰기만 담당합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
@dataclass(frozen=True, slots=True)&lt;br /&gt;
class LessonSnapshot:&lt;br /&gt;
    lesson: Lesson&lt;br /&gt;
    revision_id: int&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, slots=True)&lt;br /&gt;
class SnapshotBatch:&lt;br /&gt;
    snapshots: tuple[LessonSnapshot, ...]&lt;br /&gt;
    next_cursor: str | None&lt;br /&gt;
    partial: bool&lt;br /&gt;
    scanned: int&lt;br /&gt;
    scan_limit: int&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, slots=True)&lt;br /&gt;
class RelationEdgeBatch:&lt;br /&gt;
    edges: tuple[LessonRelation, ...]&lt;br /&gt;
    next_cursor: str | None&lt;br /&gt;
    partial: bool&lt;br /&gt;
    scanned: int&lt;br /&gt;
    scan_limit: int&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, slots=True)&lt;br /&gt;
class RankedLessonId:&lt;br /&gt;
    lesson_id: str&lt;br /&gt;
    rank: int&lt;br /&gt;
    source_score: float | None&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, slots=True)&lt;br /&gt;
class RankedLessonIdBatch:&lt;br /&gt;
    items: tuple[RankedLessonId, ...]&lt;br /&gt;
    next_cursor: str | None&lt;br /&gt;
    partial: bool&lt;br /&gt;
    scanned: int&lt;br /&gt;
    scan_limit: int&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class LessonRepository(Protocol):&lt;br /&gt;
    async def get_snapshot(&lt;br /&gt;
        self, lesson_id: str&lt;br /&gt;
    ) -&amp;gt; LessonSnapshot | None: ...&lt;br /&gt;
&lt;br /&gt;
    async def get_snapshots(&lt;br /&gt;
        self, lesson_ids: Sequence[str]&lt;br /&gt;
    ) -&amp;gt; dict[str, LessonSnapshot]: ...&lt;br /&gt;
&lt;br /&gt;
    async def scan_snapshots(&lt;br /&gt;
        self, *, limit: int, cursor: str | None&lt;br /&gt;
    ) -&amp;gt; SnapshotBatch: ...&lt;br /&gt;
&lt;br /&gt;
    async def search_full_text(&lt;br /&gt;
        self, *, query: str, limit: int, cursor: str | None&lt;br /&gt;
    ) -&amp;gt; RankedLessonIdBatch: ...&lt;br /&gt;
&lt;br /&gt;
    async def find_relation_edges(&lt;br /&gt;
        self,&lt;br /&gt;
        *,&lt;br /&gt;
        lesson_id: str,&lt;br /&gt;
        relations: Sequence[RelationType],&lt;br /&gt;
        direction: RelationDirection,&lt;br /&gt;
        limit: int,&lt;br /&gt;
        cursor: str | None,&lt;br /&gt;
    ) -&amp;gt; RelationEdgeBatch: ...&lt;br /&gt;
&lt;br /&gt;
    async def create_lesson(self, lesson: Lesson) -&amp;gt; Lesson: ...&lt;br /&gt;
    async def add_relation(&lt;br /&gt;
        self, relation: LessonRelation&lt;br /&gt;
    ) -&amp;gt; LessonRelation: ...&lt;br /&gt;
    async def add_evidence(&lt;br /&gt;
        self, lesson_id: str, evidence: Evidence&lt;br /&gt;
    ) -&amp;gt; Evidence: ...&lt;br /&gt;
&lt;br /&gt;
    async def revise_lesson(&lt;br /&gt;
        self,&lt;br /&gt;
        *,&lt;br /&gt;
        lesson_id: str,&lt;br /&gt;
        expected_revision_id: int,&lt;br /&gt;
        operation_id: str,&lt;br /&gt;
        changes: Mapping[str, object],&lt;br /&gt;
        basis_evidence_ids: Sequence[str],&lt;br /&gt;
    ) -&amp;gt; LessonSnapshot: ...&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;get_snapshots&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 후보 ID를 최대 50개씩 읽으며 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;audit_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;도 이 경계를 사용합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;scan_snapshots&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 전체 대조와 빈 query 목록에 쓰는 제한된 cursor scan입니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;search_full_text&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 MediaWiki의 ranked 전체 텍스트 후보를 반환합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;find_relation_edges&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 전체 Lesson을 읽지 않고 Semantic MediaWiki의 typed relation에서 들어오고 나가는 간선을 찾습니다. 모든 목록 응답에는 엄격한 상한과 cursor를 둡니다.&lt;br /&gt;
&lt;br /&gt;
snapshot은 MediaWiki 사실만 담습니다. 임베딩 대상 필드의 해시는 해당 generation의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;ProjectionBuilder&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 계산합니다. 그래야 active와 rollback generation이 서로 다른 입력 구성을 써도 같은 snapshot을 안전하게 검증할 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
==== 후보 검색 ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;RetrievalBackend&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 Lesson 본문이 아니라 작은 후보 레코드만 반환합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
@dataclass(frozen=True, slots=True)&lt;br /&gt;
class RankContribution:&lt;br /&gt;
    channel: str&lt;br /&gt;
    rank: int&lt;br /&gt;
    source_score: float | None&lt;br /&gt;
    indexed_revision_id: int | None&lt;br /&gt;
    indexed_retrieval_hash: str | None&lt;br /&gt;
    matched_facets: tuple[str, ...]&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, slots=True)&lt;br /&gt;
class RetrievalCandidate:&lt;br /&gt;
    lesson_id: str&lt;br /&gt;
    contributions: tuple[RankContribution, ...]&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, slots=True)&lt;br /&gt;
class RetrievalRequest:&lt;br /&gt;
    operation: str&lt;br /&gt;
    query: str | None&lt;br /&gt;
    source_projection: ProjectionDocument | None&lt;br /&gt;
    source_revision_id: int | None&lt;br /&gt;
    filter_hints: SearchFilters&lt;br /&gt;
    scan_limit: int&lt;br /&gt;
    expected_generation: str | None&lt;br /&gt;
    ranking_version: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, slots=True)&lt;br /&gt;
class RetrievalBatch:&lt;br /&gt;
    candidates: tuple[RetrievalCandidate, ...]&lt;br /&gt;
    generation: str&lt;br /&gt;
    partial: bool&lt;br /&gt;
    scanned: int&lt;br /&gt;
    scan_limit: int&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class RetrievalBackend(Protocol):&lt;br /&gt;
    async def retrieve(&lt;br /&gt;
        self, request: RetrievalRequest&lt;br /&gt;
    ) -&amp;gt; RetrievalBatch: ...&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
같은 ID가 여러 경로와 facet에서 나올 수 있으므로 각 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;RankContribution&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 유지합니다. 제품별 client, collection 이름과 거리 계산 방식은 backend 구현 안에 둡니다. 검색 조정 계층은 경로별 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;rank&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 기준으로 합치며 raw cosine 값을 서로 직접 비교하지 않습니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;source_score&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 한 후보 경로 안의 진단과 기준값 적용에만 씁니다. canonical 재검증 뒤 유효한 contribution만 다시 합쳐 전역 순서와 서비스 점수를 만듭니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;RetrievalRequest.source_projection&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;find_analogies&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서만 사용합니다. query, source revision, filters, ranking version과 generation 기대값을 cursor fingerprint에 묶습니다. 공개 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;min_score&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 candidate source에 전달하지 않고 canonical 재검증과 fusion이 끝난 서비스 점수에 적용합니다. 각 channel에 사전 기준값이 필요하면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;ranking_version&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 묶인 내부 설정으로 따로 보정합니다.&lt;br /&gt;
&lt;br /&gt;
여기서 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;RetrievalBackend&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 전체 텍스트, relation과 semantic source를 합치는 도메인 전용 composite입니다. 벡터 저장 제품은 그 아래의 비공개 semantic candidate source만 구현합니다.&lt;br /&gt;
&lt;br /&gt;
==== 입력 구성과 임베딩 ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;ProjectionBuilder&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 같은 Lesson에서 검색 목적별 텍스트를 결정적으로 만듭니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;EmbeddingProvider&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 텍스트 묶음을 벡터로 바꿉니다. 두 경계에는 각각 독립된 버전을 둡니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;ProjectionDocument&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;lesson_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, projection version, facet 텍스트, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;retrieval_hash&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 잘림 상태를 넣습니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;retrieval_hash&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 repository가 아니라 builder가 계산합니다.&lt;br /&gt;
&lt;br /&gt;
임베딩 제공자는 다음 정보를 고정해 보고해야 합니다.&lt;br /&gt;
&lt;br /&gt;
* 모델 식별자와 artifact digest&lt;br /&gt;
* tokenizer 식별자와 digest&lt;br /&gt;
* query와 document encoding mode 또는 prefix&lt;br /&gt;
* 벡터 차원&lt;br /&gt;
* 정규화 여부&lt;br /&gt;
* 거리 계산 방식&lt;br /&gt;
* 한 입력의 최대 token 수&lt;br /&gt;
외부 API, 서버 안의 모델 또는 별도 내부 추론 서비스는 같은 인터페이스를 구현할 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
현재 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;s3rm-qwen3-retrieval-v1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; profile은 다음을 하나의 generation 입력으로 묶습니다.&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Qwen/Qwen3-Embedding-0.6B&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; artifact SHA-256과 1024차원&lt;br /&gt;
* last pooling과 16K token 입력 계약&lt;br /&gt;
* 희귀 CJK byte fallback을 포함해 16K 안에 두는 document facet 4,000자 상한&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;situation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;approach&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mechanism&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;별 영문 query instruction&lt;br /&gt;
* instruction을 붙이지 않는 document 형식&lt;br /&gt;
* provider의 입력 개수·문자 수 상한&lt;br /&gt;
HTTP endpoint와 인증은 transport 설정이며 profile의 의미 계약과 분리합니다. 모델 파일, pooling, instruction 또는 차원을 바꾸면 profile generation이 달라져 전체 exact index를 새로 만듭니다. 서로 다른 벡터 공간은 섞지 않습니다. 외부 provider로 자동 failover하지 않으며 사용할 snapshot이 없으면 legacy 검색으로 대체합니다.&lt;br /&gt;
&lt;br /&gt;
==== 색인 동기화 ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;IndexSynchronizer&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 MediaWiki 변경을 읽어 파생 인덱스를 갱신합니다. MCP에는 색인 관리 도구를 추가하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
* MCP 쓰기 성공 뒤 해당 Lesson을 갱신하라는 힌트를 보냅니다.&lt;br /&gt;
* 브라우저 편집을 찾기 위해 MediaWiki recent changes를 주기적으로 읽습니다.&lt;br /&gt;
* 누락된 이벤트를 복구하기 위해 전체 Lesson을 주기적으로 대조합니다.&lt;br /&gt;
* 체크포인트, 재시도 큐와 인덱스 세대는 파생 운영 상태로 취급합니다.&lt;br /&gt;
* 한 generation의 레코드 key는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;(generation, lesson_id)&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;입니다.&lt;br /&gt;
* 처리할 때마다 MediaWiki 최신 snapshot을 다시 읽습니다.&lt;br /&gt;
* incoming revision이 현재 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;indexed_revision_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;보다 새로울 때만 원자적으로 교체합니다.&lt;br /&gt;
* 같은 revision의 재시도는 같은 결과가 나와야 합니다.&lt;br /&gt;
* 삭제와 이동 tombstone도 MediaWiki change 순서와 같은 fencing 규칙을 사용합니다.&lt;br /&gt;
* 여러 색인기를 쓸 때는 lease와 fencing token으로 오래된 worker의 쓰기를 막습니다.&lt;br /&gt;
==== 호환 이행 ====&lt;br /&gt;
&lt;br /&gt;
첫 구조 변경은 검색 결과를 바꾸지 않습니다.&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;ResearchMemoryService&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;LessonRepository&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 선택적인 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;RetrievalBackend&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 받게 합니다.&lt;br /&gt;
* 검색 backend가 없으면 현재 동작을 감싼 legacy 구현을 사용합니다.&lt;br /&gt;
* 기존 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;WikiAdapter&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 검색 메서드는 in-memory adapter와 기존 호출자를 옮기는 동안 유지합니다.&lt;br /&gt;
* 일괄 snapshot 조회에는 단일 조회를 반복하는 기본 구현을 둘 수 있습니다.&lt;br /&gt;
* 외부 MCP 도구 이름, 입력과 응답은 검색 backend 교체 때문에 바꾸지 않습니다.&lt;br /&gt;
새 내부 경계와 legacy 구현의 결과가 같은지 domain-level 테스트로 확인한 뒤 의미 검색을 추가합니다.&lt;br /&gt;
&lt;br /&gt;
=== 검색용 표현 ===&lt;br /&gt;
&lt;br /&gt;
Lesson 전체를 한 벡터로 합치지 않습니다. 첫 입력 구성 버전은 세 가지 facet을 사용합니다.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! facet !! 포함하는 필드 !! 용도&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;situation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;question&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;context&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;observation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 비슷한 문제와 관찰 찾기&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;approach&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;attempt&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 이미 시도한 방법과 실패 형태 찾기&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mechanism&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;interpretation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;reusable_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;applicability&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 해결 원리와 적용 조건 찾기&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;title&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 전체 텍스트 검색에서 높은 가중치로 다룹니다. evidence 개요, 구조화된 인용, note, URL, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;verification_basis&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, append-only evidence 검증 기록, author, reviewer, timestamp와 relation은 임베딩하지 않습니다. 이 값은 검색 근거, 필터, 감사 또는 relation 후보 경로에서 따로 사용합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;metadata_only&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; evidence가 있다는 이유만으로 Lesson 전체를 제외하지 않습니다. 한 Lesson에 서로 다른 확인 범위의 근거가 함께 있을 수 있기 때문입니다.&lt;br /&gt;
&lt;br /&gt;
입력은 다음 규칙으로 만듭니다.&lt;br /&gt;
&lt;br /&gt;
# Unicode NFKC로 정규화하고 연속 공백을 하나로 줄입니다.&lt;br /&gt;
# 필드 이름과 필드 값을 고정된 순서로 붙입니다.&lt;br /&gt;
# MediaWiki 표시 문법은 versioned sanitizer로 제거하고 외부 URL은 가져오지 않습니다.&lt;br /&gt;
# query도 facet별 고정 prefix와 encoding mode로 만듭니다.&lt;br /&gt;
# 빈 필드는 허용하지 않는 현재 Lesson 계약을 그대로 사용합니다.&lt;br /&gt;
# 모델 입력 한도를 넘으면 facet별 token 예산에 따라 앞부분과 뒷부분을 보존합니다.&lt;br /&gt;
# 잘림 여부를 색인 상태에 기록합니다.&lt;br /&gt;
# 요약용 LLM은 색인 경로에 넣지 않습니다.&lt;br /&gt;
긴 입력이 자주 잘리기 시작하면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;ProjectionBuilder&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;만 바꿔 필드별 chunk embedding과 결정적인 pooling을 도입합니다. 이 변경도 새 입력 구성 버전과 새 인덱스 세대를 사용합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;retrieval_hash&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에는 입력 구성 버전과 정규화된 facet 텍스트를 넣습니다. 다음 값은 별도 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;metadata_hash&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 관리합니다.&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;confidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;record_origin&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;author&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;review_state&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;created_at&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;updated_at&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
* 구조화된 evidence 존재 여부&lt;br /&gt;
이렇게 하면 evidence 추가처럼 의미 본문이 바뀌지 않은 수정은 재임베딩하지 않고 필터 metadata만 갱신할 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
=== 인덱스 레코드 ===&lt;br /&gt;
&lt;br /&gt;
파생 인덱스에는 다음 값만 저장합니다.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 값 !! 설명&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;lesson_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || MediaWiki 페이지의 고정 외부 ID&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;indexed_revision_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 색인할 때 읽은 리비전 ID&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;retrieval_hash&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 임베딩 대상 내용의 해시&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;metadata_hash&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 필터 metadata의 해시&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;updated_at&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 지연과 최신성 확인용 시각&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;generation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 모델과 입력 구성의 세대&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;last_change_position&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 마지막으로 적용한 MediaWiki change 순서&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;tombstone&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 삭제·이동 뒤 오래된 upsert를 막는 파생 표시&lt;br /&gt;
|-&lt;br /&gt;
| filter scalar || 기존 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;SearchFilters&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 미리 적용하기 위한 값&lt;br /&gt;
|-&lt;br /&gt;
| facet vectors || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;situation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;approach&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mechanism&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 벡터&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
정규화된 본문, evidence 전문과 relation 목록은 저장하지 않습니다. 필터 scalar는 성능 최적화용 사본입니다. 최종 결과에는 MediaWiki에서 다시 읽은 필터를 적용합니다. 삭제된 Lesson의 vector는 제거하되 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;(generation, lesson_id)&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 tombstone과 마지막 change position은 파생 동기화 상태에 유지합니다. upsert와 delete는 이 값을 원자적으로 비교해 느린 worker가 오래된 vector를 되살리지 못하게 합니다.&lt;br /&gt;
&lt;br /&gt;
=== 후보 생성과 순위 ===&lt;br /&gt;
&lt;br /&gt;
==== &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;search_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; ====&lt;br /&gt;
&lt;br /&gt;
다음 후보 경로를 독립적으로 실행합니다.&lt;br /&gt;
&lt;br /&gt;
# MediaWiki 전체 텍스트 검색&lt;br /&gt;
# query와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;situation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; facet의 의미 유사도&lt;br /&gt;
# query와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;approach&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; facet의 의미 유사도&lt;br /&gt;
# query와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mechanism&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; facet의 의미 유사도&lt;br /&gt;
정확한 식별자, 제목과 오류 문자열은 전체 텍스트 결과가 놓치지 않게 합니다. 의미 검색은 같은 뜻을 다른 표현으로 쓴 후보를 보완합니다.&lt;br /&gt;
&lt;br /&gt;
query가 비어 있으면 임베딩을 호출하지 않습니다. 현재처럼 canonical Lesson 목록을 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;scan_snapshots&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 읽고 filters와 페이지 제한을 적용합니다.&lt;br /&gt;
&lt;br /&gt;
==== &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;find_analogies&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; ====&lt;br /&gt;
&lt;br /&gt;
다음 순서로 후보를 만듭니다.&lt;br /&gt;
&lt;br /&gt;
# 저장된 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;analogous_to&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; relation 후보&lt;br /&gt;
# source의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;situation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;과 비슷한 후보&lt;br /&gt;
# source의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mechanism&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;과 비슷한 교차 문제 후보&lt;br /&gt;
# 현재 Unicode 단어와 한국어 n-gram 후보&lt;br /&gt;
relation 후보는 명시적으로 저장된 연결이라는 점을 계속 구분합니다. 벡터 후보는 relation이 아니며 자동으로 저장하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
==== 결합과 다양성 ====&lt;br /&gt;
&lt;br /&gt;
백엔드마다 점수 분포가 다르므로 raw 점수를 더하지 않습니다. 각 후보 경로의 순위를 versioned reciprocal-rank fusion으로 합칩니다. 같은 해결 방식이 상위 결과를 차지하지 않도록 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;approach&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mechanism&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 벡터에 작은 다양성 가중치를 적용합니다.&lt;br /&gt;
&lt;br /&gt;
다양성은 관련성을 대체하지 않습니다. 먼저 적용 조건과 문제 유사도가 기준을 넘은 후보만 남기고, 그 안에서 서로 다른 해결 원리를 섞습니다. 정렬 규칙과 가중치는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;ranking_version&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 고정합니다.&lt;br /&gt;
&lt;br /&gt;
각 channel은 고정된 내부 후보 상한까지 먼저 수집합니다. 모든 channel의 contribution을 합친 뒤 preliminary fusion을 수행하고, 전역 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;scan_limit&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 안의 ID만 MediaWiki에서 재검증합니다. 다음 page를 만들기 위해 channel을 조금씩 더 읽는 방식은 쓰지 않습니다. 뒤늦게 온 contribution 때문에 이미 반환한 RRF 순서가 바뀌는 것을 막기 위해서입니다.&lt;br /&gt;
&lt;br /&gt;
어느 channel이 내부 상한에 닿으면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;partial=true&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 표시합니다. canonical 재검증과 diversity 정렬까지 끝난 bounded 결과 목록에 cursor offset을 적용합니다.&lt;br /&gt;
&lt;br /&gt;
현재 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;find_analogies&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 명시 relation에 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;score=1.0&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 주고 lexical 후보에는 Jaccard score와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;shared_terms&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 반환합니다. 이 의미를 조용히 바꾸지 않습니다.&lt;br /&gt;
&lt;br /&gt;
semantic 결과는 기존 도구의 versioned &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;retrieval_mode&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 공개합니다.&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;legacy&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 현재 점수, 순서와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;shared_terms&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 그대로 유지합니다.&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;hybrid_v1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 raw cosine이 아닌 0~1 서비스 점수와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;ranking_version&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 반환합니다.&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;hybrid_v1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;basis&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;semantic_similarity&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;hybrid&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 추가합니다.&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;min_score&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 어떤 점수에 적용되는지 mode별로 문서화합니다.&lt;br /&gt;
* 같은 호환 기간에는 기본 mode를 조용히 바꾸지 않습니다.&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;search_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에도 같은 mode를 선택적으로 추가했습니다. 입력과 출력 모델, 서버 schema, 예시와 MCP 문서는 같은 계약으로 관리합니다.&lt;br /&gt;
&lt;br /&gt;
=== 기준 저장소 재검증 ===&lt;br /&gt;
&lt;br /&gt;
후보를 받은 뒤 다음 순서로 확인합니다.&lt;br /&gt;
&lt;br /&gt;
# 후보 ID를 합치되 경로별 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;RankContribution&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 유지합니다.&lt;br /&gt;
# MediaWiki에서 최신 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;LessonSnapshot&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 일괄 조회합니다.&lt;br /&gt;
# 없어진 페이지는 결과에서 빼고 인덱스 정리 대상으로 표시합니다.&lt;br /&gt;
# 현재 generation의 builder로 최신 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;retrieval_hash&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 계산합니다.&lt;br /&gt;
# hash가 다른 semantic contribution만 제거하고 재색인합니다.&lt;br /&gt;
# lexical 또는 relation contribution이 남은 ID는 최신 Lesson으로 계속 평가합니다.&lt;br /&gt;
# 해시는 같고 리비전만 다르면 최신 Lesson을 사용합니다.&lt;br /&gt;
# 기존 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;SearchFilters&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 최신 Lesson에 다시 적용합니다.&lt;br /&gt;
# 유효한 contribution을 다시 결합해 안정적인 순서로 정렬합니다.&lt;br /&gt;
# 페이지와 UTF-8 출력 제한을 적용합니다.&lt;br /&gt;
인덱스 지연으로 오래된 Lesson 내용이 반환되지는 않게 합니다. 대신 일부 semantic 후보가 잠시 빠질 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;scanned&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 인덱스 내부 ANN node 수가 아니라 기준 저장소 재검증 전에 서비스가 검토한 후보 ID 수로 정의합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;scan_limit&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 모든 후보 경로를 합친 엄격한 상한입니다.&lt;br /&gt;
&lt;br /&gt;
기존 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;partial&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 후보 탐색 상한에 닿았다는 뜻을 유지합니다. semantic generation이 준비되지 않았거나 synchronizer checkpoint가 MediaWiki change watermark보다 뒤처졌다는 상태는 작은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;semantic_status&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 값으로 따로 표시합니다. 반환 후보의 hash 검사만으로는 아직 후보에 들어오지 않은 새 Lesson을 찾을 수 없기 때문입니다. 이 값은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;disabled&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;building&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;ready&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;stale&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;degraded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;unavailable&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 제한합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;ready&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 checkpoint가 허용 지연 안에 있고 미해결 missing point나 파생 오류 ID가 없을 때만 사용합니다. checkpoint는 따라잡았지만 일부 Lesson을 색인하지 못했으면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;degraded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 사용합니다. 이 필드를 활성화할 때 output 모델, MCP 문서와 테스트를 함께 바꿉니다.&lt;br /&gt;
&lt;br /&gt;
=== 인덱스 세대 ===&lt;br /&gt;
&lt;br /&gt;
다음 중 하나가 바뀌면 새 generation을 만듭니다.&lt;br /&gt;
&lt;br /&gt;
* 임베딩 모델 또는 artifact&lt;br /&gt;
* tokenizer&lt;br /&gt;
* 입력 정규화와 facet 구성&lt;br /&gt;
* token 예산 또는 chunk pooling&lt;br /&gt;
* 벡터 차원, 정규화 또는 거리 계산&lt;br /&gt;
* 순위 결합에 필요한 인덱스 구조&lt;br /&gt;
generation ID는 이 설정을 직렬화한 값의 불투명 해시로 만듭니다. 기존 인덱스를 제자리에서 덮어쓰지 않습니다.&lt;br /&gt;
&lt;br /&gt;
새 generation은 다음 순서로 활성화합니다.&lt;br /&gt;
&lt;br /&gt;
# &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;BUILDING&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 상태로 빈 generation을 만듭니다.&lt;br /&gt;
# MediaWiki recent changes의 high-water mark를 기록합니다.&lt;br /&gt;
# 변경 tail을 시작하고 전체 Lesson을 최대 50개씩 읽어 색인합니다.&lt;br /&gt;
# build 중 생긴 변경을 계속 적용합니다.&lt;br /&gt;
# 전환 직전 cutover watermark를 기록합니다.&lt;br /&gt;
# 새 generation의 checkpoint가 cutover watermark에 도달했는지 확인합니다.&lt;br /&gt;
# 누락, orphan, 리비전과 모델 구성을 검사합니다.&lt;br /&gt;
# recent changes 보존 구간에 gap이 있으면 활성화하지 않고 다시 구축합니다.&lt;br /&gt;
# shadow query로 현재 generation과 품질·지연 시간을 비교합니다.&lt;br /&gt;
# 검사를 통과하면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;READY&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 바꾸고 active alias를 원자적으로 전환합니다.&lt;br /&gt;
# 이전 generation은 cursor 유효 기간과 rollback 기간 동안 최신 상태로 유지합니다.&lt;br /&gt;
# rollback 기간이 끝나면 이전 generation을 삭제합니다.&lt;br /&gt;
새 generation에 문제가 있으면 active alias만 이전 값으로 되돌립니다. 파생 볼륨이 손상되면 MediaWiki 리비전에서 다시 만듭니다.&lt;br /&gt;
&lt;br /&gt;
generation은 모델, projection과 거리 계산 구성이 불변이라는 뜻입니다. generation 안의 Lesson 레코드는 revision 조건부 upsert로 계속 갱신합니다. 변경 tail은 alias 전환 뒤에도 멈추지 않으며 rollback 기간에는 active와 이전 generation을 함께 갱신합니다. 이전 generation을 조회할 수 있는 동안 해당 builder, tokenizer, embedding provider와 ranking 설정을 주소로 찾을 수 있게 유지합니다. 변경된 Lesson도 각 generation의 모델로 따로 임베딩합니다.&lt;br /&gt;
&lt;br /&gt;
파생 vector volume은 기준 백업 대상이 아닙니다. snapshot은 재시작 시간을 줄이는 선택 사항이며 generation, model digest와 MediaWiki checkpoint가 맞지 않으면 폐기하고 다시 구축합니다.&lt;br /&gt;
&lt;br /&gt;
=== 페이지 나누기와 cursor ===&lt;br /&gt;
&lt;br /&gt;
1단계 구현은 기존 v1 cursor envelope를 유지하고, 요청 fingerprint 안에 query, source revision·projection hash, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;effective_mode&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;ranking_version&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;과 generation을 묶습니다. 따라서 기존 legacy cursor 형식은 바뀌지 않으면서도 generation이나 실제 mode가 달라지면 cursor를 거부합니다.&lt;br /&gt;
&lt;br /&gt;
아래 v2 형태는 만료 시각과 generation을 독립 필드로 운영해야 할 때의 후속 형식입니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;v&amp;quot;: 2,&lt;br /&gt;
  &amp;quot;tool&amp;quot;: &amp;quot;find_analogies&amp;quot;,&lt;br /&gt;
  &amp;quot;fp&amp;quot;: &amp;quot;&amp;lt;request fingerprint&amp;gt;&amp;quot;,&lt;br /&gt;
  &amp;quot;gen&amp;quot;: &amp;quot;&amp;lt;retrieval generation&amp;gt;&amp;quot;,&lt;br /&gt;
  &amp;quot;pos&amp;quot;: 37,&lt;br /&gt;
  &amp;quot;exp&amp;quot;: 1893456000&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
fingerprint에는 query, source revision과 projection hash, filters, fields, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;min_score&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;effective_mode&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;ranking_version&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 포함합니다. generation은 같은 모델과 정렬 규칙을 사용하게 하지만, 페이지 사이에 생긴 MediaWiki 편집까지 snapshot으로 고정하지는 않습니다. 따라서 현재 검색과 마찬가지로 편집이 동시에 일어나면 결과가 중복되거나 다음 페이지로 이동할 수 있습니다. 정확한 query snapshot이 필요해지면 bounded 후보 ID를 별도 cursor 상태로 보존하는 기능을 독립적으로 설계합니다.&lt;br /&gt;
&lt;br /&gt;
해당 generation을 더 이상 제공할 수 없으면 첫 페이지부터 다시 조회하라는 cursor 오류를 반환합니다.&lt;br /&gt;
&lt;br /&gt;
각 호출은 모든 channel의 bounded 후보를 다시 모아 canonical 재검증과 정렬을 완료합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;pos&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 이 최종 목록의 offset입니다. 사라지거나 filter에 맞지 않는 후보는 offset을 적용하기 전에 목록에서 빠집니다. 출력 byte 제한 때문에 페이지가 짧아지면 cursor는 실제 반환한 항목 수만큼만 이동해 빠진 정상 후보가 다음 페이지에 나오게 합니다. v2로 전환할 때는 MCP 문서와 테스트를 함께 바꿉니다.&lt;br /&gt;
&lt;br /&gt;
=== 동기화와 일관성 ===&lt;br /&gt;
&lt;br /&gt;
일관성 보장은 다음처럼 나눕니다.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 항목 !! 보장&lt;br /&gt;
|-&lt;br /&gt;
| 반환한 Lesson 내용 || MediaWiki 최신 리비전으로 확인&lt;br /&gt;
|-&lt;br /&gt;
| 전체 텍스트 검색 || 저장 성공 뒤 바로 검색 가능&lt;br /&gt;
|-&lt;br /&gt;
| 의미 검색 완전성 || 짧은 지연을 허용하는 eventual consistency&lt;br /&gt;
|-&lt;br /&gt;
| relation 의미 || MediaWiki에 저장된 typed relation만 기준&lt;br /&gt;
|-&lt;br /&gt;
| 벡터 유사도 || 후보 추천이며 사실 또는 인과관계가 아님&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
MCP 쓰기는 항상 MediaWiki에 먼저 저장합니다. 1단계에서는 Lesson 생성이나 핵심 내용 revision 성공 뒤 프로세스 안의 dirty hint를 남기고 단일 백그라운드 refresh를 시작합니다. evidence와 relation만 추가하면 현재 projection 본문은 바뀌지 않으므로 재임베딩하지 않습니다. 브라우저 편집은 최소 대조 간격이 지난 hybrid 요청이 백그라운드 refresh를 시작할 때 발견합니다. 대조 결과 Lesson 수와 모든 projection hash가 같으면 모델을 다시 호출하지 않습니다. hybrid 요청 자체는 전체 대조를 기다리지 않습니다. 사용할 snapshot이 있으면 갱신 중에도 그 세대를 사용하고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;stale&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 상태를 표시하며, 없으면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;building&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 즉시 legacy 검색을 반환합니다. hint나 임베딩이 실패해도 위키 쓰기는 되돌리지 않습니다.&lt;br /&gt;
&lt;br /&gt;
1단계 refresh는 all-or-nothing입니다. 한 Lesson이라도 임베딩하지 못하면 기존 snapshot을 유지하고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;degraded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 기존 snapshot이 없으면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;unavailable&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 표시합니다. 항목별 재시도 큐와 recent changes poll은 후속 동기화 단계에서 추가합니다.&lt;br /&gt;
&lt;br /&gt;
=== 장애 동작 ===&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 장애 !! 동작&lt;br /&gt;
|-&lt;br /&gt;
| query 임베딩 실패 || 전체 텍스트와 relation 후보로 fallback&lt;br /&gt;
|-&lt;br /&gt;
| 벡터 인덱스 연결 실패 || 전체 텍스트와 relation 후보로 fallback&lt;br /&gt;
|-&lt;br /&gt;
| 인덱스 지연 || hash가 다른 semantic 기여를 빼고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;semantic_status=stale&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 표시&lt;br /&gt;
|-&lt;br /&gt;
| 일부 Lesson 임베딩 실패 || 1단계는 기존 snapshot 전체를 유지하고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;semantic_status=degraded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 표시&lt;br /&gt;
|-&lt;br /&gt;
| 프로세스 재시작·메모리 손실 || 서비스 시작 시 단일 백그라운드 작업으로 새 generation 프리웜&lt;br /&gt;
|-&lt;br /&gt;
| 브라우저 편집 미반영 || 최소 대조 간격이 지난 hybrid 요청이 백그라운드 전체 대조 시작&lt;br /&gt;
|-&lt;br /&gt;
| 모델과 인덱스 구성 불일치 || readiness 실패, generation 활성화 금지&lt;br /&gt;
|-&lt;br /&gt;
| 새 모델 품질 저하 || 1단계는 provider 설정을 되돌려 재시작, 후속 단계는 active alias rollback&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
fallback은 cursor가 없는 첫 page에서만 검색 mode를 바꿀 수 있습니다. hybrid 요청이 legacy로 fallback되면 응답과 cursor에 실제 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;effective_mode=legacy&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 기록합니다. hybrid cursor를 발급한 뒤 semantic backend가 실패하면 중간 page에서 legacy 순서로 바꾸지 않습니다. 재시도 가능한 오류를 반환하거나 첫 page부터 다시 조회하도록 안내합니다.&lt;br /&gt;
&lt;br /&gt;
semantic 검색 장애만으로 공개 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/healthz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 실패시키지 않습니다. 전체 텍스트 fallback이 가능하면 MCP는 읽기 서비스를 계속 제공합니다. 상세 semantic readiness는 내부 상태와 제한된 로그·지표로 확인합니다.&lt;br /&gt;
&lt;br /&gt;
=== 자원 격리 ===&lt;br /&gt;
&lt;br /&gt;
읽기 도구는 인증 없이 호출할 수 있으므로 semantic 추론 자원을 별도로 제한합니다.&lt;br /&gt;
&lt;br /&gt;
1단계는 query embedding과 background indexing이 하나의 bounded semaphore와 대기열을 공유합니다. 동시 실행 수, 대기열 길이, 대기·요청 timeout, batch 크기, 입력 길이와 응답 크기를 제한하고 circuit breaker를 적용합니다.&lt;br /&gt;
&lt;br /&gt;
트래픽이나 재구축 시간이 이 공유 한도에서 충돌하면 후속 단계에서 query와 indexing queue를 분리하고 background 우선순위와 concurrency를 따로 둡니다. 로컬 CPU·GPU worker를 쓸 때는 MCP event loop와 격리하고, 외부 API에는 비용 예산과 명시적 재시도 정책을 추가합니다. 어느 단계에서도 포화 상태가 lexical 검색과 쓰기 경로의 thread, connection과 memory를 소진하지 않게 합니다.&lt;br /&gt;
&lt;br /&gt;
한도를 넘으면 cursor가 없는 첫 page는 즉시 legacy로 fallback합니다. 이미 hybrid cursor를 발급했다면 검색 순서를 바꾸지 않고 재시도 또는 첫 page 재시작 오류를 반환합니다.&lt;br /&gt;
&lt;br /&gt;
=== 단계별 확장 ===&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 단계 !! 구성 !! 다음 단계로 옮기는 기준&lt;br /&gt;
|-&lt;br /&gt;
| 0 || 현재 전체 텍스트와 lexical analogy || 평가 기준선 확보&lt;br /&gt;
|-&lt;br /&gt;
| 1 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 안의 copy-on-write exact vector snapshot || 품질과 비용 검증&lt;br /&gt;
|-&lt;br /&gt;
| 2 || backend network 전용 검색 sidecar와 파생 volume || 재구축 시간, 메모리 또는 p95 지연 목표 초과&lt;br /&gt;
|-&lt;br /&gt;
| 3 || 공유 내부 검색 서비스와 여러 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 인스턴스 || 가용성 또는 동시 요청 때문에 수평 확장 필요&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
단계 1에서는 서버가 먼저 전체 텍스트 검색을 제공합니다. 시작 프리웜과 dirty/due 백그라운드 대조가 같은 구성 generation의 vector matrix와 metadata map을 만들고, 전체 대조가 성공할 때마다 copy-on-write snapshot을 원자적으로 교체합니다. Lesson 수, 벡터 차원, 실제 메모리, 재구축 시간과 exact cosine p95를 측정합니다. 단순한 Lesson 수만으로 제품을 바꾸지 않습니다.&lt;br /&gt;
&lt;br /&gt;
단계 2의 검색 sidecar도 도메인 전용 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;search_candidates&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 내부 상태 확인만 제공합니다. 벡터 저장 제품의 포트를 호스트나 외부 네트워크에 열지 않습니다.&lt;br /&gt;
&lt;br /&gt;
단계 3에서는 색인기를 한 인스턴스만 실행하거나 lease로 조정합니다. 모든 MCP 인스턴스는 같은 active generation을 읽습니다. 외부에는 계속 중앙 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 주소 하나만 제공합니다. 현재 프로세스 메모리에 있는 인증 상태도 수평 확장 전에 공유 상태 또는 안전한 routing으로 바꿔야 합니다.&lt;br /&gt;
&lt;br /&gt;
=== 보안과 개인정보 ===&lt;br /&gt;
&lt;br /&gt;
임베딩은 익명화된 값이 아닙니다. 원문과 같은 민감도로 다룹니다.&lt;br /&gt;
&lt;br /&gt;
* 기본 선택은 서버 안이나 내부 네트워크의 다국어 임베딩 모델입니다.&lt;br /&gt;
* 외부 임베딩 API는 서버 담당자가 전송, 보존, 학습 사용, region과 비용 조건을 확인한 뒤 명시적으로 켭니다.&lt;br /&gt;
* 검색 query는 아직 위키에 저장하지 않은 민감한 문제를 포함할 수 있습니다.&lt;br /&gt;
* 원문 query와 Lesson 본문을 로그에 남기지 않습니다.&lt;br /&gt;
* 지표에는 generation, revision, 지연 시간, 결과 수와 오류 코드만 남깁니다.&lt;br /&gt;
* 벡터 인덱스는 backend network에서만 접근합니다.&lt;br /&gt;
* 별도 호스트를 쓰면 서비스 인증과 전송 암호화를 적용합니다.&lt;br /&gt;
* 색인기 계정은 Lesson 읽기에 필요한 최소 권한만 가집니다.&lt;br /&gt;
* 모델과 tokenizer artifact는 digest로 고정합니다.&lt;br /&gt;
* 페이지가 삭제되거나 접근 정책이 바뀌면 active와 rollback generation에서 모두 제거합니다.&lt;br /&gt;
공개 위키에 있는 Lesson도 외부 임베딩 제공자에게 전송해도 된다고 자동으로 간주하지 않습니다. 검색 query는 위키보다 더 민감할 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
=== 관측 항목 ===&lt;br /&gt;
&lt;br /&gt;
다음 값을 query 원문 없이 측정합니다.&lt;br /&gt;
&lt;br /&gt;
* recent changes 지연 시간과 처리하지 않은 revision 수&lt;br /&gt;
* 마지막 poll과 전체 대조 시각&lt;br /&gt;
* 기준 Lesson 수와 generation별 색인 수&lt;br /&gt;
* missing, orphan, stale hash 수&lt;br /&gt;
* embedding, 후보 검색, MediaWiki 재조회와 재정렬 지연 시간&lt;br /&gt;
* embedding queue 깊이, 거부, timeout과 circuit breaker 상태&lt;br /&gt;
* fallback 비율과 원인&lt;br /&gt;
* hash 불일치로 제거한 후보 수&lt;br /&gt;
* query당 후보 수와 canonical filter로 제거한 수&lt;br /&gt;
* generation build 진행률, 실패, 재시도와 파생 오류 수&lt;br /&gt;
* active model, 입력 구성과 ranking version&lt;br /&gt;
* cursor 무효화와 출력 byte 제한 발생 비율&lt;br /&gt;
=== 품질 평가 ===&lt;br /&gt;
&lt;br /&gt;
벡터 검색은 기술적으로 동작하는 것만으로 활성화하지 않습니다. 한국어, 영어와 혼합 query를 포함한 작은 고정 평가셋을 먼저 만듭니다.&lt;br /&gt;
&lt;br /&gt;
다음 항목을 현재 검색과 비교합니다.&lt;br /&gt;
&lt;br /&gt;
* top 5 안에서 실제로 적용 가능한 후보 비율&lt;br /&gt;
* 다른 분야이지만 도움이 되는 후보 비율&lt;br /&gt;
* 저장된 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;analogous_to&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; relation의 회수율&lt;br /&gt;
* 정확한 오류 문자열과 고유 명칭 검색의 회귀 여부&lt;br /&gt;
* 같은 해결책이 반복되는 비율&lt;br /&gt;
* 근거와 적용 조건이 부족한 후보 비율&lt;br /&gt;
* p50, p95 지연 시간과 fallback 비율&lt;br /&gt;
저장소의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;scripts/evaluate-retrieval.py&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 중앙 MCP의 read-only &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;search_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;만 호출하고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;recall&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mean reciprocal rank&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;nDCG&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, p50/p95 latency, fallback 수와 partial 응답 수를 bounded JSON으로 출력합니다. 평가 파일에는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;query&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;expected_ids&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 선택적인 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;retrieval_mode&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;scope&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 넣고 query 본문은 결과나 로그에 쓰지 않습니다.&lt;br /&gt;
&lt;br /&gt;
평가용 query 원문에 비밀이나 개인 정보를 넣지 않습니다. 실제 사용 기록을 저장소에 그대로 커밋하지 않습니다. 새 generation은 품질 기준, 출력 제한과 장애 fallback 테스트를 모두 통과한 뒤 활성화합니다.&lt;br /&gt;
&lt;br /&gt;
=== 구현 순서 ===&lt;br /&gt;
&lt;br /&gt;
# &#039;&#039;&#039;완료:&#039;&#039;&#039; &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;LessonSnapshot&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;과 MediaWiki 최대 50개 일괄 조회를 추가했습니다.&lt;br /&gt;
# &#039;&#039;&#039;완료:&#039;&#039;&#039; &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;SemanticIndexBackend&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 경계와 기존 기본 동작을 유지하는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;legacy&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; mode를 추가했습니다.&lt;br /&gt;
# &#039;&#039;&#039;완료:&#039;&#039;&#039; 고정 벡터 fake와 domain-level 회귀·fallback·stale hash 테스트를 추가했습니다.&lt;br /&gt;
# &#039;&#039;&#039;완료:&#039;&#039;&#039; &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;ProjectionBuilder&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 교체 가능한 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;EmbeddingProvider&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 exact index를 추가했습니다.&lt;br /&gt;
# &#039;&#039;&#039;완료:&#039;&#039;&#039; &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;find_analogies&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;search_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 명시적 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;hybrid_v1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; mode를 적용했습니다. 기본 mode는 바꾸지 않았습니다.&lt;br /&gt;
# &#039;&#039;&#039;완료:&#039;&#039;&#039; revision ID를 포함한 조회, 조건부 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;revise_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, bounded &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;audit_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 full citation 조회를 추가했습니다. 이 도구들은 기준 MediaWiki를 읽고 쓰며 별도 검색 저장소를 만들지 않습니다.&lt;br /&gt;
# &#039;&#039;&#039;완료:&#039;&#039;&#039; 서버 설정 scope와 고정 평가셋 기반 read-only 품질 측정을 추가했습니다.&lt;br /&gt;
# &#039;&#039;&#039;완료:&#039;&#039;&#039; code/docs 파일과 Slack export를 Lesson 이전의 bounded 후보로 읽는 connector를 추가했습니다. connector는 비밀값을 마스킹하고 중앙 저장소에 쓰지 않습니다.&lt;br /&gt;
# &#039;&#039;&#039;완료:&#039;&#039;&#039; 세 facet 병렬 planner, bounded RRF executor, 선택적 OpenAI-compatible reranker와 인용 제한 synthesis CLI를 추가했습니다.&lt;br /&gt;
# &#039;&#039;&#039;완료:&#039;&#039;&#039; scope별 optional principal allowlist를 추가했습니다. 이는 relevance scope 사용 권한만 보호하며 Lesson 필드나 canonical 저장 정책을 바꾸지 않습니다.&lt;br /&gt;
# &#039;&#039;&#039;운영 후속:&#039;&#039;&#039; 고정 평가셋과 shadow 측정으로 품질, 비용, p95, fallback 비율을 확인한 뒤 활성화 범위를 정합니다.&lt;br /&gt;
# &#039;&#039;&#039;확장 후속:&#039;&#039;&#039; recent changes checkpoint, tombstone과 지속형 generation을 추가합니다.&lt;br /&gt;
# &#039;&#039;&#039;조건부 후속:&#039;&#039;&#039; 실제 지표가 전환 기준을 넘을 때만 sidecar 또는 공유 검색 서비스로 옮깁니다.&lt;br /&gt;
각 단계에서 기존 최대 20건, cursor, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;partial&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;scanned&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;scan_limit&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 UTF-8 직렬화 상한을 유지합니다. 모델, 응답 또는 검색 정책이 바뀌면 MCP 모델, 예시와 문서를 함께 수정합니다.&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC/%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98&amp;diff=2773</id>
		<title>도움말:S3 연구 메모리/아키텍처</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC/%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98&amp;diff=2773"/>
		<updated>2026-07-20T05:08:47Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 저장소 문서 동기화&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;!-- S3RM managed repository document; edit the Markdown source, not this page. --&amp;gt;&lt;br /&gt;
&#039;&#039;이 페이지는 저장소 문서에서 자동으로 동기화됩니다. 위키에서 직접 편집하지 마세요.&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
=== 용도와 범위 ===&lt;br /&gt;
&lt;br /&gt;
S3 연구 메모리는 프로젝트가 끝난 뒤에도 다시 쓸 수 있는 Lesson을 저장합니다. 작업 추적기, 실행 로그, 산출물 저장소, 원시 지표 데이터베이스로 쓰지 않습니다. 원본 근거는 알맞은 저장소나 객체 스토리지에 두고, Lesson에는 주장과 적용 조건, 해석, 원본을 다시 찾는 데 필요한 인용을 기록합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
flowchart LR&lt;br /&gt;
    H[&amp;quot;브라우저 사용자&amp;lt;br/&amp;gt;읽기는 공개, 쓰기는 이름이 있는 계정&amp;quot;] --&amp;gt;|&amp;quot;https://s3wiki.yonsei.ac.kr/&amp;quot;| P[&amp;quot;Caddy HTTPS 진입점&amp;quot;]&lt;br /&gt;
    C[&amp;quot;모든 MCP 클라이언트&amp;quot;] --&amp;gt;|&amp;quot;https://s3wiki.yonsei.ac.kr/mcp&amp;lt;br/&amp;gt;읽기는 공개, 변경은 운영 모드로 인증&amp;quot;]| P&lt;br /&gt;
    P --&amp;gt;|&amp;quot;/mcp 이외의 모든 경로&amp;quot;| W[&amp;quot;Semantic MediaWiki + Page Forms&amp;quot;]&lt;br /&gt;
    P --&amp;gt;|&amp;quot;/mcp 및 /mcp/*&amp;quot;| M[&amp;quot;중앙 mcp-http&amp;quot;]&lt;br /&gt;
    M --&amp;gt;|&amp;quot;인증된 제한형 MediaWiki API 어댑터&amp;quot;| W&lt;br /&gt;
    W --&amp;gt; D[(&amp;quot;MariaDB: 기준 페이지, 의미 속성, 리비전&amp;quot;)]&lt;br /&gt;
    W --&amp;gt; U[(&amp;quot;업로드 볼륨&amp;quot;)]&lt;br /&gt;
    M --&amp;gt; S[&amp;quot;검색 조정 계층&amp;quot;]&lt;br /&gt;
    S --&amp;gt;|&amp;quot;전체 텍스트·relation·최신 snapshot&amp;quot;| W&lt;br /&gt;
    S -. &amp;quot;선택한 hybrid_v1&amp;quot; .-&amp;gt; V[&amp;quot;프로세스 메모리 exact vector index&amp;quot;]&lt;br /&gt;
    W -. &amp;quot;시작 프리웜·dirty 즉시·due 백그라운드 대조&amp;quot; .-&amp;gt; V&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Semantic MediaWiki는 유일한 기준 저장소입니다. Lesson 내용은 MediaWiki에만 저장합니다. 중앙 MCP 서비스는 데이터를 저장하지 않고 정책만 적용하는 어댑터이며 별도의 지식 저장소가 아닙니다. 따라서 MariaDB와 업로드 볼륨을 함께 백업하며, MCP 전송 상태는 복구 대상에 포함하지 않습니다. Caddy는 TLS 종료와 경로 전달만 맡습니다. 인증서 상태는 Docker 볼륨에 남지만 기준 Lesson 데이터에는 속하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_WIKI_DIRECT_URL&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 명시한 서버는 같은 MediaWiki 화면을 직접 HTTP 포트로도 열 수 있습니다. 정확히 설정한 Host만 이 주소를 선택하고 정식 링크와 백그라운드 작업은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;MW_SERVER_URL&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 HTTPS 도메인을 계속 사용합니다. 이 입구는 위키 프런트엔드 하나를 추가하는 것이 아니라 동일 프로세스의 요청 URL 선택이며, MCP나 Lesson 저장소를 하나 더 만들지 않습니다.&lt;br /&gt;
&lt;br /&gt;
=== 연구실 공용 MCP 연결 ===&lt;br /&gt;
&lt;br /&gt;
에이전트는 장기 실행 서비스 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 한 개와 Streamable HTTP 엔드포인트 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;https://s3wiki.yonsei.ac.kr/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 한 곳만 사용합니다. 각 컴퓨터에서 별도 어댑터나 지식 사본을 실행하지 않습니다. 모든 클라이언트는 같은 어댑터를 거쳐 내부 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;http://mediawiki/api.php&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 접근합니다. 한 클라이언트가 저장한 Lesson은 복제나 내보내기 없이 다음 검색부터 다른 클라이언트에도 보입니다. 동시에 저장할 때는 MediaWiki 리비전과 편집 충돌 검사가 쓰기 순서를 정합니다.&lt;br /&gt;
&lt;br /&gt;
원격 연결에는 다음 네 가지 제한을 둡니다.&lt;br /&gt;
&lt;br /&gt;
# &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_PUBLIC_URL&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 외부에 공개할 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 주소를 고정하고 정확한 Host 및 Origin 허용 목록을 만듭니다.&lt;br /&gt;
# 기본 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;token&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드에서는 초기화, 도구 목록 조회, 읽기 도구 다섯 개를 인증 없이 쓸 수 있습니다. 생성·관계·근거 추가·검증 기록과 기존 Lesson의 조건부 수정은 같은 공유 Bearer token을 확인합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;open&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드는 다섯 변경 도구를 공개합니다. 선택 사항인 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;github&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드는 GitHub 로그인을 상류 인증으로 사용하고 MCP 연결 전체에 자체 OAuth token을 요구합니다.&lt;br /&gt;
# 공개 URL의 정확한 Host와 Origin은 자동으로 허용합니다. 명시적인 역방향 프록시 값이 더 필요하면 쉼표로 구분한 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_ALLOWED_HOSTS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_ALLOWED_ORIGINS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 추가합니다. 와일드카드는 허용하지 않습니다.&lt;br /&gt;
# MCP 프로세스는 최소 권한 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;agent&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 계정으로 MediaWiki에 로그인합니다. 유효한 토큰이 있어도 제한된 도메인 작업과 이 계정에 부여된 위키 권한을 넘을 수 없습니다. 조건부 수정은 MCP와 MediaWiki 훅만 공유하는 HMAC 키로 서명하므로 위키 계정 비밀번호만 가진 직접 Action API 요청은 revision 표식을 만들 수 없습니다.&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;./scripts/configure-public-domain.sh s3wiki.yonsei.ac.kr --expected-ip 165.132.118.220&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 실행하면 MCP의 호스트 직접 연결 포트가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;127.0.0.1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 묶이고 HTTPS가 필수가 되며 Compose &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;domain&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 프로필이 켜집니다. Caddy는 80번과 443번 포트를 열어 TLS를 종료하고, 경로를 삭제하거나 바꾸지 않은 채 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 전달합니다. Caddy는 caller가 보낸 client IP·proxy secret 헤더를 덮어쓰고, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 별도 서버 비밀값이 맞을 때만 전달된 IP를 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;open&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드 rate principal로 사용합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;token&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드에서 토큰이 없는 클라이언트는 공개 조회를 쓸 수 있고, 공용 토큰을 헤더에 넣을 수 있는 클라이언트는 다섯 변경 도구를 쓸 수 있습니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;open&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드는 호스팅 커넥터에도 다섯 변경 도구를 공개합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;github&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드는 같은 프로세스의 OAuth discovery, 동적 client 등록, GitHub callback, token·refresh·revoke 경로를 사용합니다. Lesson 저장소는 계속 MediaWiki 하나뿐입니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;GET /livez&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 MCP 프로세스만 확인하는 작은 비인증 경로입니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;GET /healthz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;GET /readyz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 canonical MediaWiki 로그인 가능 여부와 공유 상태 파일에서 확인한 bounded job runner freshness를 모두 readiness 조건으로 사용합니다. 응답에는 bounded admission·semantic 갱신 상태도 내보내지만 설정값이나 Lesson 내용은 내보내지 않습니다. 세 경로 모두 에이전트 도구는 아닙니다. 현재 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 인증 없이 초기화와 도구 조회를 처리하고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;search_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;get_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;audit_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;find_related_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;find_analogies&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 실행합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;token&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;create_lesson_draft&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;link_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;attach_evidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;record_evidence_verification&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;revise_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에는 같은 공용 Bearer 토큰이 필요합니다. GitHub 모드에서는 다섯 도구에 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp:write&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; scope가 필요합니다.&lt;br /&gt;
&lt;br /&gt;
파일럿은 공용 쓰기 토큰 다이제스트와 공용 MediaWiki 에이전트 계정 한 개를 사용합니다. MediaWiki에는 서비스 계정과 모든 페이지 리비전이 기록됩니다. 토큰을 사용한 컴퓨터, 모델, 사람은 구분하지 않습니다. 개인별 토큰 폐기나 확인된 작성자 구분이 필요해지면 제한된 도구 권한은 그대로 두고 OAuth/OIDC 또는 별도 전송 ID를 추가할 수 있습니다. 클라이언트가 보낸 author 필드나 메모는 참고 정보일 뿐, 확인된 신원은 아닙니다.&lt;br /&gt;
&lt;br /&gt;
=== 기준 페이지 계약 ===&lt;br /&gt;
&lt;br /&gt;
브라우저 화면은 한국어로 표시합니다. 기준 식별자, 템플릿 매개변수 이름, 속성 이름, 이전 버전 호환 상태값, 근거 종류, 관계 토큰, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Lesson:&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 이름공간은 영문 기계 계약을 그대로 씁니다. 한국어 Page Forms 라벨과 매핑 템플릿은 화면에 보일 때만 이 값을 번역합니다. MediaWiki의 기본 이름공간과 화면 문구가 한국어로 바뀌어도 기존 MCP 클라이언트와 저장된 리비전은 그대로 작동합니다.&lt;br /&gt;
&lt;br /&gt;
각 Lesson은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Lesson:&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 이름공간의 페이지 한 건입니다. 전체 페이지 제목을 고정 식별자로 씁니다. 예를 들면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Lesson:gpu_allocator_fragmentation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;입니다. 화면에 보이는 제목은 식별자를 바꾸지 않고 수정할 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
페이지에는 다음 매개변수를 정확히 사용하는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 템플릿 한 개가 들어갑니다.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 필드 !! 뜻&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;title&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 화면에 표시할 Lesson 제목&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;question&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 작업을 시작하게 된 질문&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;attempt&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 시도한 방법&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;context&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 하드웨어, 워크로드, 버전, 규모, 제약 조건&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;observation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 직접 관찰한 내용&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;interpretation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 관찰한 결과가 나온 이유에 대한 해석&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;reusable_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 짧게 정리한 재사용 가능한 내용&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;applicability&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 다시 적용할 수 있는 조건과 범위&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;confidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;low&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;medium&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;high&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 중 하나&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;evidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 사람이 읽을 수 있는 근거 개요&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;record_origin&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 바꿀 수 없는 출처 분류인 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;lab&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;imported&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;synthetic&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 중 하나&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;author&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 처음 작성한 사람 또는 계정&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;reviewer&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 이전 버전 호환 필드. 일반 작성에서는 비워 둠&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;review_state&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 이전 버전 호환 필드. 값은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Draft&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Reviewed&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Stable&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Superseded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이며 일반 작성에서는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Draft&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 사용&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;created_at&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || UTC ISO-8601 생성 시각&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;updated_at&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || UTC ISO-8601 마지막 수정 시각&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
근거 첨부는 반복 가능한 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Lesson evidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 템플릿으로 저장합니다. 필드는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;citation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 선택 항목인 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;url&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;kind&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;verification_basis&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;note&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;added_by&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;added_at&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;입니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;verification_basis&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;full_text&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;official_abstract&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;partial_source&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;metadata_only&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;not_recorded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 중 하나이며, 자료에서 실제로 확인한 범위를 뜻합니다. 이는 주장의 종합 신뢰도인 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;confidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 다릅니다. URL이 없어도 citation은 반드시 입력합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;not_recorded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 이전 자료의 확인 범위가 없음을 나타내는 호환값이며 MCP의 새 evidence에는 허용하지 않습니다. 브라우저 양식에도 기본값이 없고 새 항목에서 선택할 수 없습니다. 브라우저에서 새 Lesson을 작성할 때는 구조화된 근거 한 건 이상이 필요합니다. MCP는 초기 evidence 한 건을 Lesson과 같은 revision에 원자적으로 만들고, 이후 추가 evidence만 별도 제한 작업으로 처리합니다. high confidence 생성에는 아래 검증 기록이 초기 evidence와 같은 revision에 필요합니다. 이 기록은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;full_text&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 또는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;official_abstract&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supports&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이고 observation, interpretation, reusable_lesson을 모두 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;claim_fields&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 연결해야 합니다.&lt;br /&gt;
&lt;br /&gt;
근거 검증은 반복 가능한 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Lesson evidence verification&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 템플릿으로 저장합니다. 필드는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;evidence_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;evidence_digest&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;verification_basis&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;source_identity&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;source_sha256&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;source_locator&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;coverage&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;outcome&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;claim_fields&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;verified_by&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;verified_at&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;입니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;outcome&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supports&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;contradicts&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;inconclusive&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 중 하나입니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;claim_fields&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;question&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;attempt&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;context&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;observation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;interpretation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;reusable_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;applicability&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 중 자료가 실제로 검사한 주장만 나열합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;source_sha256&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 실제로 검사한 원본 파일 바이트의 SHA-256입니다. 원본 대신 저장·정규화한 텍스트를 검사했다면 그 정확한 텍스트의 UTF-8 바이트를 해시합니다. 이 경우 변환 방법과 보존한 산출물을 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;source_identity&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;coverage&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 명시해 다시 동일한 바이트를 만들 수 있게 합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;source_locator&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에는 페이지·절·라인·실험 구간 등 실제 위치를, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;coverage&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에는 읽은 범위와 제외 범위를 적습니다.&lt;br /&gt;
&lt;br /&gt;
검증 기록은 추가 후 수정·삭제하지 않습니다. 같은 evidence를 다시 확인하면 새 기록을 append-only로 붙이고 마지막 기록을 현재 판정으로 사용합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;verified_at&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 이전 Lesson &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;updated_at&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;과 이전 검증 시각보다 반드시 늦고, 새 Lesson &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;updated_at&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;보다 늦을 수 없습니다. 시각이 같거나 거꾸로인 기록은 현재 판정을 추측하지 않고 감사 대상으로 남깁니다.&lt;br /&gt;
&lt;br /&gt;
MediaWiki revision의 actor와 timestamp가 브라우저·raw 저장의 권위 출처입니다. 저장 훅은 새 Lesson의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;author&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 새 하위 항목의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;added_by&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;created_by&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;verified_by&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 현재 저장 계정과 정확히 같은지 확인합니다. 새 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;created_at&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;updated_at&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;added_at&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, relation의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;created_at&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;verified_at&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 revision 저장 시각 전후 5분 안이어야 합니다. 기존 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;created_at&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 변경할 수 없고, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;updated_at&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;과 새로 append하는 하위 시각은 이전 revision·기록보다 늦어야 하는 monotonic 보조 필드입니다. 양식의 검증 출처·시각 필드는 숨김 표시되며, 기존 evidence·relation·검증 기록과 그 출처는 변경할 수 없습니다. 새 Lesson은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;created_at=updated_at&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 저장하고, 초기 evidence와 초기 검증의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;verification_basis&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 다르면 거부합니다.&lt;br /&gt;
&lt;br /&gt;
관계는 반복 가능한 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Lesson relation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 템플릿으로 저장합니다. 필드는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;from&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;relation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;to&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 선택 항목인 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;note&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;created_by&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;created_at&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;입니다. 사용할 수 있는 relation 값은 다음으로 제한합니다.&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supports&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;contradicts&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;analogous_to&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;failed_because&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;works_when&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;suggests&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supersedes&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
관계에는 방향이 있습니다. 조회할 때 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;analogous_to&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 대칭 관계처럼 보여 주지만, 출처를 남기기 위해 저장된 양쪽 끝점은 명시적으로 유지합니다. 같은 간선을 중복해서 저장하면 MCP 도메인 계층이 거부합니다. 브라우저와 에이전트 모두 이미 존재하는 Lesson만 관계 대상으로 지정할 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
=== 저장과 수정 ===&lt;br /&gt;
&lt;br /&gt;
저장에 성공한 Lesson은 기준 위키에 바로 반영되며 브라우저와 MCP에서 즉시 검색할 수 있습니다. 수정 이력과 근거는 다음과 같이 관리합니다.&lt;br /&gt;
&lt;br /&gt;
* MediaWiki 리비전으로 모든 변경을 남기고 되돌릴 수 있습니다.&lt;br /&gt;
* 구조화된 근거로 주장의 출처를 확인할 수 있습니다.&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;confidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 현재 근거의 수준을 표시합니다.&lt;br /&gt;
* 형식이 정해진 관계로 지지, 반박, 유사점, 적용 조건, 대체 관계를 표시합니다.&lt;br /&gt;
* Page Forms, MCP 모델, 도메인 계층에서 같은 스키마를 검사합니다.&lt;br /&gt;
같은 주장이나 같은 원천 논문의 내용을 정정·보강할 때는 같은 Lesson ID에 새 리비전을 추가합니다. evidence note나 별도 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Technical Review&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; Lesson은 본문 수정 수단이 아닙니다. 독립된 주장이 기존 판단을 실제로 대신할 때만 새 Lesson을 만들고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supersedes&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 연결합니다. 충돌하거나 범위를 제한하는 별도 주장은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;contradicts&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;나 다른 알맞은 관계로 연결합니다. 이전 판단은 삭제하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
다섯 쓰기 도구는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;operation_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 요청 digest를 사용합니다. 생성 시 자동 Lesson·evidence ID도 이 요청에서 결정되므로 응답이 유실돼도 같은 요청이 중복 레코드를 만들지 않습니다. 생성에는 구조화된 초기 evidence가 필수이며 Lesson과 같은 revision에 저장됩니다. 같은 Lesson 안에서 S3W 일반 쓰기, S3R revision, S3V 검증 기록은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;operation_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 충돌 범위를 공유합니다. 같은 ID·종류·요청 digest의 정확한 재시도만 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;replayed&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이고, 다른 종류나 digest면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;idempotency_conflict&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;입니다. 다른 Lesson은 같은 ID를 쓰더라도 서로 간섭하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;record_evidence_verification&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 현재 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;expected_revision_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 대상 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;expected_evidence_digest&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 모두 맞을 때만 새 검증 기록을 추가합니다. 검증은 evidence, Lesson 주장, relation을 다시 쓰지 않습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;revise_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 현재 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;expected_revision_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 맞을 때만 허용된 핵심 필드를 바꾸고, 이미 해당 Lesson에 붙은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;basis_evidence_ids&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 1~8개를 요구합니다. ID, 출처, 최초 작성자·생성 시각, 호환 필드, relation·evidence·검증 기록 목록은 바꾸지 않습니다. MediaWiki 편집 충돌은 자동 병합하지 않습니다. 요청 digest, 모든 basis evidence, 실제 변경 필드와 본문 digest를 HMAC으로 봉인해 revision 요약에 남깁니다. 서명·작성자·부모 revision·본문 digest가 모두 맞는 이력만 replay로 인정합니다. 최대 10,000개 revision을 페이지 단위로 확인하며 같은 요청은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;replayed&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 같은 ID의 다른 요청은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;idempotency_conflict&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;입니다. replay 응답의 적용 revision과 현재 최신 revision은 별도 필드로 반환합니다. 최신 검증 기록 중 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;full_text&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 또는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;official_abstract&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supports&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이며 실제 변경 필드를 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;claim_fields&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 연결한 근거만 저장 후 결과 confidence가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;medium&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 또는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;high&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;인 observation, interpretation, reusable_lesson 수정을 뒷받침합니다. confidence를 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;high&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 하면 세 필드 전부를 뒷받침해야 합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;contradicts&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;inconclusive&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 지지 근거가 아닙니다. 결과 confidence가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;low&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;인 교정은 기존 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;medium&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;/&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;high&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 낮추는 경우와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;low&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 유지하는 경우 모두 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;not_recorded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 아닌 basis evidence로 할 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;reviewer&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;review_state&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 이전 리비전을 읽기 위한 고정 호환 필드입니다. 화면, 검색 순서, 공개 여부에는 쓰지 않습니다.&lt;br /&gt;
&lt;br /&gt;
MCP 읽기 도구는 인증 없이 쓸 수 있습니다. 공유 쓰기 자격 증명이 있으면 다음 작업을 할 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;lab&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; Lesson 생성&lt;br /&gt;
* 기존 Lesson에 검증된 관계 한 건 추가&lt;br /&gt;
* 기존 Lesson에 인용이 있는 근거 한 건 추가&lt;br /&gt;
* 기존 evidence에 append-only 검증 기록 한 건 추가&lt;br /&gt;
* 기준 revision과 기존 근거를 전제로 같은 Lesson의 허용된 핵심 필드를 새 revision으로 수정&lt;br /&gt;
서버 관리 필드는 클라이언트가 선택하거나 바꿀 수 없습니다. 페이지 삭제, 이동, 보호, 관리도 허용하지 않습니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supersedes&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 관계를 추가해도 대상 Lesson은 변경되지 않습니다. 클라이언트가 예상하지 않은 필드를 보내면 도메인 계층이 거부하고, MediaWiki 계정에도 관리 권한을 부여하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
Lesson이 바뀔 때마다 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;updated_at&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 이전 값보다 반드시 늦어야 합니다. 에이전트가 근거, 관계나 검증 기록을 추가하면 새 항목의 출처 시각에도 같은 값을 사용합니다. 생성·관계·근거·검증 추가 응답도 적용 뒤 현재 revision ID를 반환하므로 다음 조건부 수정을 이어 갈 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
=== 검색 구현 ===&lt;br /&gt;
&lt;br /&gt;
기본 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;retrieval_mode=legacy&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 MediaWiki 전체 텍스트 검색 결과를 어댑터에서 구조화 필터로 거릅니다. Action API에는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;srwhat=text&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 명시합니다. 이를 생략하면 MediaWiki의 레거시 기본값인 페이지명 검색이 적용되어, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Lesson:&amp;amp;lt;slug&amp;amp;gt;&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 본문에 저장된 한국어 필드를 찾지 못합니다. MySQL 검색 엔진은 색인과 질의에 같은 UTF-8 정규화 및 짧은 단어 길이 보정을 적용하므로 MariaDB의 최소 단어 길이는 기본값을 사용합니다. 유사점 검색에는 Unicode NFKC 정규화, Unicode 단어, 한국어 2글자 및 3글자 n-gram을 사용합니다. MediaWiki 리비전 내용은 한 번에 최대 50페이지씩 가져옵니다. 초기 데이터 규모에서 운영하기에 충분하며 구성이 단순합니다.&lt;br /&gt;
&lt;br /&gt;
의미 검색을 켠 서버에서 호출자가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;retrieval_mode=hybrid_v1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 명시하면 세 가지 facet을 사용합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;situation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 질문·조건·관찰, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;approach&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 시도, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mechanism&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 해석·재사용 가능한 결론·적용 조건으로 구성합니다. 전체 텍스트, 저장된 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;analogous_to&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 의미 후보는 versioned RRF 규칙으로 결합합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;find_analogies&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 결합 점수는 raw cosine이 아니며 의미 후보를 relation으로 저장하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_SEARCH_SCOPES_JSON&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 서버 담당자가 이름 있는 검색 scope를 최대 16개까지 설정할 수 있습니다. scope는 기존 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;SearchFilters&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 bounded 묶음이며 호출자의 필터와 교집합으로 적용됩니다. Lesson 필드나 별도 저장소가 아니고, 현재는 relevance 범위만 정하며 접근 제어를 대신하지 않습니다. 응답과 cursor fingerprint에는 실제 scope가 포함됩니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_SEARCH_SCOPE_PRINCIPALS_JSON&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 함께 설정하면 scope 사용 자체에 optional principal ACL을 적용합니다. GitHub subject 또는 토큰의 짧은 hash 식별자만 저장하며 원문 자격 증명은 저장하지 않습니다. ACL이 설정된 scope는 익명 호출을 거부하고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;search_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;·&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;find_analogies&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 먼저 확인합니다.&lt;br /&gt;
&lt;br /&gt;
외부 자료 연결은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp/src/s3_research_memory_mcp/connectors.py&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 읽기 전용 candidate connector와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;scripts/collect-candidates.py&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 수행합니다. code/docs 파일과 Slack export를 bounded 후보로 만들 뿐 Lesson이나 evidence를 자동 저장하지 않습니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;scripts/research-query.py&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 세 facet을 병렬 검색하고 RRF로 결합하며, 선택적으로 OpenAI-compatible reranker와 인용 제한 synthesis를 실행합니다.&lt;br /&gt;
&lt;br /&gt;
파생 exact index는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 프로세스 메모리에만 있습니다. HTTP transport와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;EmbeddingProfile&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 분리하며, profile은 모델 artifact digest, 차원, pooling, query/document 형식, facet별 instruction과 입력 상한을 고정합니다. 이 profile과 projection 구성을 함께 해시해 generation을 만듭니다. 외부 Streamable HTTP 요청은 stateless로 처리하지만 위키 어댑터와 exact index는 ASGI 프로세스 lifespan에서 한 번만 만들고 모든 요청이 공유합니다. 서비스 시작 시 단 하나의 백그라운드 작업이 MediaWiki snapshot 전체를 읽어 인덱스를 프리웜합니다. MCP로 새 Lesson을 저장하면 dirty hint가 즉시 같은 단일 갱신을 시작합니다. 대조 주기가 지나면 다음 hybrid 요청이 갱신을 시작합니다. 대조한 Lesson 수와 projection hash가 모두 같으면 재임베딩 없이 대조 시각만 갱신합니다. 어느 경우에도 공개 요청은 전체 재구축을 기다리지 않습니다. 사용할 snapshot이 있으면 갱신 중에도 그 snapshot을 사용하고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;stale&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 또는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;degraded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 상태를 표시합니다. 아직 snapshot이 없으면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;building&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 또는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;unavailable&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 상태로 즉시 legacy 검색을 반환합니다. 새 인덱스는 옆에서 완성한 뒤 원자적으로 교체합니다.&lt;br /&gt;
&lt;br /&gt;
후보는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Lesson:&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; ID와 retrieval hash만 제공하며, 최종 필터와 응답 본문은 최신 MediaWiki snapshot으로 다시 확인합니다. hash가 달라진 의미 기여는 제거하고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;semantic_status=stale&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 표시합니다. 제공자 장애나 과부하 시 첫 페이지는 legacy로 대체하며, 이미 발급한 hybrid cursor의 순서를 중간에 바꾸지 않습니다.&lt;br /&gt;
&lt;br /&gt;
MCP 서비스는 같은 열 도구만 외부에 제공합니다. exact index의 재구축 시간, 메모리 또는 지연 시간이 실제 목표를 넘을 때만 같은 비공개 backend 경계를 내부 sidecar나 공유 검색 서비스로 교체합니다. 필드 및 출처 필터와 이전 버전 호환 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;review_state&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 필터, 현재 응답 제한은 계속 보존합니다. 내부 경계, 색인 세대, 장애 fallback과 단계별 확장안은 [[Help:S3 연구 메모리/벡터·하이브리드 검색 설계|벡터·하이브리드 검색 설계]]에 정리합니다.&lt;br /&gt;
&lt;br /&gt;
현재 서버의 선택적 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;embedding&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; Compose profile은 고정된 Qwen3 F16 GGUF를 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;llama.cpp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; Vulkan으로 실행합니다. 모델 downloader만 별도 egress 네트워크를 쓰고, 추론 서비스는 호스트 포트 없이 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 공유하는 전용 내부 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;embedding-backend&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에만 연결합니다. 이 서비스와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;embedding_models&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 볼륨은 파생 계산 계층이며 Semantic MediaWiki를 대신하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
=== 출력 제한 ===&lt;br /&gt;
&lt;br /&gt;
모든 MCP 목록 작업에는 작은 기본 페이지 크기, 엄격한 최댓값, 커서 기반 페이지 나누기, 필드 선택, UTF-8 직렬화 응답의 바이트 상한이 있습니다. 스캔 응답에는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;partial&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;scanned&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;scan_limit&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 들어갑니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;partial=true&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이면 검색 결과가 완전하지 않은 것으로 처리해야 합니다. 기본 결과에는 ID, 제목, 한 줄짜리 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;reusable_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 출처, confidence, 짧은 근거 인용이 들어갑니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;get_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 현재 revision ID를 포함하고 인용, 검증 기록과 관계를 각각 나누어 반환합니다. 각 하위 목록은 기본 5개, 최대 20개의 독립 cursor 페이지입니다. 기본 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;citation_detail=compact&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 짧은 인용만, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;full&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 note와 추가한 주체·시각까지 반환합니다. full 인용은 한 항목도 조용히 자르지 않고 페이지 크기를 줄이며, 한 항목도 출력 한도에 맞지 않으면 명시적인 오류를 반환합니다. 본문 필드는 필요할 때만 직접 선택합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;audit_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 정확한 ID 기대값을 최대 50개 읽고 결과를 최대 20개씩 반환합니다. revision, 내용 digest, 최대 8개 evidence와 최대 8개 relation을 확인하며, 페이지 사이 어느 대상 revision이라도 바뀌면 이전 cursor를 거부합니다. 긴 본문을 반복 반환하지 않고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;ok&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;missing&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mismatch&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 실패한 기대값 위치만 반환합니다.&lt;br /&gt;
&lt;br /&gt;
열 도구는 모두 닫힌 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;outputSchema&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 공개합니다. 성공 응답의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;structuredContent&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 이 스키마로 검증하며, 기존 클라이언트가 읽는 compact JSON text도 같은 내용으로 유지합니다.&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC/%EB%A9%94%EB%AA%A8_%EC%82%AC%EC%9A%A9%EB%B2%95&amp;diff=2772</id>
		<title>도움말:S3 연구 메모리/메모 사용법</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC/%EB%A9%94%EB%AA%A8_%EC%82%AC%EC%9A%A9%EB%B2%95&amp;diff=2772"/>
		<updated>2026-07-20T05:08:47Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 저장소 문서 동기화&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;!-- S3RM managed repository document; edit the Markdown source, not this page. --&amp;gt;&lt;br /&gt;
&#039;&#039;이 페이지는 저장소 문서에서 자동으로 동기화됩니다. 위키에서 직접 편집하지 마세요.&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
S3 연구 메모리는 프로젝트 일지나 실험 원본을 모으는 곳이 아닙니다. 다른 작업에서도 다시 참고할 만한 시행착오를 짧은 메모로 남깁니다.&lt;br /&gt;
&lt;br /&gt;
=== 먼저 검색하기 ===&lt;br /&gt;
&lt;br /&gt;
브라우저 메인 화면의 검색창에는 궁금한 점을 문장으로 입력할 수 있습니다. 표현이 달라도 관련된 메모를 함께 찾습니다. 검색 결과는 그 아래 지식 그래프 중앙에 한 군집으로 모이고, 결과가 아닌 메모는 흐리게 표시됩니다. 점을 누르면 연결 수와 관계 유형을 볼 수 있고, 두 번 누르면 메모가 열립니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;검색 결과 해제&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 누르면 전체 그래프로 돌아갑니다. 전체 그래프는 연결된 메모를 중앙에 모으고 연결이 없는 메모를 바깥쪽에 조밀하게 배치합니다. 연결된 메모의 제목은 처음부터 표시됩니다. 제목 필터, 관계 필터, 확대와 이동도 사용할 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
본문까지 찾으려면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Special:Search&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 에이전트에서는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;search_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 사용합니다. 프로젝트 이름보다 현상과 원인을 나타내는 말로 찾는 편이 잘 맞습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
메모리 단편화, retry storm, lock contention, cold start&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
같은 내용이 이미 있으면 새 메모를 만들지 않습니다. 기존 메모에 자료를 더하거나, 내용이 다른 이유를 관계로 연결하면 됩니다.&lt;br /&gt;
&lt;br /&gt;
=== 브라우저에서 메모 쓰기 ===&lt;br /&gt;
&lt;br /&gt;
로그인한 뒤 다음 화면을 엽니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
/index.php/연구_메모리#create-a-lesson&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
문서 ID에는 영문 소문자, 숫자, 밑줄, 하이픈만 씁니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Lesson:&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 입력하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
gpu_allocator_fragmentation&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
문서 ID는 에이전트가 메모를 인용할 때도 쓰므로 날짜, 사람 이름, 프로젝트에서만 통하는 줄임말은 피하세요. 화면에 보이는 제목은 나중에 바꿀 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
입력란에는 다음 내용을 적습니다.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 화면 !! 필드 !! 적을 내용&lt;br /&gt;
|-&lt;br /&gt;
| 제목 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;title&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 메모의 결론이 드러나는 짧은 제목&lt;br /&gt;
|-&lt;br /&gt;
| 궁금했던 점 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;question&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 시도를 시작한 이유&lt;br /&gt;
|-&lt;br /&gt;
| 해본 것 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;attempt&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 실제로 바꿨거나 비교한 것&lt;br /&gt;
|-&lt;br /&gt;
| 당시 조건 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;context&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 장비, 버전, 설정, 워크로드 등 결과에 영향을 준 조건&lt;br /&gt;
|-&lt;br /&gt;
| 실제 결과 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;observation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 직접 확인한 수치나 동작&lt;br /&gt;
|-&lt;br /&gt;
| 왜 그랬는지 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;interpretation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 가능한 원인과 아직 확실하지 않은 부분&lt;br /&gt;
|-&lt;br /&gt;
| 다음에 기억할 것 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;reusable_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 다른 작업에서도 써먹을 한 문장&lt;br /&gt;
|-&lt;br /&gt;
| 언제 맞는지 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;applicability&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 맞는 조건과 예외&lt;br /&gt;
|-&lt;br /&gt;
| 신뢰도 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;confidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 자료의 질에 따라 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;low&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;medium&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;high&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 중 선택&lt;br /&gt;
|-&lt;br /&gt;
| 관련 자료 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;evidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아래에 연결할 자료를 짧게 설명&lt;br /&gt;
|-&lt;br /&gt;
| 자료 출처 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;record_origin&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 직접 얻은 내용은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;lab&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 외부 자료를 정리했으면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;imported&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
관찰과 해석은 나눠 쓰세요. 예를 들어 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;동시 compaction 16개에서 p99가 18 ms에서 71 ms로 늘었다&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 관찰입니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;공유 writeback 제한 때문에 queue가 묶였다&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 해석이며, 새 자료가 나오면 달라질 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
저장한 메모는 바로 검색되고 메인 화면의 지식 그래프와 최근 메모에도 나타납니다.&lt;br /&gt;
&lt;br /&gt;
=== 자료 연결하기 ===&lt;br /&gt;
&lt;br /&gt;
메모마다 자료를 하나 이상 연결합니다. 원본 전체를 위키에 복사하지 말고 나중에 다시 찾을 수 있는 위치를 적으세요.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 입력란 !! 내용&lt;br /&gt;
|-&lt;br /&gt;
| 자료 ID || 메모 안에서 겹치지 않는 짧은 ID&lt;br /&gt;
|-&lt;br /&gt;
| 출처 || 저장소, 커밋, 실행 ID, 문서 절, 시간 구간 등&lt;br /&gt;
|-&lt;br /&gt;
| URL || 바로 열 수 있는 주소가 있을 때만 입력&lt;br /&gt;
|-&lt;br /&gt;
| 자료 종류 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;paper&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;code&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;dataset&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;log&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;benchmark&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;other&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 중 선택&lt;br /&gt;
|-&lt;br /&gt;
| 확인 범위 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;full_text&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;official_abstract&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;partial_source&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;metadata_only&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 중 선택&lt;br /&gt;
|-&lt;br /&gt;
| 메모 || 이 자료에서 확인할 부분과 한계&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
git yonsei-s3/kernel-probes@4f19a2e, results/lockstat/run-031/summary.json&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
확인 범위는 자료에서 실제로 읽은 범위이며 Lesson의 종합 신뢰도인 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;confidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 다릅니다. 서지정보만 확인했다면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;metadata_only&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 표시하고 기술 내용을 추측하지 않습니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;not_recorded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 이전 근거에 값이 없을 때 표시되는 호환값이므로 새 근거에는 선택하지 않습니다. 브라우저 양식에도 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;not_recorded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 기본값이 없고 새 근거에 선택할 수 없습니다. MCP로 새 근거를 붙일 때는 나머지 네 값 중 하나를 명시해야 합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;latest&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;대시보드&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 작성자만 아는 로컬 경로처럼 시간이 지나면 찾기 어려운 표현은 피합니다. 비밀번호, 토큰, 개인 정보, 공개할 수 없는 대화, 대용량 원시 데이터도 본문에 넣지 않습니다.&lt;br /&gt;
&lt;br /&gt;
=== 근거를 검증한 기록 남기기 ===&lt;br /&gt;
&lt;br /&gt;
자료를 실제로 읽어 Lesson 주장을 확인했다면 기존 evidence를 고치지 말고 새 검증 기록을 붙입니다. 같은 evidence를 나중에 다시 확인했다면 이전 기록을 수정·삭제하지 않고 새 기록을 append-only로 붙입니다. evidence별 마지막 기록이 현재 판정입니다.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 입력란 !! 내용&lt;br /&gt;
|-&lt;br /&gt;
| evidence ID || 검사한 기존 근거 ID&lt;br /&gt;
|-&lt;br /&gt;
| evidence digest || 읽기 시점의 evidence 내용 digest&lt;br /&gt;
|-&lt;br /&gt;
| 확인 범위 || 이번에 실제로 검사한 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;verification_basis&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| 자료 식별자 || 파일, 커밋, 데이터 버전, 문서 버전 등 고정 식별자&lt;br /&gt;
|-&lt;br /&gt;
| 자료 SHA-256 || 실제로 검사한 정확한 바이트의 SHA-256&lt;br /&gt;
|-&lt;br /&gt;
| 위치 || 페이지, 절, 라인, 실험 구간 등 주장을 확인한 위치&lt;br /&gt;
|-&lt;br /&gt;
| 확인 범위와 한계 || 읽은 범위와 제외한 부분&lt;br /&gt;
|-&lt;br /&gt;
| 판정 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supports&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;contradicts&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;inconclusive&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 중 하나&lt;br /&gt;
|-&lt;br /&gt;
| 연결 주장 || 자료가 실제로 확인한 Lesson 필드&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
원본 파일을 검사했다면 그 파일 바이트를 해시합니다. 원본 대신 저장·정규화한 텍스트를 검사했다면 그 정확한 텍스트의 UTF-8 바이트를 해시합니다. 이 경우 변환 방법과 보존한 산출물을 자료 식별자와 확인 범위에 명시합니다. MediaWiki revision의 저장 계정과 시각이 권위 출처입니다. 검증 기록의 출처는 현재 저장 계정과 정확히 같아야 하고, 기록 시각은 revision 저장 시각 전후 5분 안이며 이전 기록보다 늦어야 하는 보조 필드입니다. 기존 출처와 시각은 수정할 수 없습니다.&lt;br /&gt;
&lt;br /&gt;
high confidence는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;full_text&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 또는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;official_abstract&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 기반으로 한 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supports&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 검증이 observation, interpretation, reusable_lesson 세 필드를 모두 뒷받침할 때만 사용합니다. observation, interpretation, reusable_lesson을 수정한 후 결과 confidence가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;medium&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 또는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;high&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이면 실제로 바꾸는 주장과 연결한 같은 강도의 검증이 필요합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;contradicts&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;나 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;inconclusive&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 지지 근거로 세지 않습니다. 결과 confidence가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;low&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;인 교정은 기존 confidence를 낮추는 경우를 포함해 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;not_recorded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 아닌 basis evidence로 할 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
=== 다른 메모와 연결하기 ===&lt;br /&gt;
&lt;br /&gt;
관계는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;출발 메모 관계값 대상 메모&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 순서로 읽습니다.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 관계 !! 뜻&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supports&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 대상 메모와 맞는 자료나 원인을 보탬&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;contradicts&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 대상 메모의 주요 주장과 관찰이 충돌함&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;analogous_to&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 분야는 달라도 같은 원인이나 실패 형태를 보임&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;failed_because&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 대상 메모에 적힌 이유로 시도가 실패함&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;works_when&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 대상 메모의 조건에서 방법이 잘 작동함&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;suggests&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 다음 가설이나 시도를 제안함&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supersedes&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 새 메모가 이전 안내를 대신함. 상태는 자동으로 바뀌지 않음&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
연결 이유에는 두 메모가 닮거나 충돌하는 지점을 한두 문장으로 씁니다. 관련성이 애매하면 억지로 연결하지 않아도 됩니다.&lt;br /&gt;
&lt;br /&gt;
=== 메모 고치기 ===&lt;br /&gt;
&lt;br /&gt;
메모 화면에서 &#039;&#039;&#039;메모 편집&#039;&#039;&#039;을 누릅니다. 잘못 적은 수치나 해석을 고치고, 수정 요약에 이유를 짧게 남기세요. 이전 내용은 MediaWiki 이력에서 확인할 수 있습니다. 에이전트가 고칠 때는 현재 revision과 근거를 다시 읽은 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;revise_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 사용합니다. 같은 논문의 보강 내용을 evidence note나 새 Technical Review 메모에 숨기지 않습니다. 새 근거를 먼저 붙였다면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;attach_evidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 응답의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;current_revision_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 다음 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;revise_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;expected_revision_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 사용합니다. 그 사이 다른 변경이 있었다면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;get_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 다시 읽고 변경안을 재계산합니다.&lt;br /&gt;
&lt;br /&gt;
=== MCP로 찾기 ===&lt;br /&gt;
&lt;br /&gt;
처음에는 결과를 3~5개만 받고 필요한 필드만 요청하는 편이 빠릅니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
S3 Research Memory에서 tail latency와 관련된 메모를 최대 5개 찾아 줘.&lt;br /&gt;
id, title, reusable_lesson, applicability, confidence, citations만 반환해.&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
후보를 고른 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;get_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 자세히 읽고, 관계는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;find_related_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 다른 분야의 비슷한 사례는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;find_analogies&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 찾습니다. 근거 note와 추가 정보를 확인하려면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;citation_detail=full&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 지정합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;get_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 revision ID는 이어지는 조건부 수정과 감사에 사용합니다. 답변에 메모를 사용했다면 반환된 자료 인용도 같이 보관하세요.&lt;br /&gt;
&lt;br /&gt;
서버에서 의미 검색을 켠 경우에는 표현이 다른 문제도 찾도록 다음처럼 요청할 수 있습니다. 기본 검색은 계속 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;legacy&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이므로 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;hybrid_v1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 직접 지정합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
search_lessons를 retrieval_mode=hybrid_v1로 실행해,&lt;br /&gt;
문제의 제약과 해결 원리가 비슷한 메모를 최대 5개 찾아 줘.&lt;br /&gt;
effective_mode와 semantic_status도 알려 줘.&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
서버 담당자가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_SEARCH_SCOPES_JSON&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 설정한 경우에는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;scope&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 함께 지정할 수 있습니다. scope는 미리 정한 분야·출처·기간 필터를 호출자의 필터와 교집합으로 적용하며 접근 권한을 바꾸지 않습니다. 검색 품질을 재현해 비교하려면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;make evaluate-retrieval QUERY_FILE=queries.json&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 사용합니다. 파일에는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;query&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;expected_ids&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 선택적인 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;retrieval_mode&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;scope&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;만 넣고, 명령은 읽기 전용 검색만 실행합니다.&lt;br /&gt;
&lt;br /&gt;
의미 유사도는 저장된 관계나 사실이 아닙니다. 후보의 적용 조건과 근거를 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;get_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 확인한 뒤 실제 관계를 남길지 판단하세요.&lt;br /&gt;
&lt;br /&gt;
=== 외부 자료 후보 수집 ===&lt;br /&gt;
&lt;br /&gt;
code checkout, 문서 디렉터리와 Slack export는 다음처럼 읽기 전용 후보로 수집할 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
make collect-candidates ARGS=&#039;--code-root /path/to/repo --docs-root /path/to/docs&#039;&lt;br /&gt;
make collect-candidates ARGS=&#039;--slack-export /path/to/slack-export.json&#039;&lt;br /&gt;
make collect-candidates ARGS=&#039;--code-root /path/to/repo --checkpoint /path/to/state&#039;&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
후보에는 source, locator, 제목과 비밀값이 마스킹된 본문만 들어갑니다. 후보를 자동으로 Lesson으로 저장하지 않으며, 실제 자료를 확인한 뒤 중앙 MCP에서 evidence를 명시적으로 붙입니다.&lt;br /&gt;
&lt;br /&gt;
여러 검색 facet을 병렬로 실행하려면 다음 명령을 사용합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
make research-query QUERY=&#039;cache stampede&#039; ARGS=&#039;--scope papers&#039;&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_RESEARCH_LLM_URL&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;과 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_RESEARCH_LLM_MODEL&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 설정하고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;ARGS=&amp;amp;#x27;--synthesize&amp;amp;#x27;&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 를 추가하면 선택적 rerank와 synthesis를 사용합니다. synthesis는 검색 결과에 존재하는 Lesson ID만 인용할 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
=== 에이전트에게 작성을 맡길 때 ===&lt;br /&gt;
&lt;br /&gt;
쓰기 토큰이 있는 에이전트는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;create_lesson_draft&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;link_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;attach_evidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;record_evidence_verification&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;revise_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 쓸 수 있습니다. 에이전트가 만든 메모와 revision은 저장 즉시 검색됩니다. 상태 변경, 삭제, 이동, 관리자 작업은 MCP에서 할 수 없습니다. 다섯 쓰기 도구는 모두 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;operation_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 필요합니다. 같은 Lesson 안에서 일반 쓰기, revision, 검증 기록은 ID 충돌 범위를 공유합니다. 같은 종류·요청의 정확한 재시도일 때만 같은 ID와 같은 요청을 보내고, 다른 작업에는 새 ID를 사용합니다. Lesson 생성에는 구조화된 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;initial_evidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 필수이며 Lesson 본문과 같은 revision에 저장됩니다.&lt;br /&gt;
&lt;br /&gt;
자동화 작업·워크플로·CI·도구 호출의 성공·실패 상태, 재시도, 인증·연결 오류와 실행 로그 자체는 Lesson으로 저장하지 않습니다. 실패 자체가 연구 대상이고 단일 실행을 넘어 재사용할 원인과 적용 조건을 확인 가능한 근거로 일반화한 독립 주장일 때만 저장합니다.&lt;br /&gt;
&lt;br /&gt;
자료 위치와 확인한 사실을 먼저 제공하고 다음을 요청하세요.&lt;br /&gt;
&lt;br /&gt;
* 같은 메모가 있는지 먼저 검색&lt;br /&gt;
* 관찰과 해석을 분리&lt;br /&gt;
* 맞는 조건과 예외를 명시&lt;br /&gt;
* 자료를 추측하지 않기&lt;br /&gt;
* 검증 기록이 있다면 자료 digest·위치·범위·판정·연결 주장을 함께 확인&lt;br /&gt;
* 작업이 끝나면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Lesson:&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; ID 반환&lt;br /&gt;
* 여러 건을 바꿨다면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;audit_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 revision·내용·근거·관계를 확인&lt;br /&gt;
반환된 ID를 브라우저에서 열어 내용과 자료 위치를 확인합니다. 틀린 부분은 같은 ID의 새 revision으로 고칩니다. 출처만 추가할 때는 evidence, 독립된 새 주장일 때만 새 Lesson과 관계를 사용합니다. 감사는 저장된 구조, digest, 검증 수령 기록의 일관성을 확인하며 외부 자료의 사실 진위를 자동으로 판정하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
도구 입력과 응답 형식은 [[Help:S3 연구 메모리/MCP 클라이언트|MCP 클라이언트 안내]], 필드와 관계의 기계 계약은 [[Help:S3 연구 메모리/아키텍처|아키텍처]]에 있습니다.&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC&amp;diff=2771</id>
		<title>도움말:S3 연구 메모리</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC&amp;diff=2771"/>
		<updated>2026-07-20T05:08:47Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 저장소 문서 동기화&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;!-- S3RM managed repository document; edit the Markdown source, not this page. --&amp;gt;&lt;br /&gt;
&#039;&#039;이 페이지는 저장소 문서에서 자동으로 동기화됩니다. 위키에서 직접 편집하지 마세요.&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;onlyinclude&amp;gt;&lt;br /&gt;
S3 연구 메모리는 연구실이 함께 쓰는 지식 저장소입니다. 프로젝트가 끝난 뒤에도 시행착오를 다시 찾을 수 있게, 시도한 방법과 실제 결과를 짧게 기록합니다. 실험 원본이나 일정표는 여기 쌓지 않습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;/onlyinclude&amp;gt;&lt;br /&gt;
&lt;br /&gt;
위키 본문과 MCP 검색은 공개될 수 있습니다. 에이전트는 연구실 공용 토큰으로 메모 작성, 관계 연결, 근거 첨부, 근거 검증 기록 추가와 Lesson의 조건부 수정을 할 수 있습니다. 수정에는 기준 리비전과 기존 근거가 필요합니다. 삭제, 이동과 위키 관리 기능은 제공하지 않습니다. 저장한 메모는 바로 검색되고 모든 수정은 MediaWiki 이력에 남습니다.&lt;br /&gt;
&lt;br /&gt;
서버가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_WRITE_AUTH_MODE=open&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 운영되는 동안에는 다섯 가지 변경 도구를 토큰 없이 사용할 수 있습니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;github&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드는 GitHub 로그인으로 MCP 전체를 보호합니다. 공개 시험이 끝나면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;token&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 되돌립니다.&lt;br /&gt;
&lt;br /&gt;
=== 처음 사용하는 순서 ===&lt;br /&gt;
&lt;br /&gt;
# 서버 담당자에게 공용 쓰기 토큰을 받습니다.&lt;br /&gt;
# 아래에서 운영체제에 맞는 연결 문서를 열고 토큰을 먼저 저장합니다.&lt;br /&gt;
# 같은 문서의 에이전트 프롬프트를 사용하거나 클라이언트를 직접 연결합니다.&lt;br /&gt;
# 도구 열 개와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;search_lessons(limit=1)&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 응답을 확인합니다.&lt;br /&gt;
# [[#진행 중 프로젝트에서 메모 옮기기|진행 중 프로젝트에서 메모 옮기기]]를 사용합니다.&lt;br /&gt;
읽기 전용 연결을 기본 경로로 안내하지 않습니다. 메모를 남길 사람은 토큰까지 설정해 쓰기 가능한 상태로 연결하세요. 토큰은 채팅창, 프로젝트 파일과 Git에 붙여넣지 않습니다.&lt;br /&gt;
&lt;br /&gt;
=== 플랫폼별 연결 ===&lt;br /&gt;
&lt;br /&gt;
중앙 MCP 주소는 모든 플랫폼에서 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;https://s3wiki.yonsei.ac.kr/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;입니다. 각 PC에 저장소, Docker, 별도 위키나 로컬 MCP 서버를 만들지 않습니다.&lt;br /&gt;
&lt;br /&gt;
==== Linux ====&lt;br /&gt;
&lt;br /&gt;
[[Help:S3 연구 메모리/Linux 연결|Linux에서 연결]]&lt;br /&gt;
&lt;br /&gt;
==== macOS ====&lt;br /&gt;
&lt;br /&gt;
[[Help:S3 연구 메모리/macOS 연결|macOS에서 연결]]&lt;br /&gt;
&lt;br /&gt;
==== Windows ====&lt;br /&gt;
&lt;br /&gt;
[[Help:S3 연구 메모리/Windows 연결|Windows에서 연결]]&lt;br /&gt;
&lt;br /&gt;
Claude Code를 사용한다면 토큰을 저장한 뒤 다음 명령으로 사용자 범위에 등록합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
claude mcp add --transport http --scope user \&lt;br /&gt;
  s3-research-memory https://s3wiki.yonsei.ac.kr/mcp \&lt;br /&gt;
  --header &#039;Authorization: Bearer ${S3RM_MCP_TOKEN}&#039;&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
작은따옴표를 유지해야 토큰 값 대신 환경변수 참조가 저장됩니다. 등록 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;claude mcp get s3-research-memory&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 또는 Claude Code의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 확인하세요.&lt;br /&gt;
&lt;br /&gt;
각 문서는 다음 순서로 구성되어 있습니다.&lt;br /&gt;
&lt;br /&gt;
# 전달받은 토큰을 사용자 환경에 안전하게 저장&lt;br /&gt;
# 에이전트에게 현재 클라이언트 연결 맡기기&lt;br /&gt;
# Codex, Claude Code 또는 Cursor를 직접 설정&lt;br /&gt;
# 연결 확인&lt;br /&gt;
토큰 저장 블록을 실행하면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_TOKEN:&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 표시됩니다. 그 뒤에 서버 담당자에게 전달받은 토큰을 붙여넣고 Enter를 누르세요. 입력 중에는 토큰이 화면에 보이지 않습니다. Linux와 macOS 문서는 로그인 셸을 확인해 zsh 또는 Bash에 맞는 시작 파일을 자동으로 선택하므로 파일명을 직접 바꿀 필요가 없습니다.&lt;br /&gt;
&lt;br /&gt;
ChatGPT 웹, Claude 웹·Desktop과 조직 커넥터처럼 별도 인증 제약이 있는 제품은 [[Help:S3 연구 메모리/MCP 클라이언트|MCP 클라이언트 참고]]를 확인하세요.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
연구실에서 쓰는 여러 AI 클라이언트&lt;br /&gt;
              │&lt;br /&gt;
              ▼&lt;br /&gt;
   중앙 S3 Research Memory MCP&lt;br /&gt;
              │&lt;br /&gt;
              ▼&lt;br /&gt;
   하나의 Semantic MediaWiki&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 연결 확인 ===&lt;br /&gt;
&lt;br /&gt;
새 대화에서 다음처럼 요청합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
S3 Research Memory의 search_lessons를 빈 검색어와 limit=1로 실행해 줘.&lt;br /&gt;
id, title, confidence만 반환하고, 메모가 아직 없어도 새로 만들지는 마.&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
정상이라면 다음 열 개 도구가 보입니다.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 읽기 !! 쓰기&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;search_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;create_lesson_draft&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;get_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;link_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;audit_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;attach_evidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;find_related_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;record_evidence_verification&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;find_analogies&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;revise_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
검색 기본값은 기존 전체 텍스트·단어 겹침 방식인 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;legacy&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;입니다. 서버 담당자가 의미 검색을 켠 환경에서는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;search_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;나 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;find_analogies&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;retrieval_mode=hybrid_v1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 지정해 표현이 다른 후보도 함께 찾을 수 있습니다. 서버에 설정된 relevance 묶음은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;scope&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 선택하며, 호출자 필터와 교집합으로 적용됩니다. scope는 접근 제어가 아니며 Lesson 스키마를 늘리지 않습니다.&lt;br /&gt;
&lt;br /&gt;
고정 평가셋은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;make evaluate-retrieval QUERY_FILE=queries.json&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 실행합니다. 각 항목은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;query&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;expected_ids&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 선택적인 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;retrieval_mode&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;scope&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 사용하며 결과에는 query 본문을 포함하지 않은 recall·MRR·nDCG·p50/p95 latency·fallback 수를 출력합니다. 결과의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;effective_mode&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;semantic_status&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 실제 적용 여부를 확인하세요. 이 서버의 GPU에서 선택적으로 실행하는 번들 모델과 설정 방법은 [[Help:S3 연구 메모리/설치와 검증#선택적 의미 검색|설치·설정의 의미 검색]]에 있습니다.&lt;br /&gt;
&lt;br /&gt;
외부 자료는 먼저 Lesson이 아닌 후보로 수집합니다. 예를 들어 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;make collect-candidates ARGS=&amp;amp;#x27;--code-root /path/to/repo --docs-root /path/to/docs&amp;amp;#x27;&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 또는 Slack export JSON을 지정할 수 있습니다. 후보는 비밀값을 마스킹한 bounded JSON으로만 출력되며, 검토 뒤 중앙 MCP의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;attach_evidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 또는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;create_lesson_draft&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 명시적으로 저장해야 합니다.&lt;br /&gt;
&lt;br /&gt;
여러 검색 facet을 병렬 실행하고 RRF로 합치려면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;make research-query QUERY=&amp;amp;#x27;cache stampede&amp;amp;#x27;&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 사용합니다. 선택적인 OpenAI-compatible reranker/synthesis는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_RESEARCH_LLM_URL&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;과 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_RESEARCH_LLM_MODEL&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 설정할 때만 활성화되며, synthesis의 인용은 검색된 Lesson ID로 제한됩니다.&lt;br /&gt;
&lt;br /&gt;
=== 진행 중 프로젝트에서 메모 옮기기 ===&lt;br /&gt;
&lt;br /&gt;
쓰기 연결을 마친 클라이언트에서 아래 프롬프트를 붙여넣으세요. 현재 확인할 수 있는 문제와 해결 방법만 골라 최대 다섯 건씩 옮깁니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
현재 대화, 이 클라이언트가 제공한 저장 메모리, 현재 작업공간과 명시적으로 첨부된&lt;br /&gt;
자료 중 실제로 접근할 수 있는 내용만 사용해, 다른 프로젝트에서도 도움이 될 문제와&lt;br /&gt;
해결 방법을 중앙 S3 Research Memory에 옮겨 줘.&lt;br /&gt;
&lt;br /&gt;
반드시 지킬 조건:&lt;br /&gt;
- S3 Research Memory의 MCP 도구만 사용한다. generic MediaWiki API, 별도 위키·DB,&lt;br /&gt;
  로컬 MCP 서버는 사용하거나 만들지 않는다.&lt;br /&gt;
- 프로젝트 소개, 진행 현황, 할 일, 일정, 회의록을 메모로 만들지 않는다.&lt;br /&gt;
  자동화 작업·워크플로·CI·도구 호출의 성공·실패 상태, 재시도, 인증·연결 오류와 실행 로그&lt;br /&gt;
  자체도 메모로 만들지 않는다. 실패 자체가 연구 대상이고 단일 실행을 넘어 재사용할&lt;br /&gt;
  원인과 적용 조건을 확인 가능한 근거로 일반화한 독립 주장일 때만 후보로 삼는다.&lt;br /&gt;
  한 가지 문제와 결론을 메모 한 건으로 만든다.&lt;br /&gt;
- 과거 대화나 저장 메모리를 실제로 읽을 수 없으면 읽었다고 가정하지 않는다. 접근한&lt;br /&gt;
  자료의 범위와 접근하지 못한 범위를 마지막에 구분해 적는다.&lt;br /&gt;
- 위키는 공개로 읽힐 수 있다. 비밀번호, 토큰, 개인 정보, 사적인 대화 원문, 개인의&lt;br /&gt;
  신상·관계·취향·업무 평가, 미공개 제3자 자료는 쓰지 않는다. 사람 이름 대신 필요한&lt;br /&gt;
  실제 역할만 쓴다. 비밀 파일은 읽지 않고, 원시 데이터와 큰 산출물은 원래 저장소에&lt;br /&gt;
  둔다. 공개 논문의 저자처럼 인용에 필요한 정보는 정확히 유지한다.&lt;br /&gt;
- 수치, 버전, 짧은 기술 표현은 출처가 뒷받침할 때만 정확히 보존한다. 경로, URL,&lt;br /&gt;
  체크섬, 날짜, 실험 결과나 인과관계를 추측해서 채우지 않는다.&lt;br /&gt;
&lt;br /&gt;
다음 순서로 처리한다:&lt;br /&gt;
1. 후보마다 핵심 메커니즘과 적용 조건으로 search_lessons를 먼저 실행한다.&lt;br /&gt;
   한 번에 최대 5건만 받고, 필요할 때만 find_related_lessons나&lt;br /&gt;
   find_analogies로 확인한다.&lt;br /&gt;
2. 같은 주장과 적용 범위의 메모가 있으면 새 메모를 만들지 않는다. 출처만 늘어나면&lt;br /&gt;
   attach_evidence를 사용한다. 기존 핵심 내용을 보강해야 하면 get_lesson으로 현재&lt;br /&gt;
   revision을 읽고 근거를 먼저 붙인다. 이어지는 revise_lesson에는 attach_evidence&lt;br /&gt;
   응답의 current_revision_id를 expected_revision_id로 사용한다. 그 사이 다른 변경이&lt;br /&gt;
   있었다면 get_lesson을 다시 읽고 변경안을 재계산한다.&lt;br /&gt;
3. 주장이 다르지만 관련 메모가 있으면 새 메모를 만든 뒤 supports, contradicts,&lt;br /&gt;
   analogous_to, failed_because, works_when, suggests, supersedes 중 정확한 관계만&lt;br /&gt;
   link_lessons로 연결한다. 관련성이 불명확하면 관계를 만들지 않는다.&lt;br /&gt;
4. 새 메모에는 고유한 operation_id, title, question, attempt, context, observation,&lt;br /&gt;
   interpretation, reusable_lesson, applicability, confidence, evidence, 구조화된&lt;br /&gt;
   initial_evidence와 필요하면 안정적인 lesson_id를 넣는다. observation에는 직접 확인한&lt;br /&gt;
   사실만 쓰고 interpretation에는 가능한 원인과 불확실성을 분리해 쓴다.&lt;br /&gt;
5. 다시 찾을 수 있는 인용이 최소 한 개 있을 때만 create_lesson_draft를 실행한다.&lt;br /&gt;
   initial_evidence는 Lesson과 같은 revision에 원자적으로 저장된다. 추가 인용만&lt;br /&gt;
   attach_evidence로 붙인다. 논문 근거에는 실제 확인 범위를&lt;br /&gt;
   full_text, official_abstract, partial_source, metadata_only 중 정확히 표시한다.&lt;br /&gt;
   확인 범위를 기록하지 못한 자료는 새 evidence로 붙이지 않으며, 기술 내용을&lt;br /&gt;
   추측하지 않는다. 근거가 기억뿐이거나 위치를 확인할 수 없으면 쓰지 말고&lt;br /&gt;
   &#039;근거 필요&#039; 후보로 남긴다. 실제 자료를 읽은 경우에만 자료 식별자,&lt;br /&gt;
   검사한 자료의 SHA-256, 위치, 확인 범위, 판정과 연결한 주장 필드를&lt;br /&gt;
   검증 기록으로 남긴다. high confidence는 full_text 또는 official_abstract의&lt;br /&gt;
   `supports` 검증이 observation, interpretation, reusable_lesson 세 필드를 모두&lt;br /&gt;
   뒷받침할 때만 쓴다.&lt;br /&gt;
6. 이번 실행에서는 생성 또는 변경한 메모를 합쳐 최대 5건까지만 처리한다. 그 이상은&lt;br /&gt;
   제목과 중단 이유만 남기고 위키를 변경하지 않는다.&lt;br /&gt;
- 다섯 쓰기 도구 모두 새 작업마다 고유한 operation_id를 쓰고, 결과가 불분명한 재시도에는&lt;br /&gt;
  원래 ID와 요청을 그대로 쓴다. 내부 호환 필드는 서버에 맡기며 삭제와 이동은 하지 않는다.&lt;br /&gt;
  같은 Lesson의 정정·보강은 revision으로 남기고, supersedes는 실제로 다른 주장이&lt;br /&gt;
  이전 주장을 대신할 때만 사용한다. evidence note나 Technical Review 중복 메모를&lt;br /&gt;
  본문 수정 대신 만들지 않는다.&lt;br /&gt;
&lt;br /&gt;
별도 확인 질문 없이 위 조건을 만족하는 항목만 처리해. 마지막에는 다음 순서로 짧게&lt;br /&gt;
보고해:&lt;br /&gt;
1. 실제로 확인한 자료 범위와 확인하지 못한 범위&lt;br /&gt;
2. 생성·근거 추가·검증 추가·관계 추가·중복으로 건너뜀을 구분한 Lesson ID와 제목&lt;br /&gt;
3. 위키에 쓰지 않은 &#039;근거 필요&#039; 후보와 빠진 정보&lt;br /&gt;
4. 접근 가능한 범위를 모두 확인했는지, 아니면 5건 제한 때문에 남은 후보가 있는지&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
생성·수정한 메모도 바로 검색됩니다. 반환된 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Lesson:&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; ID와 revision을 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;audit_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 확인하고, 위키에서 관찰과 해석이 섞이지 않았는지, 인용으로 원본을 다시 찾을 수 있는지, 공개하면 안 되는 내용이 없는지 확인하세요. 문제가 있으면 새 revision으로 고칩니다. 감사는 저장된 구조, digest, 검증 수령 기록의 일관성만 확인하며 외부 자료의 사실 진위를 자동으로 판정하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
=== 무엇을 남기나 ===&lt;br /&gt;
&lt;br /&gt;
각 메모에는 궁금했던 점, 해본 것, 당시 조건, 실제 결과, 가능한 원인, 다음에 기억할 것, 언제 맞는지, 신뢰도와 관련 자료를 적습니다. 관계는 다음 일곱 가지만 사용합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
supports, contradicts, analogous_to, failed_because,&lt;br /&gt;
works_when, suggests, supersedes&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
원시 로그, 대형 데이터셋과 실험 산출물 전체는 원래 저장소에 두고, 위키에는 다시 찾을 수 있는 URL·경로·체크섬과 해석을 기록합니다.&lt;br /&gt;
&lt;br /&gt;
=== 문서 ===&lt;br /&gt;
&lt;br /&gt;
사용자:&lt;br /&gt;
&lt;br /&gt;
* [[Help:S3 연구 메모리/Linux 연결|Linux 연결]]&lt;br /&gt;
* [[Help:S3 연구 메모리/macOS 연결|macOS 연결]]&lt;br /&gt;
* [[Help:S3 연구 메모리/Windows 연결|Windows 연결]]&lt;br /&gt;
* [[Help:S3 연구 메모리/MCP 클라이언트|제품별 MCP 참고]]&lt;br /&gt;
* [[Help:S3 연구 메모리/메모 사용법|메모 작성·검색·MCP 사용]]&lt;br /&gt;
* [[Help:S3 연구 메모리/문제 해결|문제 해결]]&lt;br /&gt;
서버 담당자:&lt;br /&gt;
&lt;br /&gt;
* [[Help:S3 연구 메모리/중앙 서버 관리|중앙 서버 관리]]&lt;br /&gt;
* [[Help:S3 연구 메모리/설치와 검증|설치와 검증]]&lt;br /&gt;
* [[Help:S3 연구 메모리/백업과 복원|백업·복원·업그레이드]]&lt;br /&gt;
* [[Help:S3 연구 메모리/보안|계정·비밀·네트워크 보안]]&lt;br /&gt;
* [[Help:S3 연구 메모리/아키텍처|데이터 계약과 아키텍처]]&lt;br /&gt;
* [[Help:S3 연구 메모리/벡터·하이브리드 검색 설계|벡터·하이브리드 검색 설계]]&lt;br /&gt;
기여:&lt;br /&gt;
&lt;br /&gt;
* [[Help:S3 연구 메모리/문체|사용자에게 보이는 한국어]]&lt;br /&gt;
* [[Help:S3 연구 메모리/파일럿 가정|파일럿 가정]]&lt;br /&gt;
* [[Help:S3 연구 메모리/참고문서|상위 프로젝트와 공식 참고문서]]&lt;br /&gt;
* 에이전트 기여 규칙 (&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;AGENTS.md&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;)&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%EB%AF%B8%EB%94%94%EC%96%B4%EC%9C%84%ED%82%A4:Common.js&amp;diff=2770</id>
		<title>미디어위키:Common.js</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%EB%AF%B8%EB%94%94%EC%96%B4%EC%9C%84%ED%82%A4:Common.js&amp;diff=2770"/>
		<updated>2026-07-20T05:08:46Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 원본 스키마 동기화&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;(function () {&lt;br /&gt;
  &#039;use strict&#039;;&lt;br /&gt;
&lt;br /&gt;
  var root = document.getElementById(&#039;s3rm-knowledge-graph&#039;);&lt;br /&gt;
  var semanticRoot = document.getElementById(&#039;s3rm-semantic-search&#039;);&lt;br /&gt;
  if (!root || !window.mw) {&lt;br /&gt;
    return;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  var RELATIONS = [&lt;br /&gt;
    { property: &#039;Supports&#039;, key: &#039;supports&#039;, label: &#039;뒷받침함&#039;, color: &#039;#5f7ae6&#039; },&lt;br /&gt;
    { property: &#039;Contradicts&#039;, key: &#039;contradicts&#039;, label: &#039;상충함&#039;, color: &#039;#d0605b&#039; },&lt;br /&gt;
    { property: &#039;Analogous to&#039;, key: &#039;analogous_to&#039;, label: &#039;유사함&#039;, color: &#039;#a17bd7&#039; },&lt;br /&gt;
    { property: &#039;Failed because&#039;, key: &#039;failed_because&#039;, label: &#039;실패 원인&#039;, color: &#039;#dc8b45&#039; },&lt;br /&gt;
    { property: &#039;Works when&#039;, key: &#039;works_when&#039;, label: &#039;작동 조건&#039;, color: &#039;#43a27b&#039; },&lt;br /&gt;
    { property: &#039;Suggests&#039;, key: &#039;suggests&#039;, label: &#039;후속 제안&#039;, color: &#039;#3f9db7&#039; },&lt;br /&gt;
    { property: &#039;Supersedes&#039;, key: &#039;supersedes&#039;, label: &#039;대체함&#039;, color: &#039;#7d849e&#039; }&lt;br /&gt;
  ];&lt;br /&gt;
  var NODE_LIMIT = 160;&lt;br /&gt;
  var EDGE_LIMIT = 480;&lt;br /&gt;
  var SEMANTIC_RETRY_LIMIT = 45;&lt;br /&gt;
  var mcpReady = null;&lt;br /&gt;
  var graphSearchController = null;&lt;br /&gt;
  var pendingGraphSearch = null;&lt;br /&gt;
&lt;br /&gt;
  function mcpRequest(payload, expectBody) {&lt;br /&gt;
    return window.fetch(&#039;/mcp&#039;, {&lt;br /&gt;
      method: &#039;POST&#039;,&lt;br /&gt;
      credentials: &#039;same-origin&#039;,&lt;br /&gt;
      headers: {&lt;br /&gt;
        &#039;Content-Type&#039;: &#039;application/json&#039;,&lt;br /&gt;
        &#039;Accept&#039;: &#039;application/json, text/event-stream&#039;&lt;br /&gt;
      },&lt;br /&gt;
      body: JSON.stringify(payload)&lt;br /&gt;
    }).then(function (response) {&lt;br /&gt;
      if (!response.ok) {&lt;br /&gt;
        throw new Error(&#039;MCP HTTP &#039; + response.status);&lt;br /&gt;
      }&lt;br /&gt;
      if (!expectBody) {&lt;br /&gt;
        return null;&lt;br /&gt;
      }&lt;br /&gt;
      return response.json();&lt;br /&gt;
    });&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function initializeMcp() {&lt;br /&gt;
    if (!mcpReady) {&lt;br /&gt;
      mcpReady = mcpRequest({&lt;br /&gt;
        jsonrpc: &#039;2.0&#039;,&lt;br /&gt;
        id: 1,&lt;br /&gt;
        method: &#039;initialize&#039;,&lt;br /&gt;
        params: {&lt;br /&gt;
          protocolVersion: &#039;2025-06-18&#039;,&lt;br /&gt;
          capabilities: {},&lt;br /&gt;
          clientInfo: { name: &#039;s3rm-wiki-search&#039;, version: &#039;1&#039; }&lt;br /&gt;
        }&lt;br /&gt;
      }, true).then(function (response) {&lt;br /&gt;
        if (!response || response.error) {&lt;br /&gt;
          throw new Error(&#039;MCP initialization failed&#039;);&lt;br /&gt;
        }&lt;br /&gt;
        return mcpRequest({&lt;br /&gt;
          jsonrpc: &#039;2.0&#039;,&lt;br /&gt;
          method: &#039;notifications/initialized&#039;,&lt;br /&gt;
          params: {}&lt;br /&gt;
        }, false);&lt;br /&gt;
      });&lt;br /&gt;
    }&lt;br /&gt;
    return mcpReady;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function searchSemantically(query, attempt, onWaiting) {&lt;br /&gt;
    var retryCount = attempt || 0;&lt;br /&gt;
    return initializeMcp().then(function () {&lt;br /&gt;
      return mcpRequest({&lt;br /&gt;
        jsonrpc: &#039;2.0&#039;,&lt;br /&gt;
        id: 2,&lt;br /&gt;
        method: &#039;tools/call&#039;,&lt;br /&gt;
        params: {&lt;br /&gt;
          name: &#039;search_lessons&#039;,&lt;br /&gt;
          arguments: {&lt;br /&gt;
            query: query,&lt;br /&gt;
            retrieval_mode: &#039;hybrid_v1&#039;,&lt;br /&gt;
            fields: [&#039;title&#039;, &#039;question&#039;, &#039;reusable_lesson&#039;, &#039;confidence&#039;, &#039;relations&#039;],&lt;br /&gt;
            limit: 6&lt;br /&gt;
          }&lt;br /&gt;
        }&lt;br /&gt;
      }, true);&lt;br /&gt;
    }).then(function (response) {&lt;br /&gt;
      if (!response || response.error || !response.result || response.result.isError) {&lt;br /&gt;
        throw new Error(&#039;Semantic search failed&#039;);&lt;br /&gt;
      }&lt;br /&gt;
      var payload = response.result.structuredContent || {};&lt;br /&gt;
      if (payload.semantic_status === &#039;building&#039; &amp;amp;&amp;amp; retryCount &amp;lt; SEMANTIC_RETRY_LIMIT) {&lt;br /&gt;
        if (onWaiting) {&lt;br /&gt;
          onWaiting();&lt;br /&gt;
        }&lt;br /&gt;
        return new Promise(function (resolve) {&lt;br /&gt;
          window.setTimeout(resolve, 2000);&lt;br /&gt;
        }).then(function () {&lt;br /&gt;
          return searchSemantically(query, retryCount + 1, onWaiting);&lt;br /&gt;
        });&lt;br /&gt;
      }&lt;br /&gt;
      return payload;&lt;br /&gt;
    });&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function textValue(value) {&lt;br /&gt;
    if (value === null || value === undefined) {&lt;br /&gt;
      return &#039;&#039;;&lt;br /&gt;
    }&lt;br /&gt;
    if (typeof value === &#039;string&#039; || typeof value === &#039;number&#039;) {&lt;br /&gt;
      return String(value);&lt;br /&gt;
    }&lt;br /&gt;
    return String(value.fulltext || value.displaytitle || value.raw || value.timestamp || &#039;&#039;);&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function firstValue(values, fallback) {&lt;br /&gt;
    return values &amp;amp;&amp;amp; values.length ? textValue(values[0]) : fallback;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function confidenceLabel(confidence) {&lt;br /&gt;
    if (confidence === &#039;high&#039;) { return &#039;신뢰도: 높음&#039;; }&lt;br /&gt;
    if (confidence === &#039;medium&#039;) { return &#039;신뢰도: 중간&#039;; }&lt;br /&gt;
    return &#039;신뢰도: 낮음&#039;;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function parseGraph(data) {&lt;br /&gt;
    var rawResults = data &amp;amp;&amp;amp; data.query &amp;amp;&amp;amp; data.query.results ? data.query.results : {};&lt;br /&gt;
    var entries = Array.isArray(rawResults)&lt;br /&gt;
      ? rawResults.map(function (item) { return [item.fulltext, item]; })&lt;br /&gt;
      : Object.keys(rawResults).map(function (key) { return [key, rawResults[key]]; });&lt;br /&gt;
    var nodes = [];&lt;br /&gt;
    var byId = Object.create(null);&lt;br /&gt;
&lt;br /&gt;
    entries.slice(0, NODE_LIMIT).forEach(function (entry, index) {&lt;br /&gt;
      var id = entry[0];&lt;br /&gt;
      var result = entry[1] || {};&lt;br /&gt;
      var printouts = result.printouts || {};&lt;br /&gt;
      var angle = index * 2.399963229728653;&lt;br /&gt;
      var radius = 20 * Math.sqrt(index + 1);&lt;br /&gt;
      var node = {&lt;br /&gt;
        id: id,&lt;br /&gt;
        title: firstValue(printouts[&#039;Lesson title&#039;], id.replace(/^Lesson:/, &#039;&#039;)),&lt;br /&gt;
        confidence: firstValue(printouts.Confidence, &#039;low&#039;).toLowerCase(),&lt;br /&gt;
        updatedAt: firstValue(printouts[&#039;Updated at&#039;], &#039;&#039;),&lt;br /&gt;
        x: Math.cos(angle) * radius,&lt;br /&gt;
        y: Math.sin(angle) * radius,&lt;br /&gt;
        vx: 0,&lt;br /&gt;
        vy: 0,&lt;br /&gt;
        degree: 0,&lt;br /&gt;
        printouts: printouts&lt;br /&gt;
      };&lt;br /&gt;
      nodes.push(node);&lt;br /&gt;
      byId[id] = node;&lt;br /&gt;
    });&lt;br /&gt;
&lt;br /&gt;
    var edges = [];&lt;br /&gt;
    nodes.forEach(function (node) {&lt;br /&gt;
      RELATIONS.forEach(function (relation) {&lt;br /&gt;
        var targets = node.printouts[relation.property] || [];&lt;br /&gt;
        targets.forEach(function (target) {&lt;br /&gt;
          var targetId = textValue(target);&lt;br /&gt;
          if (edges.length &amp;lt; EDGE_LIMIT &amp;amp;&amp;amp; byId[targetId] &amp;amp;&amp;amp; targetId !== node.id) {&lt;br /&gt;
            edges.push({ source: node, target: byId[targetId], relation: relation });&lt;br /&gt;
            node.degree += 1;&lt;br /&gt;
            byId[targetId].degree += 1;&lt;br /&gt;
          }&lt;br /&gt;
        });&lt;br /&gt;
      });&lt;br /&gt;
      delete node.printouts;&lt;br /&gt;
    });&lt;br /&gt;
&lt;br /&gt;
    var connectedNodes = nodes.filter(function (node) { return node.degree &amp;gt; 0; });&lt;br /&gt;
    var isolatedNodes = nodes.filter(function (node) { return node.degree === 0; });&lt;br /&gt;
    connectedNodes.forEach(function (node, index) {&lt;br /&gt;
      var angle = index * 2.399963229728653;&lt;br /&gt;
      var radius = 16 * Math.sqrt(index + 1);&lt;br /&gt;
      node.x = Math.cos(angle) * radius;&lt;br /&gt;
      node.y = Math.sin(angle) * radius;&lt;br /&gt;
    });&lt;br /&gt;
    isolatedNodes.forEach(function (node, index) {&lt;br /&gt;
      var angle = index * 2.399963229728653 + 0.7;&lt;br /&gt;
      var radius = 92 + 10 * Math.sqrt(index + 1);&lt;br /&gt;
      node.x = Math.cos(angle) * radius;&lt;br /&gt;
      node.y = Math.sin(angle) * radius;&lt;br /&gt;
    });&lt;br /&gt;
    nodes.forEach(function (node) {&lt;br /&gt;
      node.homeX = node.x;&lt;br /&gt;
      node.homeY = node.y;&lt;br /&gt;
    });&lt;br /&gt;
    return { nodes: nodes, edges: edges };&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function makeElement(tag, className, text) {&lt;br /&gt;
    var element = document.createElement(tag);&lt;br /&gt;
    if (className) {&lt;br /&gt;
      element.className = className;&lt;br /&gt;
    }&lt;br /&gt;
    if (text !== undefined) {&lt;br /&gt;
      element.textContent = text;&lt;br /&gt;
    }&lt;br /&gt;
    return element;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function createSemanticSearch() {&lt;br /&gt;
    if (!semanticRoot) {&lt;br /&gt;
      return;&lt;br /&gt;
    }&lt;br /&gt;
    semanticRoot.replaceChildren();&lt;br /&gt;
    var intro = makeElement(&#039;div&#039;, &#039;s3rm-semantic-intro&#039;);&lt;br /&gt;
    intro.appendChild(makeElement(&#039;div&#039;, &#039;s3rm-semantic-icon&#039;, &#039;⌕&#039;));&lt;br /&gt;
    var introCopy = makeElement(&#039;div&#039;, &#039;&#039;);&lt;br /&gt;
    introCopy.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;검색&#039;));&lt;br /&gt;
    introCopy.appendChild(makeElement(&#039;p&#039;, &#039;&#039;, &#039;현상이나 궁금한 점을 문장으로 입력하세요. 표현이 달라도 관련 메모를 찾습니다.&#039;));&lt;br /&gt;
    intro.appendChild(introCopy);&lt;br /&gt;
&lt;br /&gt;
    var form = makeElement(&#039;form&#039;, &#039;s3rm-semantic-form&#039;);&lt;br /&gt;
    var input = makeElement(&#039;input&#039;, &#039;s3rm-semantic-input&#039;);&lt;br /&gt;
    input.type = &#039;search&#039;;&lt;br /&gt;
    input.name = &#039;query&#039;;&lt;br /&gt;
    input.maxLength = 500;&lt;br /&gt;
    input.placeholder = &#039;예: GPU 메모리는 남아 있는데 할당이 실패하는 이유는?&#039;;&lt;br /&gt;
    input.setAttribute(&#039;aria-label&#039;, &#039;메모 검색&#039;);&lt;br /&gt;
    var submit = makeElement(&#039;button&#039;, &#039;s3rm-semantic-submit&#039;, &#039;검색&#039;);&lt;br /&gt;
    submit.type = &#039;submit&#039;;&lt;br /&gt;
    form.append(input, submit);&lt;br /&gt;
&lt;br /&gt;
    var examples = makeElement(&#039;div&#039;, &#039;s3rm-semantic-examples&#039;);&lt;br /&gt;
    examples.appendChild(makeElement(&#039;span&#039;, &#039;&#039;, &#039;검색 예시&#039;));&lt;br /&gt;
    [&lt;br /&gt;
      &#039;재시도가 한꺼번에 몰리는 이유&#039;,&lt;br /&gt;
      &#039;GPU와 SSD 사이 병목을 줄이는 방법&#039;,&lt;br /&gt;
      &#039;긴 작업에서 지연이 갑자기 커지는 원인&#039;&lt;br /&gt;
    ].forEach(function (example) {&lt;br /&gt;
      var button = makeElement(&#039;button&#039;, &#039;&#039;, example);&lt;br /&gt;
      button.type = &#039;button&#039;;&lt;br /&gt;
      button.addEventListener(&#039;click&#039;, function () {&lt;br /&gt;
        input.value = example;&lt;br /&gt;
        form.requestSubmit();&lt;br /&gt;
      });&lt;br /&gt;
      examples.appendChild(button);&lt;br /&gt;
    });&lt;br /&gt;
&lt;br /&gt;
    var feedback = makeElement(&#039;div&#039;, &#039;s3rm-semantic-feedback&#039;);&lt;br /&gt;
    feedback.setAttribute(&#039;aria-live&#039;, &#039;polite&#039;);&lt;br /&gt;
    feedback.hidden = true;&lt;br /&gt;
    semanticRoot.append(intro, form, examples, feedback);&lt;br /&gt;
&lt;br /&gt;
    function renderSearchResults(payload, query) {&lt;br /&gt;
      var items = Array.isArray(payload.items) ? payload.items : [];&lt;br /&gt;
      if (graphSearchController) {&lt;br /&gt;
        graphSearchController(items, query);&lt;br /&gt;
      } else {&lt;br /&gt;
        pendingGraphSearch = { items: items, query: query };&lt;br /&gt;
      }&lt;br /&gt;
      feedback.replaceChildren();&lt;br /&gt;
      feedback.hidden = false;&lt;br /&gt;
      var resultHeader = makeElement(&#039;div&#039;, &#039;s3rm-semantic-result-header&#039;);&lt;br /&gt;
      resultHeader.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;“&#039; + query + &#039;” 검색 결과&#039;));&lt;br /&gt;
      resultHeader.appendChild(makeElement(&#039;span&#039;, &#039;s3rm-semantic-mode&#039;, items.length + &#039;개 메모&#039;));&lt;br /&gt;
      feedback.appendChild(resultHeader);&lt;br /&gt;
&lt;br /&gt;
      if (!items.length) {&lt;br /&gt;
        var emptyMessage = payload.semantic_status === &#039;building&#039;&lt;br /&gt;
          ? &#039;검색을 준비하고 있습니다. 잠시 후 다시 검색해 주세요.&#039;&lt;br /&gt;
          : &#039;가까운 메모를 찾지 못했습니다. 표현을 조금 바꿔 다시 검색해 보세요.&#039;;&lt;br /&gt;
        feedback.appendChild(makeElement(&#039;p&#039;, &#039;s3rm-semantic-empty&#039;, emptyMessage));&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      feedback.appendChild(makeElement(&#039;p&#039;, &#039;s3rm-semantic-graph-note&#039;, &#039;아래 그래프에 검색 결과를 모았습니다. 점을 선택하면 메모와 연결을 볼 수 있습니다.&#039;));&lt;br /&gt;
      var list = makeElement(&#039;div&#039;, &#039;s3rm-semantic-result-links&#039;);&lt;br /&gt;
      items.forEach(function (item) {&lt;br /&gt;
        var link = makeElement(&#039;a&#039;, &#039;&#039;, item.title || String(item.id).replace(/^Lesson:/, &#039;&#039;));&lt;br /&gt;
        link.href = mw.util.getUrl(item.id);&lt;br /&gt;
        list.appendChild(link);&lt;br /&gt;
      });&lt;br /&gt;
      feedback.appendChild(list);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    form.addEventListener(&#039;submit&#039;, function (event) {&lt;br /&gt;
      event.preventDefault();&lt;br /&gt;
      var query = input.value.trim();&lt;br /&gt;
      if (query.length &amp;lt; 2) {&lt;br /&gt;
        feedback.replaceChildren(makeElement(&#039;p&#039;, &#039;s3rm-semantic-error&#039;, &#039;두 글자 이상 입력해 주세요.&#039;));&lt;br /&gt;
        feedback.hidden = false;&lt;br /&gt;
        input.focus();&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      submit.disabled = true;&lt;br /&gt;
      submit.textContent = &#039;찾는 중…&#039;;&lt;br /&gt;
      feedback.replaceChildren(makeElement(&#039;div&#039;, &#039;s3rm-semantic-searching&#039;, &#039;관련 메모를 찾고 있습니다.&#039;));&lt;br /&gt;
      feedback.hidden = false;&lt;br /&gt;
      searchSemantically(query, 0, function () {&lt;br /&gt;
        feedback.replaceChildren(makeElement(&#039;div&#039;, &#039;s3rm-semantic-searching&#039;, &#039;검색을 준비하고 있습니다. 완료되면 결과를 바로 보여 드립니다.&#039;));&lt;br /&gt;
      }).then(function (payload) {&lt;br /&gt;
        renderSearchResults(payload, query);&lt;br /&gt;
      }).catch(function () {&lt;br /&gt;
        feedback.replaceChildren();&lt;br /&gt;
        var error = makeElement(&#039;div&#039;, &#039;s3rm-semantic-error&#039;);&lt;br /&gt;
        error.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;지금은 검색을 사용할 수 없습니다.&#039;));&lt;br /&gt;
        var fallback = makeElement(&#039;a&#039;, &#039;&#039;, &#039;검색 열기&#039;);&lt;br /&gt;
        fallback.href = mw.util.getUrl(&#039;Special:Search&#039;, { search: query });&lt;br /&gt;
        error.appendChild(fallback);&lt;br /&gt;
        feedback.appendChild(error);&lt;br /&gt;
      }).finally(function () {&lt;br /&gt;
        submit.disabled = false;&lt;br /&gt;
        submit.textContent = &#039;검색&#039;;&lt;br /&gt;
      });&lt;br /&gt;
    });&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function renderEmpty() {&lt;br /&gt;
    root.replaceChildren();&lt;br /&gt;
    var empty = makeElement(&#039;div&#039;, &#039;s3rm-graph-empty&#039;);&lt;br /&gt;
    empty.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;아직 연결할 메모가 없습니다&#039;));&lt;br /&gt;
    empty.appendChild(makeElement(&#039;p&#039;, &#039;&#039;, &#039;첫 메모를 남기면 이곳에서 지식의 연결을 볼 수 있습니다.&#039;));&lt;br /&gt;
    var link = makeElement(&#039;a&#039;, &#039;s3rm-graph-primary-link&#039;, &#039;첫 메모 작성하기&#039;);&lt;br /&gt;
    link.href = &#039;#quick-start&#039;;&lt;br /&gt;
    empty.appendChild(link);&lt;br /&gt;
    root.appendChild(empty);&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function renderError() {&lt;br /&gt;
    root.replaceChildren();&lt;br /&gt;
    var fallback = makeElement(&#039;div&#039;, &#039;s3rm-graph-empty&#039;);&lt;br /&gt;
    fallback.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;그래프를 불러오지 못했습니다&#039;));&lt;br /&gt;
    fallback.appendChild(makeElement(&#039;p&#039;, &#039;&#039;, &#039;최근 메모와 검색은 계속 사용할 수 있습니다. 잠시 뒤 다시 시도해 주세요.&#039;));&lt;br /&gt;
    var link = makeElement(&#039;a&#039;, &#039;s3rm-graph-primary-link&#039;, &#039;전체 메모 보기&#039;);&lt;br /&gt;
    link.href = mw.util.getUrl(&#039;Special:AllPages/Lesson:&#039;);&lt;br /&gt;
    fallback.appendChild(link);&lt;br /&gt;
    root.appendChild(fallback);&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function createGraph(graph) {&lt;br /&gt;
    var nodes = graph.nodes;&lt;br /&gt;
    var edges = graph.edges;&lt;br /&gt;
    var byId = Object.create(null);&lt;br /&gt;
    function canonicalId(id) {&lt;br /&gt;
      return String(id || &#039;&#039;).replace(/_/g, &#039; &#039;).toLocaleLowerCase(&#039;en&#039;);&lt;br /&gt;
    }&lt;br /&gt;
    nodes.forEach(function (node) { byId[canonicalId(node.id)] = node; });&lt;br /&gt;
    var selected = null;&lt;br /&gt;
    var hovered = null;&lt;br /&gt;
    var searchCluster = null;&lt;br /&gt;
    var activeRelation = &#039;all&#039;;&lt;br /&gt;
    var transform = { x: 0, y: 0, scale: 1 };&lt;br /&gt;
    var dragging = null;&lt;br /&gt;
    var panning = false;&lt;br /&gt;
    var pointerStart = null;&lt;br /&gt;
    var dragMotion = null;&lt;br /&gt;
    var animationFrame = 0;&lt;br /&gt;
    var simulationFrames = 0;&lt;br /&gt;
    var simulationRunning = false;&lt;br /&gt;
    var fitAfterSimulation = true;&lt;br /&gt;
    var reduceMotion = window.matchMedia &amp;amp;&amp;amp; window.matchMedia(&#039;(prefers-reduced-motion: reduce)&#039;).matches;&lt;br /&gt;
&lt;br /&gt;
    root.replaceChildren();&lt;br /&gt;
    var toolbar = makeElement(&#039;div&#039;, &#039;s3rm-graph-toolbar&#039;);&lt;br /&gt;
    var searchWrap = makeElement(&#039;div&#039;, &#039;s3rm-graph-search&#039;);&lt;br /&gt;
    var search = makeElement(&#039;input&#039;, &#039;s3rm-graph-search-input&#039;);&lt;br /&gt;
    search.type = &#039;search&#039;;&lt;br /&gt;
    search.placeholder = &#039;그래프에서 제목 필터&#039;;&lt;br /&gt;
    search.setAttribute(&#039;aria-label&#039;, &#039;그래프 제목 필터&#039;);&lt;br /&gt;
    search.autocomplete = &#039;off&#039;;&lt;br /&gt;
    var searchResults = makeElement(&#039;div&#039;, &#039;s3rm-graph-search-results&#039;);&lt;br /&gt;
    searchResults.hidden = true;&lt;br /&gt;
    searchWrap.append(search, searchResults);&lt;br /&gt;
&lt;br /&gt;
    var filter = makeElement(&#039;select&#039;, &#039;s3rm-graph-filter&#039;);&lt;br /&gt;
    filter.setAttribute(&#039;aria-label&#039;, &#039;관계 유형 필터&#039;);&lt;br /&gt;
    var allOption = makeElement(&#039;option&#039;, &#039;&#039;, &#039;모든 관계&#039;);&lt;br /&gt;
    allOption.value = &#039;all&#039;;&lt;br /&gt;
    filter.appendChild(allOption);&lt;br /&gt;
    RELATIONS.forEach(function (relation) {&lt;br /&gt;
      var option = makeElement(&#039;option&#039;, &#039;&#039;, relation.label);&lt;br /&gt;
      option.value = relation.key;&lt;br /&gt;
      filter.appendChild(option);&lt;br /&gt;
    });&lt;br /&gt;
    var reset = makeElement(&#039;button&#039;, &#039;s3rm-graph-reset&#039;, &#039;화면 맞춤&#039;);&lt;br /&gt;
    reset.type = &#039;button&#039;;&lt;br /&gt;
    var clearSearch = makeElement(&#039;button&#039;, &#039;s3rm-graph-clear-search&#039;, &#039;검색 결과 해제&#039;);&lt;br /&gt;
    clearSearch.type = &#039;button&#039;;&lt;br /&gt;
    clearSearch.hidden = true;&lt;br /&gt;
    toolbar.append(searchWrap, filter, reset, clearSearch);&lt;br /&gt;
&lt;br /&gt;
    var body = makeElement(&#039;div&#039;, &#039;s3rm-graph-body&#039;);&lt;br /&gt;
    var viewport = makeElement(&#039;div&#039;, &#039;s3rm-graph-viewport&#039;);&lt;br /&gt;
    var canvas = document.createElement(&#039;canvas&#039;);&lt;br /&gt;
    canvas.className = &#039;s3rm-graph-canvas&#039;;&lt;br /&gt;
    canvas.setAttribute(&#039;aria-hidden&#039;, &#039;true&#039;);&lt;br /&gt;
    viewport.appendChild(canvas);&lt;br /&gt;
    var hint = makeElement(&#039;div&#039;, &#039;s3rm-graph-hint&#039;, &#039;드래그하여 이동 · 스크롤하여 확대 · 두 번 눌러 메모 열기&#039;);&lt;br /&gt;
    viewport.appendChild(hint);&lt;br /&gt;
&lt;br /&gt;
    var inspector = makeElement(&#039;aside&#039;, &#039;s3rm-graph-inspector&#039;);&lt;br /&gt;
    inspector.setAttribute(&#039;aria-live&#039;, &#039;polite&#039;);&lt;br /&gt;
    body.append(viewport, inspector);&lt;br /&gt;
&lt;br /&gt;
    var footer = makeElement(&#039;div&#039;, &#039;s3rm-graph-footer&#039;);&lt;br /&gt;
    var counts = makeElement(&#039;span&#039;, &#039;s3rm-graph-counts&#039;, nodes.length + &#039;개 메모 · &#039; + edges.length + &#039;개 연결&#039;);&lt;br /&gt;
    var legend = makeElement(&#039;div&#039;, &#039;s3rm-graph-legend&#039;);&lt;br /&gt;
    RELATIONS.slice(0, 4).forEach(function (relation) {&lt;br /&gt;
      var item = makeElement(&#039;span&#039;, &#039;s3rm-graph-legend-item&#039;);&lt;br /&gt;
      var swatch = makeElement(&#039;i&#039;, &#039;&#039;);&lt;br /&gt;
      swatch.style.backgroundColor = relation.color;&lt;br /&gt;
      item.append(swatch, document.createTextNode(relation.label));&lt;br /&gt;
      legend.appendChild(item);&lt;br /&gt;
    });&lt;br /&gt;
    footer.append(counts, legend);&lt;br /&gt;
    root.append(toolbar, body, footer);&lt;br /&gt;
&lt;br /&gt;
    var context = canvas.getContext(&#039;2d&#039;);&lt;br /&gt;
    var size = { width: 0, height: 0, ratio: 1 };&lt;br /&gt;
&lt;br /&gt;
    function visibleEdges() {&lt;br /&gt;
      return activeRelation === &#039;all&#039;&lt;br /&gt;
        ? edges&lt;br /&gt;
        : edges.filter(function (edge) { return edge.relation.key === activeRelation; });&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function nodeRadius(node) {&lt;br /&gt;
      return 6 + Math.min(8, Math.sqrt(node.degree) * 2.2) + (node.isSearchResult ? 3 : 0);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function confidenceColor(confidence) {&lt;br /&gt;
      if (confidence === &#039;high&#039;) { return &#039;#53c18b&#039;; }&lt;br /&gt;
      if (confidence === &#039;medium&#039;) { return &#039;#8ca8ff&#039;; }&lt;br /&gt;
      return &#039;#c0c5d8&#039;;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function resize() {&lt;br /&gt;
      var rect = viewport.getBoundingClientRect();&lt;br /&gt;
      size.width = Math.max(320, rect.width);&lt;br /&gt;
      size.height = Math.max(360, rect.height);&lt;br /&gt;
      size.ratio = Math.min(window.devicePixelRatio || 1, 2);&lt;br /&gt;
      canvas.width = Math.round(size.width * size.ratio);&lt;br /&gt;
      canvas.height = Math.round(size.height * size.ratio);&lt;br /&gt;
      canvas.style.width = size.width + &#039;px&#039;;&lt;br /&gt;
      canvas.style.height = size.height + &#039;px&#039;;&lt;br /&gt;
      draw();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function fitGraph() {&lt;br /&gt;
      if (!nodes.length) { return; }&lt;br /&gt;
      var xs = nodes.map(function (node) { return node.x; });&lt;br /&gt;
      var ys = nodes.map(function (node) { return node.y; });&lt;br /&gt;
      var minX = Math.min.apply(null, xs) - 38;&lt;br /&gt;
      var maxX = Math.max.apply(null, xs) + 38;&lt;br /&gt;
      var minY = Math.min.apply(null, ys) - 38;&lt;br /&gt;
      var maxY = Math.max.apply(null, ys) + 38;&lt;br /&gt;
      transform.scale = Math.max(0.2, Math.min(1.25, Math.min(&lt;br /&gt;
        size.width / Math.max(1, maxX - minX),&lt;br /&gt;
        size.height / Math.max(1, maxY - minY)&lt;br /&gt;
      )));&lt;br /&gt;
      transform.x = size.width / 2 - ((minX + maxX) / 2) * transform.scale;&lt;br /&gt;
      transform.y = size.height / 2 - ((minY + maxY) / 2) * transform.scale;&lt;br /&gt;
      draw();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function draw() {&lt;br /&gt;
      context.setTransform(size.ratio, 0, 0, size.ratio, 0, 0);&lt;br /&gt;
      context.clearRect(0, 0, size.width, size.height);&lt;br /&gt;
      context.save();&lt;br /&gt;
      context.translate(transform.x, transform.y);&lt;br /&gt;
      context.scale(transform.scale, transform.scale);&lt;br /&gt;
&lt;br /&gt;
      if (searchCluster) {&lt;br /&gt;
        context.beginPath();&lt;br /&gt;
        context.arc(&lt;br /&gt;
          searchCluster.x,&lt;br /&gt;
          searchCluster.y,&lt;br /&gt;
          searchCluster.radius + 48,&lt;br /&gt;
          0,&lt;br /&gt;
          Math.PI * 2&lt;br /&gt;
        );&lt;br /&gt;
        context.fillStyle = &#039;rgba(104, 126, 233, 0.12)&#039;;&lt;br /&gt;
        context.fill();&lt;br /&gt;
        context.setLineDash([7 / transform.scale, 6 / transform.scale]);&lt;br /&gt;
        context.lineWidth = 1.5 / transform.scale;&lt;br /&gt;
        context.strokeStyle = &#039;rgba(140, 168, 255, 0.75)&#039;;&lt;br /&gt;
        context.stroke();&lt;br /&gt;
        context.setLineDash([]);&lt;br /&gt;
        context.font = &#039;700 &#039; + (12 / transform.scale) + &#039;px sans-serif&#039;;&lt;br /&gt;
        context.textAlign = &#039;center&#039;;&lt;br /&gt;
        context.textBaseline = &#039;bottom&#039;;&lt;br /&gt;
        context.fillStyle = &#039;#b9c7ff&#039;;&lt;br /&gt;
        context.fillText(&lt;br /&gt;
          &#039;검색 결과 · &#039; + searchCluster.nodes.length + &#039;개 메모&#039;,&lt;br /&gt;
          searchCluster.x,&lt;br /&gt;
          searchCluster.y - searchCluster.radius - 58 / transform.scale&lt;br /&gt;
        );&lt;br /&gt;
      }&lt;br /&gt;
&lt;br /&gt;
      visibleEdges().forEach(function (edge) {&lt;br /&gt;
        var emphasized = selected &amp;amp;&amp;amp; (edge.source === selected || edge.target === selected);&lt;br /&gt;
        var searchEdge = searchCluster &amp;amp;&amp;amp;&lt;br /&gt;
          (edge.source.isSearchResult || edge.target.isSearchResult);&lt;br /&gt;
        context.globalAlpha = searchCluster &amp;amp;&amp;amp; !searchEdge ? 0.1 : 1;&lt;br /&gt;
        context.beginPath();&lt;br /&gt;
        context.moveTo(edge.source.x, edge.source.y);&lt;br /&gt;
        context.lineTo(edge.target.x, edge.target.y);&lt;br /&gt;
        context.strokeStyle = emphasized ? edge.relation.color : edge.relation.color + &#039;55&#039;;&lt;br /&gt;
        context.lineWidth = (emphasized || searchEdge ? 2.2 : 1) / transform.scale;&lt;br /&gt;
        context.stroke();&lt;br /&gt;
      });&lt;br /&gt;
      context.globalAlpha = 1;&lt;br /&gt;
&lt;br /&gt;
      nodes.forEach(function (node) {&lt;br /&gt;
        var isSelected = node === selected;&lt;br /&gt;
        var isHovered = node === hovered;&lt;br /&gt;
        var isSearchResult = Boolean(node.isSearchResult);&lt;br /&gt;
        var radius = nodeRadius(node);&lt;br /&gt;
        context.globalAlpha = searchCluster &amp;amp;&amp;amp; !isSearchResult ? 0.12 : 1;&lt;br /&gt;
        context.beginPath();&lt;br /&gt;
        context.arc(node.x, node.y, radius + (isSelected ? 4 : 0), 0, Math.PI * 2);&lt;br /&gt;
        context.fillStyle = isSelected || isSearchResult ? &#039;#ffffff&#039; : confidenceColor(node.confidence);&lt;br /&gt;
        context.fill();&lt;br /&gt;
        context.lineWidth = (isSelected || isSearchResult ? 3 : 1.5) / transform.scale;&lt;br /&gt;
        context.strokeStyle = isSelected || isSearchResult ? &#039;#8ca8ff&#039; : &#039;#11182e&#039;;&lt;br /&gt;
        context.stroke();&lt;br /&gt;
&lt;br /&gt;
        if (isSelected || isHovered || isSearchResult ||&lt;br /&gt;
          (transform.scale &amp;gt; 0.68 &amp;amp;&amp;amp; node.degree &amp;gt; 0)) {&lt;br /&gt;
          context.font = (isSelected || isSearchResult ? &#039;600 &#039; : &#039;&#039;) + (12 / transform.scale) + &#039;px sans-serif&#039;;&lt;br /&gt;
          context.textAlign = &#039;center&#039;;&lt;br /&gt;
          context.textBaseline = &#039;top&#039;;&lt;br /&gt;
          context.fillStyle = &#039;#f5f7ff&#039;;&lt;br /&gt;
          context.shadowColor = &#039;#080b16&#039;;&lt;br /&gt;
          context.shadowBlur = 5 / transform.scale;&lt;br /&gt;
          var label = node.title.length &amp;gt; 26 ? node.title.slice(0, 25) + &#039;…&#039; : node.title;&lt;br /&gt;
          context.fillText(label, node.x, node.y + radius + 7 / transform.scale);&lt;br /&gt;
          context.shadowBlur = 0;&lt;br /&gt;
        }&lt;br /&gt;
      });&lt;br /&gt;
      context.globalAlpha = 1;&lt;br /&gt;
      context.restore();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function startSimulation(frames, shouldFit) {&lt;br /&gt;
      simulationFrames = Math.max(simulationFrames, reduceMotion ? 1 : frames);&lt;br /&gt;
      fitAfterSimulation = fitAfterSimulation || Boolean(shouldFit);&lt;br /&gt;
      if (!simulationRunning) {&lt;br /&gt;
        simulationRunning = true;&lt;br /&gt;
        animationFrame = window.requestAnimationFrame(simulate);&lt;br /&gt;
      }&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function simulate() {&lt;br /&gt;
      if (simulationFrames &amp;lt;= 0) {&lt;br /&gt;
        simulationRunning = false;&lt;br /&gt;
        if (fitAfterSimulation &amp;amp;&amp;amp; !searchCluster) {&lt;br /&gt;
          fitGraph();&lt;br /&gt;
        } else {&lt;br /&gt;
          draw();&lt;br /&gt;
        }&lt;br /&gt;
        fitAfterSimulation = false;&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
&lt;br /&gt;
      for (var i = 0; i &amp;lt; nodes.length; i += 1) {&lt;br /&gt;
        for (var j = i + 1; j &amp;lt; nodes.length; j += 1) {&lt;br /&gt;
          var first = nodes[i];&lt;br /&gt;
          var second = nodes[j];&lt;br /&gt;
          var isolatedPair = first.degree === 0 &amp;amp;&amp;amp; second.degree === 0;&lt;br /&gt;
          if (isolatedPair &amp;amp;&amp;amp; !first.motionActive &amp;amp;&amp;amp; !second.motionActive &amp;amp;&amp;amp;&lt;br /&gt;
            first !== dragging &amp;amp;&amp;amp; second !== dragging) {&lt;br /&gt;
            continue;&lt;br /&gt;
          }&lt;br /&gt;
          var dx = second.x - first.x || 0.01;&lt;br /&gt;
          var dy = second.y - first.y || 0.01;&lt;br /&gt;
          var distanceSquared = dx * dx + dy * dy;&lt;br /&gt;
          var distance = Math.sqrt(distanceSquared) || 0.01;&lt;br /&gt;
          var minimumDistance = nodeRadius(first) + nodeRadius(second) + 7;&lt;br /&gt;
          var force = distance &amp;lt; minimumDistance&lt;br /&gt;
            ? (minimumDistance - distance) * 0.035 / distance&lt;br /&gt;
            : 1.8 / Math.max(80, distanceSquared);&lt;br /&gt;
          if (first !== dragging) {&lt;br /&gt;
            first.vx -= dx * force;&lt;br /&gt;
            first.vy -= dy * force;&lt;br /&gt;
          }&lt;br /&gt;
          if (second !== dragging) {&lt;br /&gt;
            second.vx += dx * force;&lt;br /&gt;
            second.vy += dy * force;&lt;br /&gt;
          }&lt;br /&gt;
        }&lt;br /&gt;
      }&lt;br /&gt;
      edges.forEach(function (edge) {&lt;br /&gt;
        var dx = edge.target.x - edge.source.x;&lt;br /&gt;
        var dy = edge.target.y - edge.source.y;&lt;br /&gt;
        var distance = Math.sqrt(dx * dx + dy * dy) || 1;&lt;br /&gt;
        var force = (distance - 64) * 0.0014;&lt;br /&gt;
        if (edge.source !== dragging) {&lt;br /&gt;
          edge.source.vx += dx * force;&lt;br /&gt;
          edge.source.vy += dy * force;&lt;br /&gt;
        }&lt;br /&gt;
        if (edge.target !== dragging) {&lt;br /&gt;
          edge.target.vx -= dx * force;&lt;br /&gt;
          edge.target.vy -= dy * force;&lt;br /&gt;
        }&lt;br /&gt;
      });&lt;br /&gt;
      nodes.forEach(function (node) {&lt;br /&gt;
        if (node === dragging) {&lt;br /&gt;
          return;&lt;br /&gt;
        }&lt;br /&gt;
        if (node.isSearchResult &amp;amp;&amp;amp; !node.motionActive) {&lt;br /&gt;
          node.vx = 0;&lt;br /&gt;
          node.vy = 0;&lt;br /&gt;
          return;&lt;br /&gt;
        }&lt;br /&gt;
        if (node.degree === 0 &amp;amp;&amp;amp; !node.motionActive) {&lt;br /&gt;
          node.vx = 0;&lt;br /&gt;
          node.vy = 0;&lt;br /&gt;
          return;&lt;br /&gt;
        }&lt;br /&gt;
        if (node.degree === 0) {&lt;br /&gt;
          node.vx += (node.homeX - node.x) * 0.0022;&lt;br /&gt;
          node.vy += (node.homeY - node.y) * 0.0022;&lt;br /&gt;
        } else {&lt;br /&gt;
          node.vx += -node.x * 0.0018;&lt;br /&gt;
          node.vy += -node.y * 0.0018;&lt;br /&gt;
        }&lt;br /&gt;
        node.vx *= 0.9;&lt;br /&gt;
        node.vy *= 0.9;&lt;br /&gt;
        node.x += node.vx;&lt;br /&gt;
        node.y += node.vy;&lt;br /&gt;
        if (node.motionActive &amp;amp;&amp;amp; Math.abs(node.vx) + Math.abs(node.vy) &amp;lt; 0.025) {&lt;br /&gt;
          delete node.motionActive;&lt;br /&gt;
        }&lt;br /&gt;
      });&lt;br /&gt;
      simulationFrames -= 1;&lt;br /&gt;
      draw();&lt;br /&gt;
      animationFrame = window.requestAnimationFrame(simulate);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function relationDefinition(key) {&lt;br /&gt;
      return RELATIONS.find(function (relation) { return relation.key === key; });&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function updateCounts() {&lt;br /&gt;
      if (searchCluster) {&lt;br /&gt;
        var relatedEdges = edges.filter(function (edge) {&lt;br /&gt;
          return edge.source.isSearchResult || edge.target.isSearchResult;&lt;br /&gt;
        }).length;&lt;br /&gt;
        counts.textContent = &#039;검색 결과 &#039; + searchCluster.nodes.length + &#039;개 · &#039; +&lt;br /&gt;
          relatedEdges + &#039;개 연결&#039;;&lt;br /&gt;
      } else {&lt;br /&gt;
        counts.textContent = nodes.length + &#039;개 메모 · &#039; + visibleEdges().length + &#039;개 연결&#039;;&lt;br /&gt;
      }&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function resetSearchCluster() {&lt;br /&gt;
      var addedKeys = Object.create(null);&lt;br /&gt;
      edges.forEach(function (edge) {&lt;br /&gt;
        if (edge.searchAdded) {&lt;br /&gt;
          edge.source.degree = Math.max(0, edge.source.degree - 1);&lt;br /&gt;
          edge.target.degree = Math.max(0, edge.target.degree - 1);&lt;br /&gt;
        }&lt;br /&gt;
      });&lt;br /&gt;
      nodes.forEach(function (node) {&lt;br /&gt;
        if (node.searchAdded) {&lt;br /&gt;
          addedKeys[canonicalId(node.id)] = true;&lt;br /&gt;
          delete byId[canonicalId(node.id)];&lt;br /&gt;
        } else if (node.preSearchPosition) {&lt;br /&gt;
          node.x = node.preSearchPosition.x;&lt;br /&gt;
          node.y = node.preSearchPosition.y;&lt;br /&gt;
          node.homeX = node.preSearchHome.x;&lt;br /&gt;
          node.homeY = node.preSearchHome.y;&lt;br /&gt;
        }&lt;br /&gt;
        delete node.isSearchResult;&lt;br /&gt;
        delete node.preSearchPosition;&lt;br /&gt;
        delete node.preSearchHome;&lt;br /&gt;
        delete node.motionActive;&lt;br /&gt;
      });&lt;br /&gt;
      nodes = nodes.filter(function (node) { return !node.searchAdded; });&lt;br /&gt;
      edges = edges.filter(function (edge) {&lt;br /&gt;
        return !edge.searchAdded &amp;amp;&amp;amp;&lt;br /&gt;
          !addedKeys[canonicalId(edge.source.id)] &amp;amp;&amp;amp;&lt;br /&gt;
          !addedKeys[canonicalId(edge.target.id)];&lt;br /&gt;
      });&lt;br /&gt;
      searchCluster = null;&lt;br /&gt;
      clearSearch.hidden = true;&lt;br /&gt;
      selected = null;&lt;br /&gt;
      showInspector(null);&lt;br /&gt;
      updateCounts();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function focusSearchResults(items, query) {&lt;br /&gt;
      resetSearchCluster();&lt;br /&gt;
      if (!items.length) {&lt;br /&gt;
        fitGraph();&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
&lt;br /&gt;
      var resultNodes = [];&lt;br /&gt;
      items.forEach(function (item, index) {&lt;br /&gt;
        var key = canonicalId(item.id);&lt;br /&gt;
        var node = byId[key];&lt;br /&gt;
        if (!node) {&lt;br /&gt;
          node = {&lt;br /&gt;
            id: item.id,&lt;br /&gt;
            title: item.title || String(item.id).replace(/^Lesson:/, &#039;&#039;),&lt;br /&gt;
            confidence: item.confidence || &#039;low&#039;,&lt;br /&gt;
            updatedAt: &#039;&#039;,&lt;br /&gt;
            x: 0,&lt;br /&gt;
            y: 0,&lt;br /&gt;
            vx: 0,&lt;br /&gt;
            vy: 0,&lt;br /&gt;
            degree: 0,&lt;br /&gt;
            homeX: 0,&lt;br /&gt;
            homeY: 0,&lt;br /&gt;
            searchAdded: true&lt;br /&gt;
          };&lt;br /&gt;
          nodes.push(node);&lt;br /&gt;
          byId[key] = node;&lt;br /&gt;
        } else {&lt;br /&gt;
          node.preSearchPosition = { x: node.x, y: node.y };&lt;br /&gt;
          node.preSearchHome = { x: node.homeX, y: node.homeY };&lt;br /&gt;
          node.title = item.title || node.title;&lt;br /&gt;
          node.confidence = item.confidence || node.confidence;&lt;br /&gt;
        }&lt;br /&gt;
        node.isSearchResult = true;&lt;br /&gt;
        node.searchRank = index + 1;&lt;br /&gt;
        resultNodes.push(node);&lt;br /&gt;
      });&lt;br /&gt;
&lt;br /&gt;
      items.forEach(function (item) {&lt;br /&gt;
        (Array.isArray(item.relations) ? item.relations : []).forEach(function (relationItem) {&lt;br /&gt;
          var source = byId[canonicalId(relationItem.from || item.id)];&lt;br /&gt;
          var target = byId[canonicalId(relationItem.to)];&lt;br /&gt;
          var relation = relationDefinition(relationItem.relation);&lt;br /&gt;
          if (!source || !target || !relation || source === target) {&lt;br /&gt;
            return;&lt;br /&gt;
          }&lt;br /&gt;
          var duplicate = edges.some(function (edge) {&lt;br /&gt;
            return canonicalId(edge.source.id) === canonicalId(source.id) &amp;amp;&amp;amp;&lt;br /&gt;
              canonicalId(edge.target.id) === canonicalId(target.id) &amp;amp;&amp;amp;&lt;br /&gt;
              edge.relation.key === relation.key;&lt;br /&gt;
          });&lt;br /&gt;
          if (!duplicate) {&lt;br /&gt;
            edges.push({&lt;br /&gt;
              source: source,&lt;br /&gt;
              target: target,&lt;br /&gt;
              relation: relation,&lt;br /&gt;
              searchAdded: true&lt;br /&gt;
            });&lt;br /&gt;
            source.degree += 1;&lt;br /&gt;
            target.degree += 1;&lt;br /&gt;
          }&lt;br /&gt;
        });&lt;br /&gt;
      });&lt;br /&gt;
&lt;br /&gt;
      var clusterRadius = resultNodes.length === 1 ? 0 :&lt;br /&gt;
        Math.max(82, Math.min(132, resultNodes.length * 22));&lt;br /&gt;
      resultNodes.forEach(function (node, index) {&lt;br /&gt;
        var angle = -Math.PI / 2 + (Math.PI * 2 * index / resultNodes.length);&lt;br /&gt;
        node.x = Math.cos(angle) * clusterRadius;&lt;br /&gt;
        node.y = Math.sin(angle) * clusterRadius;&lt;br /&gt;
        node.homeX = node.x;&lt;br /&gt;
        node.homeY = node.y;&lt;br /&gt;
        node.vx = 0;&lt;br /&gt;
        node.vy = 0;&lt;br /&gt;
      });&lt;br /&gt;
      searchCluster = {&lt;br /&gt;
        query: query,&lt;br /&gt;
        nodes: resultNodes,&lt;br /&gt;
        x: 0,&lt;br /&gt;
        y: 0,&lt;br /&gt;
        radius: clusterRadius&lt;br /&gt;
      };&lt;br /&gt;
      activeRelation = &#039;all&#039;;&lt;br /&gt;
      filter.value = &#039;all&#039;;&lt;br /&gt;
      clearSearch.hidden = false;&lt;br /&gt;
      transform.scale = Math.max(0.72, Math.min(&lt;br /&gt;
        1.35,&lt;br /&gt;
        Math.min(size.width, size.height) / Math.max(360, clusterRadius * 2 + 150)&lt;br /&gt;
      ));&lt;br /&gt;
      transform.x = size.width / 2;&lt;br /&gt;
      transform.y = size.height / 2;&lt;br /&gt;
      selectNode(resultNodes[0]);&lt;br /&gt;
      updateCounts();&lt;br /&gt;
      draw();&lt;br /&gt;
      viewport.scrollIntoView({ behavior: &#039;smooth&#039;, block: &#039;center&#039; });&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function connectionSummary(node) {&lt;br /&gt;
      var connected = edges.filter(function (edge) {&lt;br /&gt;
        return edge.source === node || edge.target === node;&lt;br /&gt;
      });&lt;br /&gt;
      var summary = Object.create(null);&lt;br /&gt;
      connected.forEach(function (edge) {&lt;br /&gt;
        summary[edge.relation.label] = (summary[edge.relation.label] || 0) + 1;&lt;br /&gt;
      });&lt;br /&gt;
      return { edges: connected, counts: summary };&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function showInspector(node) {&lt;br /&gt;
      inspector.replaceChildren();&lt;br /&gt;
      if (!node) {&lt;br /&gt;
        var welcome = makeElement(&#039;div&#039;, &#039;s3rm-inspector-welcome&#039;);&lt;br /&gt;
        welcome.appendChild(makeElement(&#039;span&#039;, &#039;s3rm-inspector-symbol&#039;, &#039;⌁&#039;));&lt;br /&gt;
        welcome.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;메모를 선택하세요&#039;));&lt;br /&gt;
        welcome.appendChild(makeElement(&#039;p&#039;, &#039;&#039;, &#039;점 하나를 누르면 메모 정보와 연결된 기록을 보여 드립니다.&#039;));&lt;br /&gt;
        inspector.appendChild(welcome);&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      var summary = connectionSummary(node);&lt;br /&gt;
      inspector.appendChild(makeElement(&lt;br /&gt;
        &#039;div&#039;,&lt;br /&gt;
        &#039;s3rm-inspector-kicker&#039;,&lt;br /&gt;
        node.isSearchResult ? &#039;검색 결과 &#039; + node.searchRank : &#039;선택한 메모&#039;&lt;br /&gt;
      ));&lt;br /&gt;
      inspector.appendChild(makeElement(&#039;h3&#039;, &#039;&#039;, node.title));&lt;br /&gt;
      var meta = makeElement(&#039;div&#039;, &#039;s3rm-inspector-meta&#039;);&lt;br /&gt;
      meta.appendChild(makeElement(&#039;span&#039;, &#039;s3rm-confidence s3rm-confidence-&#039; + node.confidence, confidenceLabel(node.confidence)));&lt;br /&gt;
      meta.appendChild(makeElement(&#039;span&#039;, &#039;&#039;, summary.edges.length + &#039;개 연결&#039;));&lt;br /&gt;
      inspector.appendChild(meta);&lt;br /&gt;
      if (Object.keys(summary.counts).length) {&lt;br /&gt;
        var relationList = makeElement(&#039;ul&#039;, &#039;s3rm-inspector-relations&#039;);&lt;br /&gt;
        Object.keys(summary.counts).forEach(function (label) {&lt;br /&gt;
          var item = makeElement(&#039;li&#039;, &#039;&#039;);&lt;br /&gt;
          item.append(makeElement(&#039;span&#039;, &#039;&#039;, label), makeElement(&#039;strong&#039;, &#039;&#039;, String(summary.counts[label])));&lt;br /&gt;
          relationList.appendChild(item);&lt;br /&gt;
        });&lt;br /&gt;
        inspector.appendChild(relationList);&lt;br /&gt;
      } else {&lt;br /&gt;
        inspector.appendChild(makeElement(&#039;p&#039;, &#039;s3rm-inspector-muted&#039;, &#039;아직 다른 메모와 연결되지 않았습니다.&#039;));&lt;br /&gt;
      }&lt;br /&gt;
      var open = makeElement(&#039;a&#039;, &#039;s3rm-graph-primary-link&#039;, &#039;메모 열기&#039;);&lt;br /&gt;
      open.href = mw.util.getUrl(node.id);&lt;br /&gt;
      inspector.appendChild(open);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function canvasPoint(event) {&lt;br /&gt;
      var rect = canvas.getBoundingClientRect();&lt;br /&gt;
      return { x: event.clientX - rect.left, y: event.clientY - rect.top };&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function worldPoint(event) {&lt;br /&gt;
      var point = canvasPoint(event);&lt;br /&gt;
      return {&lt;br /&gt;
        x: (point.x - transform.x) / transform.scale,&lt;br /&gt;
        y: (point.y - transform.y) / transform.scale&lt;br /&gt;
      };&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function hitNode(event) {&lt;br /&gt;
      var point = worldPoint(event);&lt;br /&gt;
      for (var i = nodes.length - 1; i &amp;gt;= 0; i -= 1) {&lt;br /&gt;
        var node = nodes[i];&lt;br /&gt;
        var dx = point.x - node.x;&lt;br /&gt;
        var dy = point.y - node.y;&lt;br /&gt;
        if (dx * dx + dy * dy &amp;lt;= Math.pow(nodeRadius(node) + 6 / transform.scale, 2)) {&lt;br /&gt;
          return node;&lt;br /&gt;
        }&lt;br /&gt;
      }&lt;br /&gt;
      return null;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function selectNode(node) {&lt;br /&gt;
      selected = node;&lt;br /&gt;
      showInspector(node);&lt;br /&gt;
      draw();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    canvas.addEventListener(&#039;pointerdown&#039;, function (event) {&lt;br /&gt;
      dragging = hitNode(event);&lt;br /&gt;
      panning = !dragging;&lt;br /&gt;
      pointerStart = canvasPoint(event);&lt;br /&gt;
      if (dragging) {&lt;br /&gt;
        var world = worldPoint(event);&lt;br /&gt;
        dragMotion = {&lt;br /&gt;
          x: world.x,&lt;br /&gt;
          y: world.y,&lt;br /&gt;
          time: event.timeStamp,&lt;br /&gt;
          vx: 0,&lt;br /&gt;
          vy: 0&lt;br /&gt;
        };&lt;br /&gt;
        dragging.motionActive = true;&lt;br /&gt;
        startSimulation(120, false);&lt;br /&gt;
      }&lt;br /&gt;
      canvas.setPointerCapture(event.pointerId);&lt;br /&gt;
      canvas.classList.add(&#039;is-dragging&#039;);&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;pointermove&#039;, function (event) {&lt;br /&gt;
      if (dragging) {&lt;br /&gt;
        var world = worldPoint(event);&lt;br /&gt;
        if (dragMotion) {&lt;br /&gt;
          var elapsed = Math.max(8, event.timeStamp - dragMotion.time);&lt;br /&gt;
          var frameRatio = 16.67 / elapsed;&lt;br /&gt;
          var instantVX = (world.x - dragMotion.x) * frameRatio;&lt;br /&gt;
          var instantVY = (world.y - dragMotion.y) * frameRatio;&lt;br /&gt;
          dragMotion.vx = dragMotion.vx * 0.45 + instantVX * 0.55;&lt;br /&gt;
          dragMotion.vy = dragMotion.vy * 0.45 + instantVY * 0.55;&lt;br /&gt;
          var speed = Math.sqrt(dragMotion.vx * dragMotion.vx + dragMotion.vy * dragMotion.vy);&lt;br /&gt;
          if (speed &amp;gt; 14) {&lt;br /&gt;
            dragMotion.vx = dragMotion.vx / speed * 14;&lt;br /&gt;
            dragMotion.vy = dragMotion.vy / speed * 14;&lt;br /&gt;
          }&lt;br /&gt;
          dragMotion.x = world.x;&lt;br /&gt;
          dragMotion.y = world.y;&lt;br /&gt;
          dragMotion.time = event.timeStamp;&lt;br /&gt;
        }&lt;br /&gt;
        dragging.x = world.x;&lt;br /&gt;
        dragging.y = world.y;&lt;br /&gt;
        startSimulation(120, false);&lt;br /&gt;
      } else if (panning &amp;amp;&amp;amp; pointerStart) {&lt;br /&gt;
        var point = canvasPoint(event);&lt;br /&gt;
        transform.x += point.x - pointerStart.x;&lt;br /&gt;
        transform.y += point.y - pointerStart.y;&lt;br /&gt;
        pointerStart = point;&lt;br /&gt;
      } else {&lt;br /&gt;
        hovered = hitNode(event);&lt;br /&gt;
        canvas.style.cursor = hovered ? &#039;pointer&#039; : &#039;grab&#039;;&lt;br /&gt;
      }&lt;br /&gt;
      draw();&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;pointerup&#039;, function (event) {&lt;br /&gt;
      var hit = hitNode(event);&lt;br /&gt;
      var releasedNode = dragging;&lt;br /&gt;
      if (releasedNode &amp;amp;&amp;amp; dragMotion) {&lt;br /&gt;
        releasedNode.vx = reduceMotion ? 0 : dragMotion.vx;&lt;br /&gt;
        releasedNode.vy = reduceMotion ? 0 : dragMotion.vy;&lt;br /&gt;
      }&lt;br /&gt;
      dragging = null;&lt;br /&gt;
      panning = false;&lt;br /&gt;
      pointerStart = null;&lt;br /&gt;
      dragMotion = null;&lt;br /&gt;
      canvas.classList.remove(&#039;is-dragging&#039;);&lt;br /&gt;
      if (releasedNode) {&lt;br /&gt;
        if (hit === releasedNode) {&lt;br /&gt;
          selectNode(releasedNode);&lt;br /&gt;
        }&lt;br /&gt;
        startSimulation(140, false);&lt;br /&gt;
      }&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;pointercancel&#039;, function () {&lt;br /&gt;
      if (dragging) {&lt;br /&gt;
        dragging.vx = 0;&lt;br /&gt;
        dragging.vy = 0;&lt;br /&gt;
        delete dragging.motionActive;&lt;br /&gt;
      }&lt;br /&gt;
      dragging = null;&lt;br /&gt;
      panning = false;&lt;br /&gt;
      pointerStart = null;&lt;br /&gt;
      dragMotion = null;&lt;br /&gt;
      canvas.classList.remove(&#039;is-dragging&#039;);&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;dblclick&#039;, function (event) {&lt;br /&gt;
      var node = hitNode(event);&lt;br /&gt;
      if (node) {&lt;br /&gt;
        window.location.href = mw.util.getUrl(node.id);&lt;br /&gt;
      }&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;wheel&#039;, function (event) {&lt;br /&gt;
      event.preventDefault();&lt;br /&gt;
      var point = canvasPoint(event);&lt;br /&gt;
      var oldScale = transform.scale;&lt;br /&gt;
      var nextScale = Math.max(0.25, Math.min(3.5, oldScale * Math.exp(-event.deltaY * 0.0012)));&lt;br /&gt;
      transform.x = point.x - ((point.x - transform.x) / oldScale) * nextScale;&lt;br /&gt;
      transform.y = point.y - ((point.y - transform.y) / oldScale) * nextScale;&lt;br /&gt;
      transform.scale = nextScale;&lt;br /&gt;
      draw();&lt;br /&gt;
    }, { passive: false });&lt;br /&gt;
&lt;br /&gt;
    search.addEventListener(&#039;input&#039;, function () {&lt;br /&gt;
      var query = search.value.trim().toLocaleLowerCase(&#039;ko&#039;);&lt;br /&gt;
      searchResults.replaceChildren();&lt;br /&gt;
      if (!query) {&lt;br /&gt;
        searchResults.hidden = true;&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      var matches = nodes.filter(function (node) {&lt;br /&gt;
        return node.title.toLocaleLowerCase(&#039;ko&#039;).includes(query) || node.id.toLowerCase().includes(query);&lt;br /&gt;
      }).slice(0, 6);&lt;br /&gt;
      matches.forEach(function (node) {&lt;br /&gt;
        var button = makeElement(&#039;button&#039;, &#039;&#039;, node.title);&lt;br /&gt;
        button.type = &#039;button&#039;;&lt;br /&gt;
        button.addEventListener(&#039;click&#039;, function () {&lt;br /&gt;
          selectNode(node);&lt;br /&gt;
          transform.x = size.width / 2 - node.x * transform.scale;&lt;br /&gt;
          transform.y = size.height / 2 - node.y * transform.scale;&lt;br /&gt;
          search.value = node.title;&lt;br /&gt;
          searchResults.hidden = true;&lt;br /&gt;
          draw();&lt;br /&gt;
        });&lt;br /&gt;
        searchResults.appendChild(button);&lt;br /&gt;
      });&lt;br /&gt;
      if (!matches.length) {&lt;br /&gt;
        searchResults.appendChild(makeElement(&#039;span&#039;, &#039;&#039;, &#039;일치하는 메모가 없습니다.&#039;));&lt;br /&gt;
      }&lt;br /&gt;
      searchResults.hidden = false;&lt;br /&gt;
    });&lt;br /&gt;
    search.addEventListener(&#039;keydown&#039;, function (event) {&lt;br /&gt;
      if (event.key === &#039;Escape&#039;) {&lt;br /&gt;
        searchResults.hidden = true;&lt;br /&gt;
      } else if (event.key === &#039;Enter&#039;) {&lt;br /&gt;
        var first = searchResults.querySelector(&#039;button&#039;);&lt;br /&gt;
        if (first) { first.click(); }&lt;br /&gt;
      }&lt;br /&gt;
    });&lt;br /&gt;
    filter.addEventListener(&#039;change&#039;, function () {&lt;br /&gt;
      activeRelation = filter.value;&lt;br /&gt;
      updateCounts();&lt;br /&gt;
      draw();&lt;br /&gt;
    });&lt;br /&gt;
    reset.addEventListener(&#039;click&#039;, fitGraph);&lt;br /&gt;
    clearSearch.addEventListener(&#039;click&#039;, function () {&lt;br /&gt;
      resetSearchCluster();&lt;br /&gt;
      fitGraph();&lt;br /&gt;
    });&lt;br /&gt;
&lt;br /&gt;
    showInspector(null);&lt;br /&gt;
    resize();&lt;br /&gt;
    var resizeObserver = window.ResizeObserver ? new ResizeObserver(resize) : null;&lt;br /&gt;
    if (resizeObserver) {&lt;br /&gt;
      resizeObserver.observe(viewport);&lt;br /&gt;
    } else {&lt;br /&gt;
      window.addEventListener(&#039;resize&#039;, resize);&lt;br /&gt;
    }&lt;br /&gt;
    graphSearchController = focusSearchResults;&lt;br /&gt;
    if (pendingGraphSearch) {&lt;br /&gt;
      focusSearchResults(pendingGraphSearch.items, pendingGraphSearch.query);&lt;br /&gt;
      pendingGraphSearch = null;&lt;br /&gt;
    }&lt;br /&gt;
    startSimulation(160, true);&lt;br /&gt;
    window.addEventListener(&#039;pagehide&#039;, function () {&lt;br /&gt;
      window.cancelAnimationFrame(animationFrame);&lt;br /&gt;
      if (resizeObserver) { resizeObserver.disconnect(); }&lt;br /&gt;
    }, { once: true });&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  var askQuery = &#039;[[Category:Lessons]]&#039; +&lt;br /&gt;
    &#039;|?Lesson title|?Confidence|?Updated at&#039; +&lt;br /&gt;
    RELATIONS.map(function (relation) { return &#039;|?&#039; + relation.property; }).join(&#039;&#039;) +&lt;br /&gt;
    &#039;|sort=Updated at|order=descending|limit=&#039; + NODE_LIMIT;&lt;br /&gt;
&lt;br /&gt;
  createSemanticSearch();&lt;br /&gt;
&lt;br /&gt;
  mw.loader.using([&#039;mediawiki.api&#039;, &#039;mediawiki.util&#039;]).then(function () {&lt;br /&gt;
    return new mw.Api().get({ action: &#039;ask&#039;, query: askQuery, formatversion: 2 });&lt;br /&gt;
  }).then(function (data) {&lt;br /&gt;
    var graph = parseGraph(data);&lt;br /&gt;
    if (!graph.nodes.length) {&lt;br /&gt;
      renderEmpty();&lt;br /&gt;
      return;&lt;br /&gt;
    }&lt;br /&gt;
    createGraph(graph);&lt;br /&gt;
  }).catch(renderError);&lt;br /&gt;
}());&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%EB%AF%B8%EB%94%94%EC%96%B4%EC%9C%84%ED%82%A4:Common.js&amp;diff=2751</id>
		<title>미디어위키:Common.js</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%EB%AF%B8%EB%94%94%EC%96%B4%EC%9C%84%ED%82%A4:Common.js&amp;diff=2751"/>
		<updated>2026-07-19T18:55:24Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 원본 스키마 동기화&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;(function () {&lt;br /&gt;
  &#039;use strict&#039;;&lt;br /&gt;
&lt;br /&gt;
  var root = document.getElementById(&#039;s3rm-knowledge-graph&#039;);&lt;br /&gt;
  var semanticRoot = document.getElementById(&#039;s3rm-semantic-search&#039;);&lt;br /&gt;
  if (!root || !window.mw) {&lt;br /&gt;
    return;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  var RELATIONS = [&lt;br /&gt;
    { property: &#039;Supports&#039;, key: &#039;supports&#039;, label: &#039;뒷받침함&#039;, color: &#039;#5f7ae6&#039; },&lt;br /&gt;
    { property: &#039;Contradicts&#039;, key: &#039;contradicts&#039;, label: &#039;상충함&#039;, color: &#039;#d0605b&#039; },&lt;br /&gt;
    { property: &#039;Analogous to&#039;, key: &#039;analogous_to&#039;, label: &#039;유사함&#039;, color: &#039;#a17bd7&#039; },&lt;br /&gt;
    { property: &#039;Failed because&#039;, key: &#039;failed_because&#039;, label: &#039;실패 원인&#039;, color: &#039;#dc8b45&#039; },&lt;br /&gt;
    { property: &#039;Works when&#039;, key: &#039;works_when&#039;, label: &#039;작동 조건&#039;, color: &#039;#43a27b&#039; },&lt;br /&gt;
    { property: &#039;Suggests&#039;, key: &#039;suggests&#039;, label: &#039;후속 제안&#039;, color: &#039;#3f9db7&#039; },&lt;br /&gt;
    { property: &#039;Supersedes&#039;, key: &#039;supersedes&#039;, label: &#039;대체함&#039;, color: &#039;#7d849e&#039; }&lt;br /&gt;
  ];&lt;br /&gt;
  var NODE_LIMIT = 160;&lt;br /&gt;
  var EDGE_LIMIT = 480;&lt;br /&gt;
  var SEMANTIC_RETRY_LIMIT = 15;&lt;br /&gt;
  var mcpReady = null;&lt;br /&gt;
  var graphSearchController = null;&lt;br /&gt;
  var pendingGraphSearch = null;&lt;br /&gt;
&lt;br /&gt;
  function mcpRequest(payload, expectBody) {&lt;br /&gt;
    return window.fetch(&#039;/mcp&#039;, {&lt;br /&gt;
      method: &#039;POST&#039;,&lt;br /&gt;
      credentials: &#039;same-origin&#039;,&lt;br /&gt;
      headers: {&lt;br /&gt;
        &#039;Content-Type&#039;: &#039;application/json&#039;,&lt;br /&gt;
        &#039;Accept&#039;: &#039;application/json, text/event-stream&#039;&lt;br /&gt;
      },&lt;br /&gt;
      body: JSON.stringify(payload)&lt;br /&gt;
    }).then(function (response) {&lt;br /&gt;
      if (!response.ok) {&lt;br /&gt;
        throw new Error(&#039;MCP HTTP &#039; + response.status);&lt;br /&gt;
      }&lt;br /&gt;
      if (!expectBody) {&lt;br /&gt;
        return null;&lt;br /&gt;
      }&lt;br /&gt;
      return response.json();&lt;br /&gt;
    });&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function initializeMcp() {&lt;br /&gt;
    if (!mcpReady) {&lt;br /&gt;
      mcpReady = mcpRequest({&lt;br /&gt;
        jsonrpc: &#039;2.0&#039;,&lt;br /&gt;
        id: 1,&lt;br /&gt;
        method: &#039;initialize&#039;,&lt;br /&gt;
        params: {&lt;br /&gt;
          protocolVersion: &#039;2025-06-18&#039;,&lt;br /&gt;
          capabilities: {},&lt;br /&gt;
          clientInfo: { name: &#039;s3rm-wiki-search&#039;, version: &#039;1&#039; }&lt;br /&gt;
        }&lt;br /&gt;
      }, true).then(function (response) {&lt;br /&gt;
        if (!response || response.error) {&lt;br /&gt;
          throw new Error(&#039;MCP initialization failed&#039;);&lt;br /&gt;
        }&lt;br /&gt;
        return mcpRequest({&lt;br /&gt;
          jsonrpc: &#039;2.0&#039;,&lt;br /&gt;
          method: &#039;notifications/initialized&#039;,&lt;br /&gt;
          params: {}&lt;br /&gt;
        }, false);&lt;br /&gt;
      });&lt;br /&gt;
    }&lt;br /&gt;
    return mcpReady;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function searchSemantically(query, attempt, onWaiting) {&lt;br /&gt;
    var retryCount = attempt || 0;&lt;br /&gt;
    return initializeMcp().then(function () {&lt;br /&gt;
      return mcpRequest({&lt;br /&gt;
        jsonrpc: &#039;2.0&#039;,&lt;br /&gt;
        id: 2,&lt;br /&gt;
        method: &#039;tools/call&#039;,&lt;br /&gt;
        params: {&lt;br /&gt;
          name: &#039;search_lessons&#039;,&lt;br /&gt;
          arguments: {&lt;br /&gt;
            query: query,&lt;br /&gt;
            retrieval_mode: &#039;hybrid_v1&#039;,&lt;br /&gt;
            fields: [&#039;title&#039;, &#039;question&#039;, &#039;reusable_lesson&#039;, &#039;confidence&#039;, &#039;relations&#039;],&lt;br /&gt;
            limit: 6&lt;br /&gt;
          }&lt;br /&gt;
        }&lt;br /&gt;
      }, true);&lt;br /&gt;
    }).then(function (response) {&lt;br /&gt;
      if (!response || response.error || !response.result || response.result.isError) {&lt;br /&gt;
        throw new Error(&#039;Semantic search failed&#039;);&lt;br /&gt;
      }&lt;br /&gt;
      var payload = response.result.structuredContent || {};&lt;br /&gt;
      if (payload.semantic_status === &#039;building&#039; &amp;amp;&amp;amp; retryCount &amp;lt; SEMANTIC_RETRY_LIMIT) {&lt;br /&gt;
        if (onWaiting) {&lt;br /&gt;
          onWaiting();&lt;br /&gt;
        }&lt;br /&gt;
        return new Promise(function (resolve) {&lt;br /&gt;
          window.setTimeout(resolve, 2000);&lt;br /&gt;
        }).then(function () {&lt;br /&gt;
          return searchSemantically(query, retryCount + 1, onWaiting);&lt;br /&gt;
        });&lt;br /&gt;
      }&lt;br /&gt;
      return payload;&lt;br /&gt;
    });&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function textValue(value) {&lt;br /&gt;
    if (value === null || value === undefined) {&lt;br /&gt;
      return &#039;&#039;;&lt;br /&gt;
    }&lt;br /&gt;
    if (typeof value === &#039;string&#039; || typeof value === &#039;number&#039;) {&lt;br /&gt;
      return String(value);&lt;br /&gt;
    }&lt;br /&gt;
    return String(value.fulltext || value.displaytitle || value.raw || value.timestamp || &#039;&#039;);&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function firstValue(values, fallback) {&lt;br /&gt;
    return values &amp;amp;&amp;amp; values.length ? textValue(values[0]) : fallback;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function confidenceLabel(confidence) {&lt;br /&gt;
    if (confidence === &#039;high&#039;) { return &#039;신뢰도: 높음&#039;; }&lt;br /&gt;
    if (confidence === &#039;medium&#039;) { return &#039;신뢰도: 중간&#039;; }&lt;br /&gt;
    return &#039;신뢰도: 낮음&#039;;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function parseGraph(data) {&lt;br /&gt;
    var rawResults = data &amp;amp;&amp;amp; data.query &amp;amp;&amp;amp; data.query.results ? data.query.results : {};&lt;br /&gt;
    var entries = Array.isArray(rawResults)&lt;br /&gt;
      ? rawResults.map(function (item) { return [item.fulltext, item]; })&lt;br /&gt;
      : Object.keys(rawResults).map(function (key) { return [key, rawResults[key]]; });&lt;br /&gt;
    var nodes = [];&lt;br /&gt;
    var byId = Object.create(null);&lt;br /&gt;
&lt;br /&gt;
    entries.slice(0, NODE_LIMIT).forEach(function (entry, index) {&lt;br /&gt;
      var id = entry[0];&lt;br /&gt;
      var result = entry[1] || {};&lt;br /&gt;
      var printouts = result.printouts || {};&lt;br /&gt;
      var angle = index * 2.399963229728653;&lt;br /&gt;
      var radius = 20 * Math.sqrt(index + 1);&lt;br /&gt;
      var node = {&lt;br /&gt;
        id: id,&lt;br /&gt;
        title: firstValue(printouts[&#039;Lesson title&#039;], id.replace(/^Lesson:/, &#039;&#039;)),&lt;br /&gt;
        confidence: firstValue(printouts.Confidence, &#039;low&#039;).toLowerCase(),&lt;br /&gt;
        updatedAt: firstValue(printouts[&#039;Updated at&#039;], &#039;&#039;),&lt;br /&gt;
        x: Math.cos(angle) * radius,&lt;br /&gt;
        y: Math.sin(angle) * radius,&lt;br /&gt;
        vx: 0,&lt;br /&gt;
        vy: 0,&lt;br /&gt;
        degree: 0,&lt;br /&gt;
        printouts: printouts&lt;br /&gt;
      };&lt;br /&gt;
      nodes.push(node);&lt;br /&gt;
      byId[id] = node;&lt;br /&gt;
    });&lt;br /&gt;
&lt;br /&gt;
    var edges = [];&lt;br /&gt;
    nodes.forEach(function (node) {&lt;br /&gt;
      RELATIONS.forEach(function (relation) {&lt;br /&gt;
        var targets = node.printouts[relation.property] || [];&lt;br /&gt;
        targets.forEach(function (target) {&lt;br /&gt;
          var targetId = textValue(target);&lt;br /&gt;
          if (edges.length &amp;lt; EDGE_LIMIT &amp;amp;&amp;amp; byId[targetId] &amp;amp;&amp;amp; targetId !== node.id) {&lt;br /&gt;
            edges.push({ source: node, target: byId[targetId], relation: relation });&lt;br /&gt;
            node.degree += 1;&lt;br /&gt;
            byId[targetId].degree += 1;&lt;br /&gt;
          }&lt;br /&gt;
        });&lt;br /&gt;
      });&lt;br /&gt;
      delete node.printouts;&lt;br /&gt;
    });&lt;br /&gt;
&lt;br /&gt;
    var connectedNodes = nodes.filter(function (node) { return node.degree &amp;gt; 0; });&lt;br /&gt;
    var isolatedNodes = nodes.filter(function (node) { return node.degree === 0; });&lt;br /&gt;
    connectedNodes.forEach(function (node, index) {&lt;br /&gt;
      var angle = index * 2.399963229728653;&lt;br /&gt;
      var radius = 16 * Math.sqrt(index + 1);&lt;br /&gt;
      node.x = Math.cos(angle) * radius;&lt;br /&gt;
      node.y = Math.sin(angle) * radius;&lt;br /&gt;
    });&lt;br /&gt;
    isolatedNodes.forEach(function (node, index) {&lt;br /&gt;
      var angle = index * 2.399963229728653 + 0.7;&lt;br /&gt;
      var radius = 92 + 10 * Math.sqrt(index + 1);&lt;br /&gt;
      node.x = Math.cos(angle) * radius;&lt;br /&gt;
      node.y = Math.sin(angle) * radius;&lt;br /&gt;
    });&lt;br /&gt;
    nodes.forEach(function (node) {&lt;br /&gt;
      node.homeX = node.x;&lt;br /&gt;
      node.homeY = node.y;&lt;br /&gt;
    });&lt;br /&gt;
    return { nodes: nodes, edges: edges };&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function makeElement(tag, className, text) {&lt;br /&gt;
    var element = document.createElement(tag);&lt;br /&gt;
    if (className) {&lt;br /&gt;
      element.className = className;&lt;br /&gt;
    }&lt;br /&gt;
    if (text !== undefined) {&lt;br /&gt;
      element.textContent = text;&lt;br /&gt;
    }&lt;br /&gt;
    return element;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function createSemanticSearch() {&lt;br /&gt;
    if (!semanticRoot) {&lt;br /&gt;
      return;&lt;br /&gt;
    }&lt;br /&gt;
    semanticRoot.replaceChildren();&lt;br /&gt;
    var intro = makeElement(&#039;div&#039;, &#039;s3rm-semantic-intro&#039;);&lt;br /&gt;
    intro.appendChild(makeElement(&#039;div&#039;, &#039;s3rm-semantic-icon&#039;, &#039;⌕&#039;));&lt;br /&gt;
    var introCopy = makeElement(&#039;div&#039;, &#039;&#039;);&lt;br /&gt;
    introCopy.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;검색&#039;));&lt;br /&gt;
    introCopy.appendChild(makeElement(&#039;p&#039;, &#039;&#039;, &#039;현상이나 궁금한 점을 문장으로 입력하세요. 표현이 달라도 관련 메모를 찾습니다.&#039;));&lt;br /&gt;
    intro.appendChild(introCopy);&lt;br /&gt;
&lt;br /&gt;
    var form = makeElement(&#039;form&#039;, &#039;s3rm-semantic-form&#039;);&lt;br /&gt;
    var input = makeElement(&#039;input&#039;, &#039;s3rm-semantic-input&#039;);&lt;br /&gt;
    input.type = &#039;search&#039;;&lt;br /&gt;
    input.name = &#039;query&#039;;&lt;br /&gt;
    input.maxLength = 500;&lt;br /&gt;
    input.placeholder = &#039;예: GPU 메모리는 남아 있는데 할당이 실패하는 이유는?&#039;;&lt;br /&gt;
    input.setAttribute(&#039;aria-label&#039;, &#039;메모 검색&#039;);&lt;br /&gt;
    var submit = makeElement(&#039;button&#039;, &#039;s3rm-semantic-submit&#039;, &#039;검색&#039;);&lt;br /&gt;
    submit.type = &#039;submit&#039;;&lt;br /&gt;
    form.append(input, submit);&lt;br /&gt;
&lt;br /&gt;
    var examples = makeElement(&#039;div&#039;, &#039;s3rm-semantic-examples&#039;);&lt;br /&gt;
    examples.appendChild(makeElement(&#039;span&#039;, &#039;&#039;, &#039;검색 예시&#039;));&lt;br /&gt;
    [&lt;br /&gt;
      &#039;재시도가 한꺼번에 몰리는 이유&#039;,&lt;br /&gt;
      &#039;GPU와 SSD 사이 병목을 줄이는 방법&#039;,&lt;br /&gt;
      &#039;긴 작업에서 지연이 갑자기 커지는 원인&#039;&lt;br /&gt;
    ].forEach(function (example) {&lt;br /&gt;
      var button = makeElement(&#039;button&#039;, &#039;&#039;, example);&lt;br /&gt;
      button.type = &#039;button&#039;;&lt;br /&gt;
      button.addEventListener(&#039;click&#039;, function () {&lt;br /&gt;
        input.value = example;&lt;br /&gt;
        form.requestSubmit();&lt;br /&gt;
      });&lt;br /&gt;
      examples.appendChild(button);&lt;br /&gt;
    });&lt;br /&gt;
&lt;br /&gt;
    var feedback = makeElement(&#039;div&#039;, &#039;s3rm-semantic-feedback&#039;);&lt;br /&gt;
    feedback.setAttribute(&#039;aria-live&#039;, &#039;polite&#039;);&lt;br /&gt;
    feedback.hidden = true;&lt;br /&gt;
    semanticRoot.append(intro, form, examples, feedback);&lt;br /&gt;
&lt;br /&gt;
    function renderSearchResults(payload, query) {&lt;br /&gt;
      var items = Array.isArray(payload.items) ? payload.items : [];&lt;br /&gt;
      if (graphSearchController) {&lt;br /&gt;
        graphSearchController(items, query);&lt;br /&gt;
      } else {&lt;br /&gt;
        pendingGraphSearch = { items: items, query: query };&lt;br /&gt;
      }&lt;br /&gt;
      feedback.replaceChildren();&lt;br /&gt;
      feedback.hidden = false;&lt;br /&gt;
      var resultHeader = makeElement(&#039;div&#039;, &#039;s3rm-semantic-result-header&#039;);&lt;br /&gt;
      resultHeader.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;“&#039; + query + &#039;” 검색 결과&#039;));&lt;br /&gt;
      resultHeader.appendChild(makeElement(&#039;span&#039;, &#039;s3rm-semantic-mode&#039;, items.length + &#039;개 메모&#039;));&lt;br /&gt;
      feedback.appendChild(resultHeader);&lt;br /&gt;
&lt;br /&gt;
      if (!items.length) {&lt;br /&gt;
        var emptyMessage = payload.semantic_status === &#039;building&#039;&lt;br /&gt;
          ? &#039;검색을 준비하고 있습니다. 잠시 후 다시 검색해 주세요.&#039;&lt;br /&gt;
          : &#039;가까운 메모를 찾지 못했습니다. 표현을 조금 바꿔 다시 검색해 보세요.&#039;;&lt;br /&gt;
        feedback.appendChild(makeElement(&#039;p&#039;, &#039;s3rm-semantic-empty&#039;, emptyMessage));&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      feedback.appendChild(makeElement(&#039;p&#039;, &#039;s3rm-semantic-graph-note&#039;, &#039;아래 그래프에 검색 결과를 모았습니다. 점을 선택하면 메모와 연결을 볼 수 있습니다.&#039;));&lt;br /&gt;
      var list = makeElement(&#039;div&#039;, &#039;s3rm-semantic-result-links&#039;);&lt;br /&gt;
      items.forEach(function (item) {&lt;br /&gt;
        var link = makeElement(&#039;a&#039;, &#039;&#039;, item.title || String(item.id).replace(/^Lesson:/, &#039;&#039;));&lt;br /&gt;
        link.href = mw.util.getUrl(item.id);&lt;br /&gt;
        list.appendChild(link);&lt;br /&gt;
      });&lt;br /&gt;
      feedback.appendChild(list);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    form.addEventListener(&#039;submit&#039;, function (event) {&lt;br /&gt;
      event.preventDefault();&lt;br /&gt;
      var query = input.value.trim();&lt;br /&gt;
      if (query.length &amp;lt; 2) {&lt;br /&gt;
        feedback.replaceChildren(makeElement(&#039;p&#039;, &#039;s3rm-semantic-error&#039;, &#039;두 글자 이상 입력해 주세요.&#039;));&lt;br /&gt;
        feedback.hidden = false;&lt;br /&gt;
        input.focus();&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      submit.disabled = true;&lt;br /&gt;
      submit.textContent = &#039;찾는 중…&#039;;&lt;br /&gt;
      feedback.replaceChildren(makeElement(&#039;div&#039;, &#039;s3rm-semantic-searching&#039;, &#039;관련 메모를 찾고 있습니다.&#039;));&lt;br /&gt;
      feedback.hidden = false;&lt;br /&gt;
      searchSemantically(query, 0, function () {&lt;br /&gt;
        feedback.replaceChildren(makeElement(&#039;div&#039;, &#039;s3rm-semantic-searching&#039;, &#039;검색을 준비하고 있습니다. 완료되면 결과를 바로 보여 드립니다.&#039;));&lt;br /&gt;
      }).then(function (payload) {&lt;br /&gt;
        renderSearchResults(payload, query);&lt;br /&gt;
      }).catch(function () {&lt;br /&gt;
        feedback.replaceChildren();&lt;br /&gt;
        var error = makeElement(&#039;div&#039;, &#039;s3rm-semantic-error&#039;);&lt;br /&gt;
        error.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;지금은 검색을 사용할 수 없습니다.&#039;));&lt;br /&gt;
        var fallback = makeElement(&#039;a&#039;, &#039;&#039;, &#039;검색 열기&#039;);&lt;br /&gt;
        fallback.href = mw.util.getUrl(&#039;Special:Search&#039;, { search: query });&lt;br /&gt;
        error.appendChild(fallback);&lt;br /&gt;
        feedback.appendChild(error);&lt;br /&gt;
      }).finally(function () {&lt;br /&gt;
        submit.disabled = false;&lt;br /&gt;
        submit.textContent = &#039;검색&#039;;&lt;br /&gt;
      });&lt;br /&gt;
    });&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function renderEmpty() {&lt;br /&gt;
    root.replaceChildren();&lt;br /&gt;
    var empty = makeElement(&#039;div&#039;, &#039;s3rm-graph-empty&#039;);&lt;br /&gt;
    empty.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;아직 연결할 메모가 없습니다&#039;));&lt;br /&gt;
    empty.appendChild(makeElement(&#039;p&#039;, &#039;&#039;, &#039;첫 메모를 남기면 이곳에서 지식의 연결을 볼 수 있습니다.&#039;));&lt;br /&gt;
    var link = makeElement(&#039;a&#039;, &#039;s3rm-graph-primary-link&#039;, &#039;첫 메모 작성하기&#039;);&lt;br /&gt;
    link.href = &#039;#quick-start&#039;;&lt;br /&gt;
    empty.appendChild(link);&lt;br /&gt;
    root.appendChild(empty);&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function renderError() {&lt;br /&gt;
    root.replaceChildren();&lt;br /&gt;
    var fallback = makeElement(&#039;div&#039;, &#039;s3rm-graph-empty&#039;);&lt;br /&gt;
    fallback.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;그래프를 불러오지 못했습니다&#039;));&lt;br /&gt;
    fallback.appendChild(makeElement(&#039;p&#039;, &#039;&#039;, &#039;최근 메모와 검색은 계속 사용할 수 있습니다. 잠시 뒤 다시 시도해 주세요.&#039;));&lt;br /&gt;
    var link = makeElement(&#039;a&#039;, &#039;s3rm-graph-primary-link&#039;, &#039;전체 메모 보기&#039;);&lt;br /&gt;
    link.href = mw.util.getUrl(&#039;Special:AllPages/Lesson:&#039;);&lt;br /&gt;
    fallback.appendChild(link);&lt;br /&gt;
    root.appendChild(fallback);&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function createGraph(graph) {&lt;br /&gt;
    var nodes = graph.nodes;&lt;br /&gt;
    var edges = graph.edges;&lt;br /&gt;
    var byId = Object.create(null);&lt;br /&gt;
    function canonicalId(id) {&lt;br /&gt;
      return String(id || &#039;&#039;).replace(/_/g, &#039; &#039;).toLocaleLowerCase(&#039;en&#039;);&lt;br /&gt;
    }&lt;br /&gt;
    nodes.forEach(function (node) { byId[canonicalId(node.id)] = node; });&lt;br /&gt;
    var selected = null;&lt;br /&gt;
    var hovered = null;&lt;br /&gt;
    var searchCluster = null;&lt;br /&gt;
    var activeRelation = &#039;all&#039;;&lt;br /&gt;
    var transform = { x: 0, y: 0, scale: 1 };&lt;br /&gt;
    var dragging = null;&lt;br /&gt;
    var panning = false;&lt;br /&gt;
    var pointerStart = null;&lt;br /&gt;
    var dragMotion = null;&lt;br /&gt;
    var animationFrame = 0;&lt;br /&gt;
    var simulationFrames = 0;&lt;br /&gt;
    var simulationRunning = false;&lt;br /&gt;
    var fitAfterSimulation = true;&lt;br /&gt;
    var reduceMotion = window.matchMedia &amp;amp;&amp;amp; window.matchMedia(&#039;(prefers-reduced-motion: reduce)&#039;).matches;&lt;br /&gt;
&lt;br /&gt;
    root.replaceChildren();&lt;br /&gt;
    var toolbar = makeElement(&#039;div&#039;, &#039;s3rm-graph-toolbar&#039;);&lt;br /&gt;
    var searchWrap = makeElement(&#039;div&#039;, &#039;s3rm-graph-search&#039;);&lt;br /&gt;
    var search = makeElement(&#039;input&#039;, &#039;s3rm-graph-search-input&#039;);&lt;br /&gt;
    search.type = &#039;search&#039;;&lt;br /&gt;
    search.placeholder = &#039;그래프에서 제목 필터&#039;;&lt;br /&gt;
    search.setAttribute(&#039;aria-label&#039;, &#039;그래프 제목 필터&#039;);&lt;br /&gt;
    search.autocomplete = &#039;off&#039;;&lt;br /&gt;
    var searchResults = makeElement(&#039;div&#039;, &#039;s3rm-graph-search-results&#039;);&lt;br /&gt;
    searchResults.hidden = true;&lt;br /&gt;
    searchWrap.append(search, searchResults);&lt;br /&gt;
&lt;br /&gt;
    var filter = makeElement(&#039;select&#039;, &#039;s3rm-graph-filter&#039;);&lt;br /&gt;
    filter.setAttribute(&#039;aria-label&#039;, &#039;관계 유형 필터&#039;);&lt;br /&gt;
    var allOption = makeElement(&#039;option&#039;, &#039;&#039;, &#039;모든 관계&#039;);&lt;br /&gt;
    allOption.value = &#039;all&#039;;&lt;br /&gt;
    filter.appendChild(allOption);&lt;br /&gt;
    RELATIONS.forEach(function (relation) {&lt;br /&gt;
      var option = makeElement(&#039;option&#039;, &#039;&#039;, relation.label);&lt;br /&gt;
      option.value = relation.key;&lt;br /&gt;
      filter.appendChild(option);&lt;br /&gt;
    });&lt;br /&gt;
    var reset = makeElement(&#039;button&#039;, &#039;s3rm-graph-reset&#039;, &#039;화면 맞춤&#039;);&lt;br /&gt;
    reset.type = &#039;button&#039;;&lt;br /&gt;
    var clearSearch = makeElement(&#039;button&#039;, &#039;s3rm-graph-clear-search&#039;, &#039;검색 결과 해제&#039;);&lt;br /&gt;
    clearSearch.type = &#039;button&#039;;&lt;br /&gt;
    clearSearch.hidden = true;&lt;br /&gt;
    toolbar.append(searchWrap, filter, reset, clearSearch);&lt;br /&gt;
&lt;br /&gt;
    var body = makeElement(&#039;div&#039;, &#039;s3rm-graph-body&#039;);&lt;br /&gt;
    var viewport = makeElement(&#039;div&#039;, &#039;s3rm-graph-viewport&#039;);&lt;br /&gt;
    var canvas = document.createElement(&#039;canvas&#039;);&lt;br /&gt;
    canvas.className = &#039;s3rm-graph-canvas&#039;;&lt;br /&gt;
    canvas.setAttribute(&#039;aria-hidden&#039;, &#039;true&#039;);&lt;br /&gt;
    viewport.appendChild(canvas);&lt;br /&gt;
    var hint = makeElement(&#039;div&#039;, &#039;s3rm-graph-hint&#039;, &#039;드래그하여 이동 · 스크롤하여 확대 · 두 번 눌러 메모 열기&#039;);&lt;br /&gt;
    viewport.appendChild(hint);&lt;br /&gt;
&lt;br /&gt;
    var inspector = makeElement(&#039;aside&#039;, &#039;s3rm-graph-inspector&#039;);&lt;br /&gt;
    inspector.setAttribute(&#039;aria-live&#039;, &#039;polite&#039;);&lt;br /&gt;
    body.append(viewport, inspector);&lt;br /&gt;
&lt;br /&gt;
    var footer = makeElement(&#039;div&#039;, &#039;s3rm-graph-footer&#039;);&lt;br /&gt;
    var counts = makeElement(&#039;span&#039;, &#039;s3rm-graph-counts&#039;, nodes.length + &#039;개 메모 · &#039; + edges.length + &#039;개 연결&#039;);&lt;br /&gt;
    var legend = makeElement(&#039;div&#039;, &#039;s3rm-graph-legend&#039;);&lt;br /&gt;
    RELATIONS.slice(0, 4).forEach(function (relation) {&lt;br /&gt;
      var item = makeElement(&#039;span&#039;, &#039;s3rm-graph-legend-item&#039;);&lt;br /&gt;
      var swatch = makeElement(&#039;i&#039;, &#039;&#039;);&lt;br /&gt;
      swatch.style.backgroundColor = relation.color;&lt;br /&gt;
      item.append(swatch, document.createTextNode(relation.label));&lt;br /&gt;
      legend.appendChild(item);&lt;br /&gt;
    });&lt;br /&gt;
    footer.append(counts, legend);&lt;br /&gt;
    root.append(toolbar, body, footer);&lt;br /&gt;
&lt;br /&gt;
    var context = canvas.getContext(&#039;2d&#039;);&lt;br /&gt;
    var size = { width: 0, height: 0, ratio: 1 };&lt;br /&gt;
&lt;br /&gt;
    function visibleEdges() {&lt;br /&gt;
      return activeRelation === &#039;all&#039;&lt;br /&gt;
        ? edges&lt;br /&gt;
        : edges.filter(function (edge) { return edge.relation.key === activeRelation; });&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function nodeRadius(node) {&lt;br /&gt;
      return 6 + Math.min(8, Math.sqrt(node.degree) * 2.2) + (node.isSearchResult ? 3 : 0);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function confidenceColor(confidence) {&lt;br /&gt;
      if (confidence === &#039;high&#039;) { return &#039;#53c18b&#039;; }&lt;br /&gt;
      if (confidence === &#039;medium&#039;) { return &#039;#8ca8ff&#039;; }&lt;br /&gt;
      return &#039;#c0c5d8&#039;;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function resize() {&lt;br /&gt;
      var rect = viewport.getBoundingClientRect();&lt;br /&gt;
      size.width = Math.max(320, rect.width);&lt;br /&gt;
      size.height = Math.max(360, rect.height);&lt;br /&gt;
      size.ratio = Math.min(window.devicePixelRatio || 1, 2);&lt;br /&gt;
      canvas.width = Math.round(size.width * size.ratio);&lt;br /&gt;
      canvas.height = Math.round(size.height * size.ratio);&lt;br /&gt;
      canvas.style.width = size.width + &#039;px&#039;;&lt;br /&gt;
      canvas.style.height = size.height + &#039;px&#039;;&lt;br /&gt;
      draw();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function fitGraph() {&lt;br /&gt;
      if (!nodes.length) { return; }&lt;br /&gt;
      var xs = nodes.map(function (node) { return node.x; });&lt;br /&gt;
      var ys = nodes.map(function (node) { return node.y; });&lt;br /&gt;
      var minX = Math.min.apply(null, xs) - 38;&lt;br /&gt;
      var maxX = Math.max.apply(null, xs) + 38;&lt;br /&gt;
      var minY = Math.min.apply(null, ys) - 38;&lt;br /&gt;
      var maxY = Math.max.apply(null, ys) + 38;&lt;br /&gt;
      transform.scale = Math.max(0.2, Math.min(1.25, Math.min(&lt;br /&gt;
        size.width / Math.max(1, maxX - minX),&lt;br /&gt;
        size.height / Math.max(1, maxY - minY)&lt;br /&gt;
      )));&lt;br /&gt;
      transform.x = size.width / 2 - ((minX + maxX) / 2) * transform.scale;&lt;br /&gt;
      transform.y = size.height / 2 - ((minY + maxY) / 2) * transform.scale;&lt;br /&gt;
      draw();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function draw() {&lt;br /&gt;
      context.setTransform(size.ratio, 0, 0, size.ratio, 0, 0);&lt;br /&gt;
      context.clearRect(0, 0, size.width, size.height);&lt;br /&gt;
      context.save();&lt;br /&gt;
      context.translate(transform.x, transform.y);&lt;br /&gt;
      context.scale(transform.scale, transform.scale);&lt;br /&gt;
&lt;br /&gt;
      if (searchCluster) {&lt;br /&gt;
        context.beginPath();&lt;br /&gt;
        context.arc(&lt;br /&gt;
          searchCluster.x,&lt;br /&gt;
          searchCluster.y,&lt;br /&gt;
          searchCluster.radius + 48,&lt;br /&gt;
          0,&lt;br /&gt;
          Math.PI * 2&lt;br /&gt;
        );&lt;br /&gt;
        context.fillStyle = &#039;rgba(104, 126, 233, 0.12)&#039;;&lt;br /&gt;
        context.fill();&lt;br /&gt;
        context.setLineDash([7 / transform.scale, 6 / transform.scale]);&lt;br /&gt;
        context.lineWidth = 1.5 / transform.scale;&lt;br /&gt;
        context.strokeStyle = &#039;rgba(140, 168, 255, 0.75)&#039;;&lt;br /&gt;
        context.stroke();&lt;br /&gt;
        context.setLineDash([]);&lt;br /&gt;
        context.font = &#039;700 &#039; + (12 / transform.scale) + &#039;px sans-serif&#039;;&lt;br /&gt;
        context.textAlign = &#039;center&#039;;&lt;br /&gt;
        context.textBaseline = &#039;bottom&#039;;&lt;br /&gt;
        context.fillStyle = &#039;#b9c7ff&#039;;&lt;br /&gt;
        context.fillText(&lt;br /&gt;
          &#039;검색 결과 · &#039; + searchCluster.nodes.length + &#039;개 메모&#039;,&lt;br /&gt;
          searchCluster.x,&lt;br /&gt;
          searchCluster.y - searchCluster.radius - 58 / transform.scale&lt;br /&gt;
        );&lt;br /&gt;
      }&lt;br /&gt;
&lt;br /&gt;
      visibleEdges().forEach(function (edge) {&lt;br /&gt;
        var emphasized = selected &amp;amp;&amp;amp; (edge.source === selected || edge.target === selected);&lt;br /&gt;
        var searchEdge = searchCluster &amp;amp;&amp;amp;&lt;br /&gt;
          (edge.source.isSearchResult || edge.target.isSearchResult);&lt;br /&gt;
        context.globalAlpha = searchCluster &amp;amp;&amp;amp; !searchEdge ? 0.1 : 1;&lt;br /&gt;
        context.beginPath();&lt;br /&gt;
        context.moveTo(edge.source.x, edge.source.y);&lt;br /&gt;
        context.lineTo(edge.target.x, edge.target.y);&lt;br /&gt;
        context.strokeStyle = emphasized ? edge.relation.color : edge.relation.color + &#039;55&#039;;&lt;br /&gt;
        context.lineWidth = (emphasized || searchEdge ? 2.2 : 1) / transform.scale;&lt;br /&gt;
        context.stroke();&lt;br /&gt;
      });&lt;br /&gt;
      context.globalAlpha = 1;&lt;br /&gt;
&lt;br /&gt;
      nodes.forEach(function (node) {&lt;br /&gt;
        var isSelected = node === selected;&lt;br /&gt;
        var isHovered = node === hovered;&lt;br /&gt;
        var isSearchResult = Boolean(node.isSearchResult);&lt;br /&gt;
        var radius = nodeRadius(node);&lt;br /&gt;
        context.globalAlpha = searchCluster &amp;amp;&amp;amp; !isSearchResult ? 0.12 : 1;&lt;br /&gt;
        context.beginPath();&lt;br /&gt;
        context.arc(node.x, node.y, radius + (isSelected ? 4 : 0), 0, Math.PI * 2);&lt;br /&gt;
        context.fillStyle = isSelected || isSearchResult ? &#039;#ffffff&#039; : confidenceColor(node.confidence);&lt;br /&gt;
        context.fill();&lt;br /&gt;
        context.lineWidth = (isSelected || isSearchResult ? 3 : 1.5) / transform.scale;&lt;br /&gt;
        context.strokeStyle = isSelected || isSearchResult ? &#039;#8ca8ff&#039; : &#039;#11182e&#039;;&lt;br /&gt;
        context.stroke();&lt;br /&gt;
&lt;br /&gt;
        if (isSelected || isHovered || isSearchResult ||&lt;br /&gt;
          (transform.scale &amp;gt; 0.68 &amp;amp;&amp;amp; node.degree &amp;gt; 0)) {&lt;br /&gt;
          context.font = (isSelected || isSearchResult ? &#039;600 &#039; : &#039;&#039;) + (12 / transform.scale) + &#039;px sans-serif&#039;;&lt;br /&gt;
          context.textAlign = &#039;center&#039;;&lt;br /&gt;
          context.textBaseline = &#039;top&#039;;&lt;br /&gt;
          context.fillStyle = &#039;#f5f7ff&#039;;&lt;br /&gt;
          context.shadowColor = &#039;#080b16&#039;;&lt;br /&gt;
          context.shadowBlur = 5 / transform.scale;&lt;br /&gt;
          var label = node.title.length &amp;gt; 26 ? node.title.slice(0, 25) + &#039;…&#039; : node.title;&lt;br /&gt;
          context.fillText(label, node.x, node.y + radius + 7 / transform.scale);&lt;br /&gt;
          context.shadowBlur = 0;&lt;br /&gt;
        }&lt;br /&gt;
      });&lt;br /&gt;
      context.globalAlpha = 1;&lt;br /&gt;
      context.restore();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function startSimulation(frames, shouldFit) {&lt;br /&gt;
      simulationFrames = Math.max(simulationFrames, reduceMotion ? 1 : frames);&lt;br /&gt;
      fitAfterSimulation = fitAfterSimulation || Boolean(shouldFit);&lt;br /&gt;
      if (!simulationRunning) {&lt;br /&gt;
        simulationRunning = true;&lt;br /&gt;
        animationFrame = window.requestAnimationFrame(simulate);&lt;br /&gt;
      }&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function simulate() {&lt;br /&gt;
      if (simulationFrames &amp;lt;= 0) {&lt;br /&gt;
        simulationRunning = false;&lt;br /&gt;
        if (fitAfterSimulation &amp;amp;&amp;amp; !searchCluster) {&lt;br /&gt;
          fitGraph();&lt;br /&gt;
        } else {&lt;br /&gt;
          draw();&lt;br /&gt;
        }&lt;br /&gt;
        fitAfterSimulation = false;&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
&lt;br /&gt;
      for (var i = 0; i &amp;lt; nodes.length; i += 1) {&lt;br /&gt;
        for (var j = i + 1; j &amp;lt; nodes.length; j += 1) {&lt;br /&gt;
          var first = nodes[i];&lt;br /&gt;
          var second = nodes[j];&lt;br /&gt;
          var isolatedPair = first.degree === 0 &amp;amp;&amp;amp; second.degree === 0;&lt;br /&gt;
          if (isolatedPair &amp;amp;&amp;amp; !first.motionActive &amp;amp;&amp;amp; !second.motionActive &amp;amp;&amp;amp;&lt;br /&gt;
            first !== dragging &amp;amp;&amp;amp; second !== dragging) {&lt;br /&gt;
            continue;&lt;br /&gt;
          }&lt;br /&gt;
          var dx = second.x - first.x || 0.01;&lt;br /&gt;
          var dy = second.y - first.y || 0.01;&lt;br /&gt;
          var distanceSquared = dx * dx + dy * dy;&lt;br /&gt;
          var distance = Math.sqrt(distanceSquared) || 0.01;&lt;br /&gt;
          var minimumDistance = nodeRadius(first) + nodeRadius(second) + 7;&lt;br /&gt;
          var force = distance &amp;lt; minimumDistance&lt;br /&gt;
            ? (minimumDistance - distance) * 0.035 / distance&lt;br /&gt;
            : 1.8 / Math.max(80, distanceSquared);&lt;br /&gt;
          if (first !== dragging) {&lt;br /&gt;
            first.vx -= dx * force;&lt;br /&gt;
            first.vy -= dy * force;&lt;br /&gt;
          }&lt;br /&gt;
          if (second !== dragging) {&lt;br /&gt;
            second.vx += dx * force;&lt;br /&gt;
            second.vy += dy * force;&lt;br /&gt;
          }&lt;br /&gt;
        }&lt;br /&gt;
      }&lt;br /&gt;
      edges.forEach(function (edge) {&lt;br /&gt;
        var dx = edge.target.x - edge.source.x;&lt;br /&gt;
        var dy = edge.target.y - edge.source.y;&lt;br /&gt;
        var distance = Math.sqrt(dx * dx + dy * dy) || 1;&lt;br /&gt;
        var force = (distance - 64) * 0.0014;&lt;br /&gt;
        if (edge.source !== dragging) {&lt;br /&gt;
          edge.source.vx += dx * force;&lt;br /&gt;
          edge.source.vy += dy * force;&lt;br /&gt;
        }&lt;br /&gt;
        if (edge.target !== dragging) {&lt;br /&gt;
          edge.target.vx -= dx * force;&lt;br /&gt;
          edge.target.vy -= dy * force;&lt;br /&gt;
        }&lt;br /&gt;
      });&lt;br /&gt;
      nodes.forEach(function (node) {&lt;br /&gt;
        if (node === dragging) {&lt;br /&gt;
          return;&lt;br /&gt;
        }&lt;br /&gt;
        if (node.isSearchResult &amp;amp;&amp;amp; !node.motionActive) {&lt;br /&gt;
          node.vx = 0;&lt;br /&gt;
          node.vy = 0;&lt;br /&gt;
          return;&lt;br /&gt;
        }&lt;br /&gt;
        if (node.degree === 0 &amp;amp;&amp;amp; !node.motionActive) {&lt;br /&gt;
          node.vx = 0;&lt;br /&gt;
          node.vy = 0;&lt;br /&gt;
          return;&lt;br /&gt;
        }&lt;br /&gt;
        if (node.degree === 0) {&lt;br /&gt;
          node.vx += (node.homeX - node.x) * 0.0022;&lt;br /&gt;
          node.vy += (node.homeY - node.y) * 0.0022;&lt;br /&gt;
        } else {&lt;br /&gt;
          node.vx += -node.x * 0.0018;&lt;br /&gt;
          node.vy += -node.y * 0.0018;&lt;br /&gt;
        }&lt;br /&gt;
        node.vx *= 0.9;&lt;br /&gt;
        node.vy *= 0.9;&lt;br /&gt;
        node.x += node.vx;&lt;br /&gt;
        node.y += node.vy;&lt;br /&gt;
        if (node.motionActive &amp;amp;&amp;amp; Math.abs(node.vx) + Math.abs(node.vy) &amp;lt; 0.025) {&lt;br /&gt;
          delete node.motionActive;&lt;br /&gt;
        }&lt;br /&gt;
      });&lt;br /&gt;
      simulationFrames -= 1;&lt;br /&gt;
      draw();&lt;br /&gt;
      animationFrame = window.requestAnimationFrame(simulate);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function relationDefinition(key) {&lt;br /&gt;
      return RELATIONS.find(function (relation) { return relation.key === key; });&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function updateCounts() {&lt;br /&gt;
      if (searchCluster) {&lt;br /&gt;
        var relatedEdges = edges.filter(function (edge) {&lt;br /&gt;
          return edge.source.isSearchResult || edge.target.isSearchResult;&lt;br /&gt;
        }).length;&lt;br /&gt;
        counts.textContent = &#039;검색 결과 &#039; + searchCluster.nodes.length + &#039;개 · &#039; +&lt;br /&gt;
          relatedEdges + &#039;개 연결&#039;;&lt;br /&gt;
      } else {&lt;br /&gt;
        counts.textContent = nodes.length + &#039;개 메모 · &#039; + visibleEdges().length + &#039;개 연결&#039;;&lt;br /&gt;
      }&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function resetSearchCluster() {&lt;br /&gt;
      var addedKeys = Object.create(null);&lt;br /&gt;
      edges.forEach(function (edge) {&lt;br /&gt;
        if (edge.searchAdded) {&lt;br /&gt;
          edge.source.degree = Math.max(0, edge.source.degree - 1);&lt;br /&gt;
          edge.target.degree = Math.max(0, edge.target.degree - 1);&lt;br /&gt;
        }&lt;br /&gt;
      });&lt;br /&gt;
      nodes.forEach(function (node) {&lt;br /&gt;
        if (node.searchAdded) {&lt;br /&gt;
          addedKeys[canonicalId(node.id)] = true;&lt;br /&gt;
          delete byId[canonicalId(node.id)];&lt;br /&gt;
        } else if (node.preSearchPosition) {&lt;br /&gt;
          node.x = node.preSearchPosition.x;&lt;br /&gt;
          node.y = node.preSearchPosition.y;&lt;br /&gt;
          node.homeX = node.preSearchHome.x;&lt;br /&gt;
          node.homeY = node.preSearchHome.y;&lt;br /&gt;
        }&lt;br /&gt;
        delete node.isSearchResult;&lt;br /&gt;
        delete node.preSearchPosition;&lt;br /&gt;
        delete node.preSearchHome;&lt;br /&gt;
        delete node.motionActive;&lt;br /&gt;
      });&lt;br /&gt;
      nodes = nodes.filter(function (node) { return !node.searchAdded; });&lt;br /&gt;
      edges = edges.filter(function (edge) {&lt;br /&gt;
        return !edge.searchAdded &amp;amp;&amp;amp;&lt;br /&gt;
          !addedKeys[canonicalId(edge.source.id)] &amp;amp;&amp;amp;&lt;br /&gt;
          !addedKeys[canonicalId(edge.target.id)];&lt;br /&gt;
      });&lt;br /&gt;
      searchCluster = null;&lt;br /&gt;
      clearSearch.hidden = true;&lt;br /&gt;
      selected = null;&lt;br /&gt;
      showInspector(null);&lt;br /&gt;
      updateCounts();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function focusSearchResults(items, query) {&lt;br /&gt;
      resetSearchCluster();&lt;br /&gt;
      if (!items.length) {&lt;br /&gt;
        fitGraph();&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
&lt;br /&gt;
      var resultNodes = [];&lt;br /&gt;
      items.forEach(function (item, index) {&lt;br /&gt;
        var key = canonicalId(item.id);&lt;br /&gt;
        var node = byId[key];&lt;br /&gt;
        if (!node) {&lt;br /&gt;
          node = {&lt;br /&gt;
            id: item.id,&lt;br /&gt;
            title: item.title || String(item.id).replace(/^Lesson:/, &#039;&#039;),&lt;br /&gt;
            confidence: item.confidence || &#039;low&#039;,&lt;br /&gt;
            updatedAt: &#039;&#039;,&lt;br /&gt;
            x: 0,&lt;br /&gt;
            y: 0,&lt;br /&gt;
            vx: 0,&lt;br /&gt;
            vy: 0,&lt;br /&gt;
            degree: 0,&lt;br /&gt;
            homeX: 0,&lt;br /&gt;
            homeY: 0,&lt;br /&gt;
            searchAdded: true&lt;br /&gt;
          };&lt;br /&gt;
          nodes.push(node);&lt;br /&gt;
          byId[key] = node;&lt;br /&gt;
        } else {&lt;br /&gt;
          node.preSearchPosition = { x: node.x, y: node.y };&lt;br /&gt;
          node.preSearchHome = { x: node.homeX, y: node.homeY };&lt;br /&gt;
          node.title = item.title || node.title;&lt;br /&gt;
          node.confidence = item.confidence || node.confidence;&lt;br /&gt;
        }&lt;br /&gt;
        node.isSearchResult = true;&lt;br /&gt;
        node.searchRank = index + 1;&lt;br /&gt;
        resultNodes.push(node);&lt;br /&gt;
      });&lt;br /&gt;
&lt;br /&gt;
      items.forEach(function (item) {&lt;br /&gt;
        (Array.isArray(item.relations) ? item.relations : []).forEach(function (relationItem) {&lt;br /&gt;
          var source = byId[canonicalId(relationItem.from || item.id)];&lt;br /&gt;
          var target = byId[canonicalId(relationItem.to)];&lt;br /&gt;
          var relation = relationDefinition(relationItem.relation);&lt;br /&gt;
          if (!source || !target || !relation || source === target) {&lt;br /&gt;
            return;&lt;br /&gt;
          }&lt;br /&gt;
          var duplicate = edges.some(function (edge) {&lt;br /&gt;
            return canonicalId(edge.source.id) === canonicalId(source.id) &amp;amp;&amp;amp;&lt;br /&gt;
              canonicalId(edge.target.id) === canonicalId(target.id) &amp;amp;&amp;amp;&lt;br /&gt;
              edge.relation.key === relation.key;&lt;br /&gt;
          });&lt;br /&gt;
          if (!duplicate) {&lt;br /&gt;
            edges.push({&lt;br /&gt;
              source: source,&lt;br /&gt;
              target: target,&lt;br /&gt;
              relation: relation,&lt;br /&gt;
              searchAdded: true&lt;br /&gt;
            });&lt;br /&gt;
            source.degree += 1;&lt;br /&gt;
            target.degree += 1;&lt;br /&gt;
          }&lt;br /&gt;
        });&lt;br /&gt;
      });&lt;br /&gt;
&lt;br /&gt;
      var clusterRadius = resultNodes.length === 1 ? 0 :&lt;br /&gt;
        Math.max(82, Math.min(132, resultNodes.length * 22));&lt;br /&gt;
      resultNodes.forEach(function (node, index) {&lt;br /&gt;
        var angle = -Math.PI / 2 + (Math.PI * 2 * index / resultNodes.length);&lt;br /&gt;
        node.x = Math.cos(angle) * clusterRadius;&lt;br /&gt;
        node.y = Math.sin(angle) * clusterRadius;&lt;br /&gt;
        node.homeX = node.x;&lt;br /&gt;
        node.homeY = node.y;&lt;br /&gt;
        node.vx = 0;&lt;br /&gt;
        node.vy = 0;&lt;br /&gt;
      });&lt;br /&gt;
      searchCluster = {&lt;br /&gt;
        query: query,&lt;br /&gt;
        nodes: resultNodes,&lt;br /&gt;
        x: 0,&lt;br /&gt;
        y: 0,&lt;br /&gt;
        radius: clusterRadius&lt;br /&gt;
      };&lt;br /&gt;
      activeRelation = &#039;all&#039;;&lt;br /&gt;
      filter.value = &#039;all&#039;;&lt;br /&gt;
      clearSearch.hidden = false;&lt;br /&gt;
      transform.scale = Math.max(0.72, Math.min(&lt;br /&gt;
        1.35,&lt;br /&gt;
        Math.min(size.width, size.height) / Math.max(360, clusterRadius * 2 + 150)&lt;br /&gt;
      ));&lt;br /&gt;
      transform.x = size.width / 2;&lt;br /&gt;
      transform.y = size.height / 2;&lt;br /&gt;
      selectNode(resultNodes[0]);&lt;br /&gt;
      updateCounts();&lt;br /&gt;
      draw();&lt;br /&gt;
      viewport.scrollIntoView({ behavior: &#039;smooth&#039;, block: &#039;center&#039; });&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function connectionSummary(node) {&lt;br /&gt;
      var connected = edges.filter(function (edge) {&lt;br /&gt;
        return edge.source === node || edge.target === node;&lt;br /&gt;
      });&lt;br /&gt;
      var summary = Object.create(null);&lt;br /&gt;
      connected.forEach(function (edge) {&lt;br /&gt;
        summary[edge.relation.label] = (summary[edge.relation.label] || 0) + 1;&lt;br /&gt;
      });&lt;br /&gt;
      return { edges: connected, counts: summary };&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function showInspector(node) {&lt;br /&gt;
      inspector.replaceChildren();&lt;br /&gt;
      if (!node) {&lt;br /&gt;
        var welcome = makeElement(&#039;div&#039;, &#039;s3rm-inspector-welcome&#039;);&lt;br /&gt;
        welcome.appendChild(makeElement(&#039;span&#039;, &#039;s3rm-inspector-symbol&#039;, &#039;⌁&#039;));&lt;br /&gt;
        welcome.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;메모를 선택하세요&#039;));&lt;br /&gt;
        welcome.appendChild(makeElement(&#039;p&#039;, &#039;&#039;, &#039;점 하나를 누르면 메모 정보와 연결된 기록을 보여 드립니다.&#039;));&lt;br /&gt;
        inspector.appendChild(welcome);&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      var summary = connectionSummary(node);&lt;br /&gt;
      inspector.appendChild(makeElement(&lt;br /&gt;
        &#039;div&#039;,&lt;br /&gt;
        &#039;s3rm-inspector-kicker&#039;,&lt;br /&gt;
        node.isSearchResult ? &#039;검색 결과 &#039; + node.searchRank : &#039;선택한 메모&#039;&lt;br /&gt;
      ));&lt;br /&gt;
      inspector.appendChild(makeElement(&#039;h3&#039;, &#039;&#039;, node.title));&lt;br /&gt;
      var meta = makeElement(&#039;div&#039;, &#039;s3rm-inspector-meta&#039;);&lt;br /&gt;
      meta.appendChild(makeElement(&#039;span&#039;, &#039;s3rm-confidence s3rm-confidence-&#039; + node.confidence, confidenceLabel(node.confidence)));&lt;br /&gt;
      meta.appendChild(makeElement(&#039;span&#039;, &#039;&#039;, summary.edges.length + &#039;개 연결&#039;));&lt;br /&gt;
      inspector.appendChild(meta);&lt;br /&gt;
      if (Object.keys(summary.counts).length) {&lt;br /&gt;
        var relationList = makeElement(&#039;ul&#039;, &#039;s3rm-inspector-relations&#039;);&lt;br /&gt;
        Object.keys(summary.counts).forEach(function (label) {&lt;br /&gt;
          var item = makeElement(&#039;li&#039;, &#039;&#039;);&lt;br /&gt;
          item.append(makeElement(&#039;span&#039;, &#039;&#039;, label), makeElement(&#039;strong&#039;, &#039;&#039;, String(summary.counts[label])));&lt;br /&gt;
          relationList.appendChild(item);&lt;br /&gt;
        });&lt;br /&gt;
        inspector.appendChild(relationList);&lt;br /&gt;
      } else {&lt;br /&gt;
        inspector.appendChild(makeElement(&#039;p&#039;, &#039;s3rm-inspector-muted&#039;, &#039;아직 다른 메모와 연결되지 않았습니다.&#039;));&lt;br /&gt;
      }&lt;br /&gt;
      var open = makeElement(&#039;a&#039;, &#039;s3rm-graph-primary-link&#039;, &#039;메모 열기&#039;);&lt;br /&gt;
      open.href = mw.util.getUrl(node.id);&lt;br /&gt;
      inspector.appendChild(open);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function canvasPoint(event) {&lt;br /&gt;
      var rect = canvas.getBoundingClientRect();&lt;br /&gt;
      return { x: event.clientX - rect.left, y: event.clientY - rect.top };&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function worldPoint(event) {&lt;br /&gt;
      var point = canvasPoint(event);&lt;br /&gt;
      return {&lt;br /&gt;
        x: (point.x - transform.x) / transform.scale,&lt;br /&gt;
        y: (point.y - transform.y) / transform.scale&lt;br /&gt;
      };&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function hitNode(event) {&lt;br /&gt;
      var point = worldPoint(event);&lt;br /&gt;
      for (var i = nodes.length - 1; i &amp;gt;= 0; i -= 1) {&lt;br /&gt;
        var node = nodes[i];&lt;br /&gt;
        var dx = point.x - node.x;&lt;br /&gt;
        var dy = point.y - node.y;&lt;br /&gt;
        if (dx * dx + dy * dy &amp;lt;= Math.pow(nodeRadius(node) + 6 / transform.scale, 2)) {&lt;br /&gt;
          return node;&lt;br /&gt;
        }&lt;br /&gt;
      }&lt;br /&gt;
      return null;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function selectNode(node) {&lt;br /&gt;
      selected = node;&lt;br /&gt;
      showInspector(node);&lt;br /&gt;
      draw();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    canvas.addEventListener(&#039;pointerdown&#039;, function (event) {&lt;br /&gt;
      dragging = hitNode(event);&lt;br /&gt;
      panning = !dragging;&lt;br /&gt;
      pointerStart = canvasPoint(event);&lt;br /&gt;
      if (dragging) {&lt;br /&gt;
        var world = worldPoint(event);&lt;br /&gt;
        dragMotion = {&lt;br /&gt;
          x: world.x,&lt;br /&gt;
          y: world.y,&lt;br /&gt;
          time: event.timeStamp,&lt;br /&gt;
          vx: 0,&lt;br /&gt;
          vy: 0&lt;br /&gt;
        };&lt;br /&gt;
        dragging.motionActive = true;&lt;br /&gt;
        startSimulation(120, false);&lt;br /&gt;
      }&lt;br /&gt;
      canvas.setPointerCapture(event.pointerId);&lt;br /&gt;
      canvas.classList.add(&#039;is-dragging&#039;);&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;pointermove&#039;, function (event) {&lt;br /&gt;
      if (dragging) {&lt;br /&gt;
        var world = worldPoint(event);&lt;br /&gt;
        if (dragMotion) {&lt;br /&gt;
          var elapsed = Math.max(8, event.timeStamp - dragMotion.time);&lt;br /&gt;
          var frameRatio = 16.67 / elapsed;&lt;br /&gt;
          var instantVX = (world.x - dragMotion.x) * frameRatio;&lt;br /&gt;
          var instantVY = (world.y - dragMotion.y) * frameRatio;&lt;br /&gt;
          dragMotion.vx = dragMotion.vx * 0.45 + instantVX * 0.55;&lt;br /&gt;
          dragMotion.vy = dragMotion.vy * 0.45 + instantVY * 0.55;&lt;br /&gt;
          var speed = Math.sqrt(dragMotion.vx * dragMotion.vx + dragMotion.vy * dragMotion.vy);&lt;br /&gt;
          if (speed &amp;gt; 14) {&lt;br /&gt;
            dragMotion.vx = dragMotion.vx / speed * 14;&lt;br /&gt;
            dragMotion.vy = dragMotion.vy / speed * 14;&lt;br /&gt;
          }&lt;br /&gt;
          dragMotion.x = world.x;&lt;br /&gt;
          dragMotion.y = world.y;&lt;br /&gt;
          dragMotion.time = event.timeStamp;&lt;br /&gt;
        }&lt;br /&gt;
        dragging.x = world.x;&lt;br /&gt;
        dragging.y = world.y;&lt;br /&gt;
        startSimulation(120, false);&lt;br /&gt;
      } else if (panning &amp;amp;&amp;amp; pointerStart) {&lt;br /&gt;
        var point = canvasPoint(event);&lt;br /&gt;
        transform.x += point.x - pointerStart.x;&lt;br /&gt;
        transform.y += point.y - pointerStart.y;&lt;br /&gt;
        pointerStart = point;&lt;br /&gt;
      } else {&lt;br /&gt;
        hovered = hitNode(event);&lt;br /&gt;
        canvas.style.cursor = hovered ? &#039;pointer&#039; : &#039;grab&#039;;&lt;br /&gt;
      }&lt;br /&gt;
      draw();&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;pointerup&#039;, function (event) {&lt;br /&gt;
      var hit = hitNode(event);&lt;br /&gt;
      var releasedNode = dragging;&lt;br /&gt;
      if (releasedNode &amp;amp;&amp;amp; dragMotion) {&lt;br /&gt;
        releasedNode.vx = reduceMotion ? 0 : dragMotion.vx;&lt;br /&gt;
        releasedNode.vy = reduceMotion ? 0 : dragMotion.vy;&lt;br /&gt;
      }&lt;br /&gt;
      dragging = null;&lt;br /&gt;
      panning = false;&lt;br /&gt;
      pointerStart = null;&lt;br /&gt;
      dragMotion = null;&lt;br /&gt;
      canvas.classList.remove(&#039;is-dragging&#039;);&lt;br /&gt;
      if (releasedNode) {&lt;br /&gt;
        if (hit === releasedNode) {&lt;br /&gt;
          selectNode(releasedNode);&lt;br /&gt;
        }&lt;br /&gt;
        startSimulation(140, false);&lt;br /&gt;
      }&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;pointercancel&#039;, function () {&lt;br /&gt;
      if (dragging) {&lt;br /&gt;
        dragging.vx = 0;&lt;br /&gt;
        dragging.vy = 0;&lt;br /&gt;
        delete dragging.motionActive;&lt;br /&gt;
      }&lt;br /&gt;
      dragging = null;&lt;br /&gt;
      panning = false;&lt;br /&gt;
      pointerStart = null;&lt;br /&gt;
      dragMotion = null;&lt;br /&gt;
      canvas.classList.remove(&#039;is-dragging&#039;);&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;dblclick&#039;, function (event) {&lt;br /&gt;
      var node = hitNode(event);&lt;br /&gt;
      if (node) {&lt;br /&gt;
        window.location.href = mw.util.getUrl(node.id);&lt;br /&gt;
      }&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;wheel&#039;, function (event) {&lt;br /&gt;
      event.preventDefault();&lt;br /&gt;
      var point = canvasPoint(event);&lt;br /&gt;
      var oldScale = transform.scale;&lt;br /&gt;
      var nextScale = Math.max(0.25, Math.min(3.5, oldScale * Math.exp(-event.deltaY * 0.0012)));&lt;br /&gt;
      transform.x = point.x - ((point.x - transform.x) / oldScale) * nextScale;&lt;br /&gt;
      transform.y = point.y - ((point.y - transform.y) / oldScale) * nextScale;&lt;br /&gt;
      transform.scale = nextScale;&lt;br /&gt;
      draw();&lt;br /&gt;
    }, { passive: false });&lt;br /&gt;
&lt;br /&gt;
    search.addEventListener(&#039;input&#039;, function () {&lt;br /&gt;
      var query = search.value.trim().toLocaleLowerCase(&#039;ko&#039;);&lt;br /&gt;
      searchResults.replaceChildren();&lt;br /&gt;
      if (!query) {&lt;br /&gt;
        searchResults.hidden = true;&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      var matches = nodes.filter(function (node) {&lt;br /&gt;
        return node.title.toLocaleLowerCase(&#039;ko&#039;).includes(query) || node.id.toLowerCase().includes(query);&lt;br /&gt;
      }).slice(0, 6);&lt;br /&gt;
      matches.forEach(function (node) {&lt;br /&gt;
        var button = makeElement(&#039;button&#039;, &#039;&#039;, node.title);&lt;br /&gt;
        button.type = &#039;button&#039;;&lt;br /&gt;
        button.addEventListener(&#039;click&#039;, function () {&lt;br /&gt;
          selectNode(node);&lt;br /&gt;
          transform.x = size.width / 2 - node.x * transform.scale;&lt;br /&gt;
          transform.y = size.height / 2 - node.y * transform.scale;&lt;br /&gt;
          search.value = node.title;&lt;br /&gt;
          searchResults.hidden = true;&lt;br /&gt;
          draw();&lt;br /&gt;
        });&lt;br /&gt;
        searchResults.appendChild(button);&lt;br /&gt;
      });&lt;br /&gt;
      if (!matches.length) {&lt;br /&gt;
        searchResults.appendChild(makeElement(&#039;span&#039;, &#039;&#039;, &#039;일치하는 메모가 없습니다.&#039;));&lt;br /&gt;
      }&lt;br /&gt;
      searchResults.hidden = false;&lt;br /&gt;
    });&lt;br /&gt;
    search.addEventListener(&#039;keydown&#039;, function (event) {&lt;br /&gt;
      if (event.key === &#039;Escape&#039;) {&lt;br /&gt;
        searchResults.hidden = true;&lt;br /&gt;
      } else if (event.key === &#039;Enter&#039;) {&lt;br /&gt;
        var first = searchResults.querySelector(&#039;button&#039;);&lt;br /&gt;
        if (first) { first.click(); }&lt;br /&gt;
      }&lt;br /&gt;
    });&lt;br /&gt;
    filter.addEventListener(&#039;change&#039;, function () {&lt;br /&gt;
      activeRelation = filter.value;&lt;br /&gt;
      updateCounts();&lt;br /&gt;
      draw();&lt;br /&gt;
    });&lt;br /&gt;
    reset.addEventListener(&#039;click&#039;, fitGraph);&lt;br /&gt;
    clearSearch.addEventListener(&#039;click&#039;, function () {&lt;br /&gt;
      resetSearchCluster();&lt;br /&gt;
      fitGraph();&lt;br /&gt;
    });&lt;br /&gt;
&lt;br /&gt;
    showInspector(null);&lt;br /&gt;
    resize();&lt;br /&gt;
    var resizeObserver = window.ResizeObserver ? new ResizeObserver(resize) : null;&lt;br /&gt;
    if (resizeObserver) {&lt;br /&gt;
      resizeObserver.observe(viewport);&lt;br /&gt;
    } else {&lt;br /&gt;
      window.addEventListener(&#039;resize&#039;, resize);&lt;br /&gt;
    }&lt;br /&gt;
    graphSearchController = focusSearchResults;&lt;br /&gt;
    if (pendingGraphSearch) {&lt;br /&gt;
      focusSearchResults(pendingGraphSearch.items, pendingGraphSearch.query);&lt;br /&gt;
      pendingGraphSearch = null;&lt;br /&gt;
    }&lt;br /&gt;
    startSimulation(160, true);&lt;br /&gt;
    window.addEventListener(&#039;pagehide&#039;, function () {&lt;br /&gt;
      window.cancelAnimationFrame(animationFrame);&lt;br /&gt;
      if (resizeObserver) { resizeObserver.disconnect(); }&lt;br /&gt;
    }, { once: true });&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  var askQuery = &#039;[[Category:Lessons]]&#039; +&lt;br /&gt;
    &#039;|?Lesson title|?Confidence|?Updated at&#039; +&lt;br /&gt;
    RELATIONS.map(function (relation) { return &#039;|?&#039; + relation.property; }).join(&#039;&#039;) +&lt;br /&gt;
    &#039;|sort=Updated at|order=descending|limit=&#039; + NODE_LIMIT;&lt;br /&gt;
&lt;br /&gt;
  createSemanticSearch();&lt;br /&gt;
&lt;br /&gt;
  mw.loader.using([&#039;mediawiki.api&#039;, &#039;mediawiki.util&#039;]).then(function () {&lt;br /&gt;
    return new mw.Api().get({ action: &#039;ask&#039;, query: askQuery, formatversion: 2 });&lt;br /&gt;
  }).then(function (data) {&lt;br /&gt;
    var graph = parseGraph(data);&lt;br /&gt;
    if (!graph.nodes.length) {&lt;br /&gt;
      renderEmpty();&lt;br /&gt;
      return;&lt;br /&gt;
    }&lt;br /&gt;
    createGraph(graph);&lt;br /&gt;
  }).catch(renderError);&lt;br /&gt;
}());&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%EB%AF%B8%EB%94%94%EC%96%B4%EC%9C%84%ED%82%A4:Common.js&amp;diff=2745</id>
		<title>미디어위키:Common.js</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%EB%AF%B8%EB%94%94%EC%96%B4%EC%9C%84%ED%82%A4:Common.js&amp;diff=2745"/>
		<updated>2026-07-19T02:44:24Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 원본 스키마 동기화&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;(function () {&lt;br /&gt;
  &#039;use strict&#039;;&lt;br /&gt;
&lt;br /&gt;
  var root = document.getElementById(&#039;s3rm-knowledge-graph&#039;);&lt;br /&gt;
  var semanticRoot = document.getElementById(&#039;s3rm-semantic-search&#039;);&lt;br /&gt;
  if (!root || !window.mw) {&lt;br /&gt;
    return;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  var RELATIONS = [&lt;br /&gt;
    { property: &#039;Supports&#039;, key: &#039;supports&#039;, label: &#039;뒷받침함&#039;, color: &#039;#5f7ae6&#039; },&lt;br /&gt;
    { property: &#039;Contradicts&#039;, key: &#039;contradicts&#039;, label: &#039;상충함&#039;, color: &#039;#d0605b&#039; },&lt;br /&gt;
    { property: &#039;Analogous to&#039;, key: &#039;analogous_to&#039;, label: &#039;유사함&#039;, color: &#039;#a17bd7&#039; },&lt;br /&gt;
    { property: &#039;Failed because&#039;, key: &#039;failed_because&#039;, label: &#039;실패 원인&#039;, color: &#039;#dc8b45&#039; },&lt;br /&gt;
    { property: &#039;Works when&#039;, key: &#039;works_when&#039;, label: &#039;작동 조건&#039;, color: &#039;#43a27b&#039; },&lt;br /&gt;
    { property: &#039;Suggests&#039;, key: &#039;suggests&#039;, label: &#039;후속 제안&#039;, color: &#039;#3f9db7&#039; },&lt;br /&gt;
    { property: &#039;Supersedes&#039;, key: &#039;supersedes&#039;, label: &#039;대체함&#039;, color: &#039;#7d849e&#039; }&lt;br /&gt;
  ];&lt;br /&gt;
  var NODE_LIMIT = 160;&lt;br /&gt;
  var EDGE_LIMIT = 480;&lt;br /&gt;
  var SEMANTIC_RETRY_LIMIT = 15;&lt;br /&gt;
  var mcpReady = null;&lt;br /&gt;
  var graphSearchController = null;&lt;br /&gt;
  var pendingGraphSearch = null;&lt;br /&gt;
&lt;br /&gt;
  function mcpRequest(payload, expectBody) {&lt;br /&gt;
    return window.fetch(&#039;/mcp&#039;, {&lt;br /&gt;
      method: &#039;POST&#039;,&lt;br /&gt;
      credentials: &#039;same-origin&#039;,&lt;br /&gt;
      headers: {&lt;br /&gt;
        &#039;Content-Type&#039;: &#039;application/json&#039;,&lt;br /&gt;
        &#039;Accept&#039;: &#039;application/json, text/event-stream&#039;&lt;br /&gt;
      },&lt;br /&gt;
      body: JSON.stringify(payload)&lt;br /&gt;
    }).then(function (response) {&lt;br /&gt;
      if (!response.ok) {&lt;br /&gt;
        throw new Error(&#039;MCP HTTP &#039; + response.status);&lt;br /&gt;
      }&lt;br /&gt;
      if (!expectBody) {&lt;br /&gt;
        return null;&lt;br /&gt;
      }&lt;br /&gt;
      return response.json();&lt;br /&gt;
    });&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function initializeMcp() {&lt;br /&gt;
    if (!mcpReady) {&lt;br /&gt;
      mcpReady = mcpRequest({&lt;br /&gt;
        jsonrpc: &#039;2.0&#039;,&lt;br /&gt;
        id: 1,&lt;br /&gt;
        method: &#039;initialize&#039;,&lt;br /&gt;
        params: {&lt;br /&gt;
          protocolVersion: &#039;2025-06-18&#039;,&lt;br /&gt;
          capabilities: {},&lt;br /&gt;
          clientInfo: { name: &#039;s3rm-wiki-search&#039;, version: &#039;1&#039; }&lt;br /&gt;
        }&lt;br /&gt;
      }, true).then(function (response) {&lt;br /&gt;
        if (!response || response.error) {&lt;br /&gt;
          throw new Error(&#039;MCP initialization failed&#039;);&lt;br /&gt;
        }&lt;br /&gt;
        return mcpRequest({&lt;br /&gt;
          jsonrpc: &#039;2.0&#039;,&lt;br /&gt;
          method: &#039;notifications/initialized&#039;,&lt;br /&gt;
          params: {}&lt;br /&gt;
        }, false);&lt;br /&gt;
      });&lt;br /&gt;
    }&lt;br /&gt;
    return mcpReady;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function searchSemantically(query, attempt, onWaiting) {&lt;br /&gt;
    var retryCount = attempt || 0;&lt;br /&gt;
    return initializeMcp().then(function () {&lt;br /&gt;
      return mcpRequest({&lt;br /&gt;
        jsonrpc: &#039;2.0&#039;,&lt;br /&gt;
        id: 2,&lt;br /&gt;
        method: &#039;tools/call&#039;,&lt;br /&gt;
        params: {&lt;br /&gt;
          name: &#039;search_lessons&#039;,&lt;br /&gt;
          arguments: {&lt;br /&gt;
            query: query,&lt;br /&gt;
            retrieval_mode: &#039;hybrid_v1&#039;,&lt;br /&gt;
            fields: [&#039;title&#039;, &#039;question&#039;, &#039;reusable_lesson&#039;, &#039;confidence&#039;, &#039;relations&#039;],&lt;br /&gt;
            limit: 6&lt;br /&gt;
          }&lt;br /&gt;
        }&lt;br /&gt;
      }, true);&lt;br /&gt;
    }).then(function (response) {&lt;br /&gt;
      if (!response || response.error || !response.result || response.result.isError) {&lt;br /&gt;
        throw new Error(&#039;Semantic search failed&#039;);&lt;br /&gt;
      }&lt;br /&gt;
      var payload = response.result.structuredContent || {};&lt;br /&gt;
      if (payload.semantic_status === &#039;building&#039; &amp;amp;&amp;amp; retryCount &amp;lt; SEMANTIC_RETRY_LIMIT) {&lt;br /&gt;
        if (onWaiting) {&lt;br /&gt;
          onWaiting();&lt;br /&gt;
        }&lt;br /&gt;
        return new Promise(function (resolve) {&lt;br /&gt;
          window.setTimeout(resolve, 2000);&lt;br /&gt;
        }).then(function () {&lt;br /&gt;
          return searchSemantically(query, retryCount + 1, onWaiting);&lt;br /&gt;
        });&lt;br /&gt;
      }&lt;br /&gt;
      return payload;&lt;br /&gt;
    });&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function textValue(value) {&lt;br /&gt;
    if (value === null || value === undefined) {&lt;br /&gt;
      return &#039;&#039;;&lt;br /&gt;
    }&lt;br /&gt;
    if (typeof value === &#039;string&#039; || typeof value === &#039;number&#039;) {&lt;br /&gt;
      return String(value);&lt;br /&gt;
    }&lt;br /&gt;
    return String(value.fulltext || value.displaytitle || value.raw || value.timestamp || &#039;&#039;);&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function firstValue(values, fallback) {&lt;br /&gt;
    return values &amp;amp;&amp;amp; values.length ? textValue(values[0]) : fallback;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function confidenceLabel(confidence) {&lt;br /&gt;
    if (confidence === &#039;high&#039;) { return &#039;신뢰도: 높음&#039;; }&lt;br /&gt;
    if (confidence === &#039;medium&#039;) { return &#039;신뢰도: 중간&#039;; }&lt;br /&gt;
    return &#039;신뢰도: 낮음&#039;;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function parseGraph(data) {&lt;br /&gt;
    var rawResults = data &amp;amp;&amp;amp; data.query &amp;amp;&amp;amp; data.query.results ? data.query.results : {};&lt;br /&gt;
    var entries = Array.isArray(rawResults)&lt;br /&gt;
      ? rawResults.map(function (item) { return [item.fulltext, item]; })&lt;br /&gt;
      : Object.keys(rawResults).map(function (key) { return [key, rawResults[key]]; });&lt;br /&gt;
    var nodes = [];&lt;br /&gt;
    var byId = Object.create(null);&lt;br /&gt;
&lt;br /&gt;
    entries.slice(0, NODE_LIMIT).forEach(function (entry, index) {&lt;br /&gt;
      var id = entry[0];&lt;br /&gt;
      var result = entry[1] || {};&lt;br /&gt;
      var printouts = result.printouts || {};&lt;br /&gt;
      var angle = index * 2.399963229728653;&lt;br /&gt;
      var radius = 20 * Math.sqrt(index + 1);&lt;br /&gt;
      var node = {&lt;br /&gt;
        id: id,&lt;br /&gt;
        title: firstValue(printouts[&#039;Lesson title&#039;], id.replace(/^Lesson:/, &#039;&#039;)),&lt;br /&gt;
        confidence: firstValue(printouts.Confidence, &#039;low&#039;).toLowerCase(),&lt;br /&gt;
        updatedAt: firstValue(printouts[&#039;Updated at&#039;], &#039;&#039;),&lt;br /&gt;
        x: Math.cos(angle) * radius,&lt;br /&gt;
        y: Math.sin(angle) * radius,&lt;br /&gt;
        vx: 0,&lt;br /&gt;
        vy: 0,&lt;br /&gt;
        degree: 0,&lt;br /&gt;
        printouts: printouts&lt;br /&gt;
      };&lt;br /&gt;
      nodes.push(node);&lt;br /&gt;
      byId[id] = node;&lt;br /&gt;
    });&lt;br /&gt;
&lt;br /&gt;
    var edges = [];&lt;br /&gt;
    nodes.forEach(function (node) {&lt;br /&gt;
      RELATIONS.forEach(function (relation) {&lt;br /&gt;
        var targets = node.printouts[relation.property] || [];&lt;br /&gt;
        targets.forEach(function (target) {&lt;br /&gt;
          var targetId = textValue(target);&lt;br /&gt;
          if (edges.length &amp;lt; EDGE_LIMIT &amp;amp;&amp;amp; byId[targetId] &amp;amp;&amp;amp; targetId !== node.id) {&lt;br /&gt;
            edges.push({ source: node, target: byId[targetId], relation: relation });&lt;br /&gt;
            node.degree += 1;&lt;br /&gt;
            byId[targetId].degree += 1;&lt;br /&gt;
          }&lt;br /&gt;
        });&lt;br /&gt;
      });&lt;br /&gt;
      delete node.printouts;&lt;br /&gt;
    });&lt;br /&gt;
&lt;br /&gt;
    var connectedNodes = nodes.filter(function (node) { return node.degree &amp;gt; 0; });&lt;br /&gt;
    var isolatedNodes = nodes.filter(function (node) { return node.degree === 0; });&lt;br /&gt;
    connectedNodes.forEach(function (node, index) {&lt;br /&gt;
      var angle = index * 2.399963229728653;&lt;br /&gt;
      var radius = 16 * Math.sqrt(index + 1);&lt;br /&gt;
      node.x = Math.cos(angle) * radius;&lt;br /&gt;
      node.y = Math.sin(angle) * radius;&lt;br /&gt;
    });&lt;br /&gt;
    isolatedNodes.forEach(function (node, index) {&lt;br /&gt;
      var angle = index * 2.399963229728653 + 0.7;&lt;br /&gt;
      var radius = 92 + 10 * Math.sqrt(index + 1);&lt;br /&gt;
      node.x = Math.cos(angle) * radius;&lt;br /&gt;
      node.y = Math.sin(angle) * radius;&lt;br /&gt;
    });&lt;br /&gt;
    nodes.forEach(function (node) {&lt;br /&gt;
      node.homeX = node.x;&lt;br /&gt;
      node.homeY = node.y;&lt;br /&gt;
    });&lt;br /&gt;
    return { nodes: nodes, edges: edges };&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function makeElement(tag, className, text) {&lt;br /&gt;
    var element = document.createElement(tag);&lt;br /&gt;
    if (className) {&lt;br /&gt;
      element.className = className;&lt;br /&gt;
    }&lt;br /&gt;
    if (text !== undefined) {&lt;br /&gt;
      element.textContent = text;&lt;br /&gt;
    }&lt;br /&gt;
    return element;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function createSemanticSearch() {&lt;br /&gt;
    if (!semanticRoot) {&lt;br /&gt;
      return;&lt;br /&gt;
    }&lt;br /&gt;
    semanticRoot.replaceChildren();&lt;br /&gt;
    var intro = makeElement(&#039;div&#039;, &#039;s3rm-semantic-intro&#039;);&lt;br /&gt;
    intro.appendChild(makeElement(&#039;div&#039;, &#039;s3rm-semantic-icon&#039;, &#039;⌕&#039;));&lt;br /&gt;
    var introCopy = makeElement(&#039;div&#039;, &#039;&#039;);&lt;br /&gt;
    introCopy.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;검색&#039;));&lt;br /&gt;
    introCopy.appendChild(makeElement(&#039;p&#039;, &#039;&#039;, &#039;현상이나 궁금한 점을 문장으로 입력하세요. 표현이 달라도 관련 메모를 찾습니다.&#039;));&lt;br /&gt;
    intro.appendChild(introCopy);&lt;br /&gt;
&lt;br /&gt;
    var form = makeElement(&#039;form&#039;, &#039;s3rm-semantic-form&#039;);&lt;br /&gt;
    var input = makeElement(&#039;input&#039;, &#039;s3rm-semantic-input&#039;);&lt;br /&gt;
    input.type = &#039;search&#039;;&lt;br /&gt;
    input.name = &#039;query&#039;;&lt;br /&gt;
    input.maxLength = 500;&lt;br /&gt;
    input.placeholder = &#039;예: GPU 메모리는 남아 있는데 할당이 실패하는 이유는?&#039;;&lt;br /&gt;
    input.setAttribute(&#039;aria-label&#039;, &#039;메모 검색&#039;);&lt;br /&gt;
    var submit = makeElement(&#039;button&#039;, &#039;s3rm-semantic-submit&#039;, &#039;검색&#039;);&lt;br /&gt;
    submit.type = &#039;submit&#039;;&lt;br /&gt;
    form.append(input, submit);&lt;br /&gt;
&lt;br /&gt;
    var examples = makeElement(&#039;div&#039;, &#039;s3rm-semantic-examples&#039;);&lt;br /&gt;
    examples.appendChild(makeElement(&#039;span&#039;, &#039;&#039;, &#039;검색 예시&#039;));&lt;br /&gt;
    [&lt;br /&gt;
      &#039;재시도가 한꺼번에 몰리는 이유&#039;,&lt;br /&gt;
      &#039;GPU와 SSD 사이 병목을 줄이는 방법&#039;,&lt;br /&gt;
      &#039;긴 작업에서 지연이 갑자기 커지는 원인&#039;&lt;br /&gt;
    ].forEach(function (example) {&lt;br /&gt;
      var button = makeElement(&#039;button&#039;, &#039;&#039;, example);&lt;br /&gt;
      button.type = &#039;button&#039;;&lt;br /&gt;
      button.addEventListener(&#039;click&#039;, function () {&lt;br /&gt;
        input.value = example;&lt;br /&gt;
        form.requestSubmit();&lt;br /&gt;
      });&lt;br /&gt;
      examples.appendChild(button);&lt;br /&gt;
    });&lt;br /&gt;
&lt;br /&gt;
    var feedback = makeElement(&#039;div&#039;, &#039;s3rm-semantic-feedback&#039;);&lt;br /&gt;
    feedback.setAttribute(&#039;aria-live&#039;, &#039;polite&#039;);&lt;br /&gt;
    feedback.hidden = true;&lt;br /&gt;
    semanticRoot.append(intro, form, examples, feedback);&lt;br /&gt;
&lt;br /&gt;
    function renderSearchResults(payload, query) {&lt;br /&gt;
      var items = Array.isArray(payload.items) ? payload.items : [];&lt;br /&gt;
      if (graphSearchController) {&lt;br /&gt;
        graphSearchController(items, query);&lt;br /&gt;
      } else {&lt;br /&gt;
        pendingGraphSearch = { items: items, query: query };&lt;br /&gt;
      }&lt;br /&gt;
      feedback.replaceChildren();&lt;br /&gt;
      feedback.hidden = false;&lt;br /&gt;
      var resultHeader = makeElement(&#039;div&#039;, &#039;s3rm-semantic-result-header&#039;);&lt;br /&gt;
      resultHeader.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;“&#039; + query + &#039;” 검색 결과&#039;));&lt;br /&gt;
      resultHeader.appendChild(makeElement(&#039;span&#039;, &#039;s3rm-semantic-mode&#039;, items.length + &#039;개 메모&#039;));&lt;br /&gt;
      feedback.appendChild(resultHeader);&lt;br /&gt;
&lt;br /&gt;
      if (!items.length) {&lt;br /&gt;
        var emptyMessage = payload.semantic_status === &#039;building&#039;&lt;br /&gt;
          ? &#039;검색을 준비하고 있습니다. 잠시 후 다시 검색해 주세요.&#039;&lt;br /&gt;
          : &#039;가까운 메모를 찾지 못했습니다. 표현을 조금 바꿔 다시 검색해 보세요.&#039;;&lt;br /&gt;
        feedback.appendChild(makeElement(&#039;p&#039;, &#039;s3rm-semantic-empty&#039;, emptyMessage));&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      feedback.appendChild(makeElement(&#039;p&#039;, &#039;s3rm-semantic-graph-note&#039;, &#039;아래 그래프에 검색 결과를 모았습니다. 점을 선택하면 메모와 연결을 볼 수 있습니다.&#039;));&lt;br /&gt;
      var list = makeElement(&#039;div&#039;, &#039;s3rm-semantic-result-links&#039;);&lt;br /&gt;
      items.forEach(function (item) {&lt;br /&gt;
        var link = makeElement(&#039;a&#039;, &#039;&#039;, item.title || String(item.id).replace(/^Lesson:/, &#039;&#039;));&lt;br /&gt;
        link.href = mw.util.getUrl(item.id);&lt;br /&gt;
        list.appendChild(link);&lt;br /&gt;
      });&lt;br /&gt;
      feedback.appendChild(list);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    form.addEventListener(&#039;submit&#039;, function (event) {&lt;br /&gt;
      event.preventDefault();&lt;br /&gt;
      var query = input.value.trim();&lt;br /&gt;
      if (query.length &amp;lt; 2) {&lt;br /&gt;
        feedback.replaceChildren(makeElement(&#039;p&#039;, &#039;s3rm-semantic-error&#039;, &#039;두 글자 이상 입력해 주세요.&#039;));&lt;br /&gt;
        feedback.hidden = false;&lt;br /&gt;
        input.focus();&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      submit.disabled = true;&lt;br /&gt;
      submit.textContent = &#039;찾는 중…&#039;;&lt;br /&gt;
      feedback.replaceChildren(makeElement(&#039;div&#039;, &#039;s3rm-semantic-searching&#039;, &#039;관련 메모를 찾고 있습니다.&#039;));&lt;br /&gt;
      feedback.hidden = false;&lt;br /&gt;
      searchSemantically(query, 0, function () {&lt;br /&gt;
        feedback.replaceChildren(makeElement(&#039;div&#039;, &#039;s3rm-semantic-searching&#039;, &#039;검색을 준비하고 있습니다. 완료되면 결과를 바로 보여 드립니다.&#039;));&lt;br /&gt;
      }).then(function (payload) {&lt;br /&gt;
        renderSearchResults(payload, query);&lt;br /&gt;
      }).catch(function () {&lt;br /&gt;
        feedback.replaceChildren();&lt;br /&gt;
        var error = makeElement(&#039;div&#039;, &#039;s3rm-semantic-error&#039;);&lt;br /&gt;
        error.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;지금은 검색을 사용할 수 없습니다.&#039;));&lt;br /&gt;
        var fallback = makeElement(&#039;a&#039;, &#039;&#039;, &#039;검색 열기&#039;);&lt;br /&gt;
        fallback.href = mw.util.getUrl(&#039;Special:Search&#039;, { search: query });&lt;br /&gt;
        error.appendChild(fallback);&lt;br /&gt;
        feedback.appendChild(error);&lt;br /&gt;
      }).finally(function () {&lt;br /&gt;
        submit.disabled = false;&lt;br /&gt;
        submit.textContent = &#039;검색&#039;;&lt;br /&gt;
      });&lt;br /&gt;
    });&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function renderEmpty() {&lt;br /&gt;
    root.replaceChildren();&lt;br /&gt;
    var empty = makeElement(&#039;div&#039;, &#039;s3rm-graph-empty&#039;);&lt;br /&gt;
    empty.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;아직 연결할 메모가 없습니다&#039;));&lt;br /&gt;
    empty.appendChild(makeElement(&#039;p&#039;, &#039;&#039;, &#039;첫 메모를 남기면 이곳에서 지식의 연결을 볼 수 있습니다.&#039;));&lt;br /&gt;
    var link = makeElement(&#039;a&#039;, &#039;s3rm-graph-primary-link&#039;, &#039;첫 메모 작성하기&#039;);&lt;br /&gt;
    link.href = &#039;#quick-start&#039;;&lt;br /&gt;
    empty.appendChild(link);&lt;br /&gt;
    root.appendChild(empty);&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function renderError() {&lt;br /&gt;
    root.replaceChildren();&lt;br /&gt;
    var fallback = makeElement(&#039;div&#039;, &#039;s3rm-graph-empty&#039;);&lt;br /&gt;
    fallback.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;그래프를 불러오지 못했습니다&#039;));&lt;br /&gt;
    fallback.appendChild(makeElement(&#039;p&#039;, &#039;&#039;, &#039;최근 메모와 검색은 계속 사용할 수 있습니다. 잠시 뒤 다시 시도해 주세요.&#039;));&lt;br /&gt;
    var link = makeElement(&#039;a&#039;, &#039;s3rm-graph-primary-link&#039;, &#039;전체 메모 보기&#039;);&lt;br /&gt;
    link.href = mw.util.getUrl(&#039;Special:AllPages/Lesson:&#039;);&lt;br /&gt;
    fallback.appendChild(link);&lt;br /&gt;
    root.appendChild(fallback);&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function createGraph(graph) {&lt;br /&gt;
    var nodes = graph.nodes;&lt;br /&gt;
    var edges = graph.edges;&lt;br /&gt;
    var byId = Object.create(null);&lt;br /&gt;
    function canonicalId(id) {&lt;br /&gt;
      return String(id || &#039;&#039;).replace(/_/g, &#039; &#039;).toLocaleLowerCase(&#039;en&#039;);&lt;br /&gt;
    }&lt;br /&gt;
    nodes.forEach(function (node) { byId[canonicalId(node.id)] = node; });&lt;br /&gt;
    var selected = null;&lt;br /&gt;
    var hovered = null;&lt;br /&gt;
    var searchCluster = null;&lt;br /&gt;
    var activeRelation = &#039;all&#039;;&lt;br /&gt;
    var transform = { x: 0, y: 0, scale: 1 };&lt;br /&gt;
    var dragging = null;&lt;br /&gt;
    var panning = false;&lt;br /&gt;
    var pointerStart = null;&lt;br /&gt;
    var dragMotion = null;&lt;br /&gt;
    var animationFrame = 0;&lt;br /&gt;
    var simulationFrames = 0;&lt;br /&gt;
    var simulationRunning = false;&lt;br /&gt;
    var fitAfterSimulation = true;&lt;br /&gt;
    var reduceMotion = window.matchMedia &amp;amp;&amp;amp; window.matchMedia(&#039;(prefers-reduced-motion: reduce)&#039;).matches;&lt;br /&gt;
&lt;br /&gt;
    root.replaceChildren();&lt;br /&gt;
    var toolbar = makeElement(&#039;div&#039;, &#039;s3rm-graph-toolbar&#039;);&lt;br /&gt;
    var searchWrap = makeElement(&#039;div&#039;, &#039;s3rm-graph-search&#039;);&lt;br /&gt;
    var search = makeElement(&#039;input&#039;, &#039;s3rm-graph-search-input&#039;);&lt;br /&gt;
    search.type = &#039;search&#039;;&lt;br /&gt;
    search.placeholder = &#039;그래프에서 제목 필터&#039;;&lt;br /&gt;
    search.setAttribute(&#039;aria-label&#039;, &#039;그래프 제목 필터&#039;);&lt;br /&gt;
    search.autocomplete = &#039;off&#039;;&lt;br /&gt;
    var searchResults = makeElement(&#039;div&#039;, &#039;s3rm-graph-search-results&#039;);&lt;br /&gt;
    searchResults.hidden = true;&lt;br /&gt;
    searchWrap.append(search, searchResults);&lt;br /&gt;
&lt;br /&gt;
    var filter = makeElement(&#039;select&#039;, &#039;s3rm-graph-filter&#039;);&lt;br /&gt;
    filter.setAttribute(&#039;aria-label&#039;, &#039;관계 유형 필터&#039;);&lt;br /&gt;
    var allOption = makeElement(&#039;option&#039;, &#039;&#039;, &#039;모든 관계&#039;);&lt;br /&gt;
    allOption.value = &#039;all&#039;;&lt;br /&gt;
    filter.appendChild(allOption);&lt;br /&gt;
    RELATIONS.forEach(function (relation) {&lt;br /&gt;
      var option = makeElement(&#039;option&#039;, &#039;&#039;, relation.label);&lt;br /&gt;
      option.value = relation.key;&lt;br /&gt;
      filter.appendChild(option);&lt;br /&gt;
    });&lt;br /&gt;
    var reset = makeElement(&#039;button&#039;, &#039;s3rm-graph-reset&#039;, &#039;화면 맞춤&#039;);&lt;br /&gt;
    reset.type = &#039;button&#039;;&lt;br /&gt;
    var clearSearch = makeElement(&#039;button&#039;, &#039;s3rm-graph-clear-search&#039;, &#039;검색 결과 해제&#039;);&lt;br /&gt;
    clearSearch.type = &#039;button&#039;;&lt;br /&gt;
    clearSearch.hidden = true;&lt;br /&gt;
    toolbar.append(searchWrap, filter, reset, clearSearch);&lt;br /&gt;
&lt;br /&gt;
    var body = makeElement(&#039;div&#039;, &#039;s3rm-graph-body&#039;);&lt;br /&gt;
    var viewport = makeElement(&#039;div&#039;, &#039;s3rm-graph-viewport&#039;);&lt;br /&gt;
    var canvas = document.createElement(&#039;canvas&#039;);&lt;br /&gt;
    canvas.className = &#039;s3rm-graph-canvas&#039;;&lt;br /&gt;
    canvas.setAttribute(&#039;aria-hidden&#039;, &#039;true&#039;);&lt;br /&gt;
    viewport.appendChild(canvas);&lt;br /&gt;
    var hint = makeElement(&#039;div&#039;, &#039;s3rm-graph-hint&#039;, &#039;드래그하여 이동 · 스크롤하여 확대 · 두 번 눌러 메모 열기&#039;);&lt;br /&gt;
    viewport.appendChild(hint);&lt;br /&gt;
&lt;br /&gt;
    var inspector = makeElement(&#039;aside&#039;, &#039;s3rm-graph-inspector&#039;);&lt;br /&gt;
    inspector.setAttribute(&#039;aria-live&#039;, &#039;polite&#039;);&lt;br /&gt;
    body.append(viewport, inspector);&lt;br /&gt;
&lt;br /&gt;
    var footer = makeElement(&#039;div&#039;, &#039;s3rm-graph-footer&#039;);&lt;br /&gt;
    var counts = makeElement(&#039;span&#039;, &#039;s3rm-graph-counts&#039;, nodes.length + &#039;개 메모 · &#039; + edges.length + &#039;개 연결&#039;);&lt;br /&gt;
    var legend = makeElement(&#039;div&#039;, &#039;s3rm-graph-legend&#039;);&lt;br /&gt;
    RELATIONS.slice(0, 4).forEach(function (relation) {&lt;br /&gt;
      var item = makeElement(&#039;span&#039;, &#039;s3rm-graph-legend-item&#039;);&lt;br /&gt;
      var swatch = makeElement(&#039;i&#039;, &#039;&#039;);&lt;br /&gt;
      swatch.style.backgroundColor = relation.color;&lt;br /&gt;
      item.append(swatch, document.createTextNode(relation.label));&lt;br /&gt;
      legend.appendChild(item);&lt;br /&gt;
    });&lt;br /&gt;
    footer.append(counts, legend);&lt;br /&gt;
    root.append(toolbar, body, footer);&lt;br /&gt;
&lt;br /&gt;
    var context = canvas.getContext(&#039;2d&#039;);&lt;br /&gt;
    var size = { width: 0, height: 0, ratio: 1 };&lt;br /&gt;
&lt;br /&gt;
    function visibleEdges() {&lt;br /&gt;
      return activeRelation === &#039;all&#039;&lt;br /&gt;
        ? edges&lt;br /&gt;
        : edges.filter(function (edge) { return edge.relation.key === activeRelation; });&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function nodeRadius(node) {&lt;br /&gt;
      return 6 + Math.min(8, Math.sqrt(node.degree) * 2.2) + (node.isSearchResult ? 3 : 0);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function confidenceColor(confidence) {&lt;br /&gt;
      if (confidence === &#039;high&#039;) { return &#039;#53c18b&#039;; }&lt;br /&gt;
      if (confidence === &#039;medium&#039;) { return &#039;#8ca8ff&#039;; }&lt;br /&gt;
      return &#039;#c0c5d8&#039;;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function resize() {&lt;br /&gt;
      var rect = viewport.getBoundingClientRect();&lt;br /&gt;
      size.width = Math.max(320, rect.width);&lt;br /&gt;
      size.height = Math.max(360, rect.height);&lt;br /&gt;
      size.ratio = Math.min(window.devicePixelRatio || 1, 2);&lt;br /&gt;
      canvas.width = Math.round(size.width * size.ratio);&lt;br /&gt;
      canvas.height = Math.round(size.height * size.ratio);&lt;br /&gt;
      canvas.style.width = size.width + &#039;px&#039;;&lt;br /&gt;
      canvas.style.height = size.height + &#039;px&#039;;&lt;br /&gt;
      draw();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function fitGraph() {&lt;br /&gt;
      if (!nodes.length) { return; }&lt;br /&gt;
      var xs = nodes.map(function (node) { return node.x; });&lt;br /&gt;
      var ys = nodes.map(function (node) { return node.y; });&lt;br /&gt;
      var minX = Math.min.apply(null, xs) - 38;&lt;br /&gt;
      var maxX = Math.max.apply(null, xs) + 38;&lt;br /&gt;
      var minY = Math.min.apply(null, ys) - 38;&lt;br /&gt;
      var maxY = Math.max.apply(null, ys) + 38;&lt;br /&gt;
      transform.scale = Math.max(0.2, Math.min(1.25, Math.min(&lt;br /&gt;
        size.width / Math.max(1, maxX - minX),&lt;br /&gt;
        size.height / Math.max(1, maxY - minY)&lt;br /&gt;
      )));&lt;br /&gt;
      transform.x = size.width / 2 - ((minX + maxX) / 2) * transform.scale;&lt;br /&gt;
      transform.y = size.height / 2 - ((minY + maxY) / 2) * transform.scale;&lt;br /&gt;
      draw();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function draw() {&lt;br /&gt;
      context.setTransform(size.ratio, 0, 0, size.ratio, 0, 0);&lt;br /&gt;
      context.clearRect(0, 0, size.width, size.height);&lt;br /&gt;
      context.save();&lt;br /&gt;
      context.translate(transform.x, transform.y);&lt;br /&gt;
      context.scale(transform.scale, transform.scale);&lt;br /&gt;
&lt;br /&gt;
      if (searchCluster) {&lt;br /&gt;
        context.beginPath();&lt;br /&gt;
        context.arc(&lt;br /&gt;
          searchCluster.x,&lt;br /&gt;
          searchCluster.y,&lt;br /&gt;
          searchCluster.radius + 48,&lt;br /&gt;
          0,&lt;br /&gt;
          Math.PI * 2&lt;br /&gt;
        );&lt;br /&gt;
        context.fillStyle = &#039;rgba(104, 126, 233, 0.12)&#039;;&lt;br /&gt;
        context.fill();&lt;br /&gt;
        context.setLineDash([7 / transform.scale, 6 / transform.scale]);&lt;br /&gt;
        context.lineWidth = 1.5 / transform.scale;&lt;br /&gt;
        context.strokeStyle = &#039;rgba(140, 168, 255, 0.75)&#039;;&lt;br /&gt;
        context.stroke();&lt;br /&gt;
        context.setLineDash([]);&lt;br /&gt;
        context.font = &#039;700 &#039; + (12 / transform.scale) + &#039;px sans-serif&#039;;&lt;br /&gt;
        context.textAlign = &#039;center&#039;;&lt;br /&gt;
        context.textBaseline = &#039;bottom&#039;;&lt;br /&gt;
        context.fillStyle = &#039;#b9c7ff&#039;;&lt;br /&gt;
        context.fillText(&lt;br /&gt;
          &#039;검색 결과 · &#039; + searchCluster.nodes.length + &#039;개 메모&#039;,&lt;br /&gt;
          searchCluster.x,&lt;br /&gt;
          searchCluster.y - searchCluster.radius - 58 / transform.scale&lt;br /&gt;
        );&lt;br /&gt;
      }&lt;br /&gt;
&lt;br /&gt;
      visibleEdges().forEach(function (edge) {&lt;br /&gt;
        var emphasized = selected &amp;amp;&amp;amp; (edge.source === selected || edge.target === selected);&lt;br /&gt;
        var searchEdge = searchCluster &amp;amp;&amp;amp;&lt;br /&gt;
          (edge.source.isSearchResult || edge.target.isSearchResult);&lt;br /&gt;
        context.globalAlpha = searchCluster &amp;amp;&amp;amp; !searchEdge ? 0.1 : 1;&lt;br /&gt;
        context.beginPath();&lt;br /&gt;
        context.moveTo(edge.source.x, edge.source.y);&lt;br /&gt;
        context.lineTo(edge.target.x, edge.target.y);&lt;br /&gt;
        context.strokeStyle = emphasized ? edge.relation.color : edge.relation.color + &#039;55&#039;;&lt;br /&gt;
        context.lineWidth = (emphasized || searchEdge ? 2.2 : 1) / transform.scale;&lt;br /&gt;
        context.stroke();&lt;br /&gt;
      });&lt;br /&gt;
      context.globalAlpha = 1;&lt;br /&gt;
&lt;br /&gt;
      nodes.forEach(function (node) {&lt;br /&gt;
        var isSelected = node === selected;&lt;br /&gt;
        var isHovered = node === hovered;&lt;br /&gt;
        var isSearchResult = Boolean(node.isSearchResult);&lt;br /&gt;
        var radius = nodeRadius(node);&lt;br /&gt;
        context.globalAlpha = searchCluster &amp;amp;&amp;amp; !isSearchResult ? 0.12 : 1;&lt;br /&gt;
        context.beginPath();&lt;br /&gt;
        context.arc(node.x, node.y, radius + (isSelected ? 4 : 0), 0, Math.PI * 2);&lt;br /&gt;
        context.fillStyle = isSelected || isSearchResult ? &#039;#ffffff&#039; : confidenceColor(node.confidence);&lt;br /&gt;
        context.fill();&lt;br /&gt;
        context.lineWidth = (isSelected || isSearchResult ? 3 : 1.5) / transform.scale;&lt;br /&gt;
        context.strokeStyle = isSelected || isSearchResult ? &#039;#8ca8ff&#039; : &#039;#11182e&#039;;&lt;br /&gt;
        context.stroke();&lt;br /&gt;
&lt;br /&gt;
        if (isSelected || isHovered || isSearchResult ||&lt;br /&gt;
          (transform.scale &amp;gt; 0.68 &amp;amp;&amp;amp; node.degree &amp;gt; 0)) {&lt;br /&gt;
          context.font = (isSelected || isSearchResult ? &#039;600 &#039; : &#039;&#039;) + (12 / transform.scale) + &#039;px sans-serif&#039;;&lt;br /&gt;
          context.textAlign = &#039;center&#039;;&lt;br /&gt;
          context.textBaseline = &#039;top&#039;;&lt;br /&gt;
          context.fillStyle = &#039;#f5f7ff&#039;;&lt;br /&gt;
          context.shadowColor = &#039;#080b16&#039;;&lt;br /&gt;
          context.shadowBlur = 5 / transform.scale;&lt;br /&gt;
          var label = node.title.length &amp;gt; 26 ? node.title.slice(0, 25) + &#039;…&#039; : node.title;&lt;br /&gt;
          context.fillText(label, node.x, node.y + radius + 7 / transform.scale);&lt;br /&gt;
          context.shadowBlur = 0;&lt;br /&gt;
        }&lt;br /&gt;
      });&lt;br /&gt;
      context.globalAlpha = 1;&lt;br /&gt;
      context.restore();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function startSimulation(frames, shouldFit) {&lt;br /&gt;
      simulationFrames = Math.max(simulationFrames, reduceMotion ? 1 : frames);&lt;br /&gt;
      fitAfterSimulation = fitAfterSimulation || Boolean(shouldFit);&lt;br /&gt;
      if (!simulationRunning) {&lt;br /&gt;
        simulationRunning = true;&lt;br /&gt;
        animationFrame = window.requestAnimationFrame(simulate);&lt;br /&gt;
      }&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function simulate() {&lt;br /&gt;
      if (simulationFrames &amp;lt;= 0) {&lt;br /&gt;
        simulationRunning = false;&lt;br /&gt;
        if (fitAfterSimulation &amp;amp;&amp;amp; !searchCluster) {&lt;br /&gt;
          fitGraph();&lt;br /&gt;
        } else {&lt;br /&gt;
          draw();&lt;br /&gt;
        }&lt;br /&gt;
        fitAfterSimulation = false;&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
&lt;br /&gt;
      for (var i = 0; i &amp;lt; nodes.length; i += 1) {&lt;br /&gt;
        for (var j = i + 1; j &amp;lt; nodes.length; j += 1) {&lt;br /&gt;
          var first = nodes[i];&lt;br /&gt;
          var second = nodes[j];&lt;br /&gt;
          var isolatedPair = first.degree === 0 &amp;amp;&amp;amp; second.degree === 0;&lt;br /&gt;
          if (isolatedPair &amp;amp;&amp;amp; !first.motionActive &amp;amp;&amp;amp; !second.motionActive &amp;amp;&amp;amp;&lt;br /&gt;
            first !== dragging &amp;amp;&amp;amp; second !== dragging) {&lt;br /&gt;
            continue;&lt;br /&gt;
          }&lt;br /&gt;
          var dx = second.x - first.x || 0.01;&lt;br /&gt;
          var dy = second.y - first.y || 0.01;&lt;br /&gt;
          var distanceSquared = dx * dx + dy * dy;&lt;br /&gt;
          var distance = Math.sqrt(distanceSquared) || 0.01;&lt;br /&gt;
          var minimumDistance = nodeRadius(first) + nodeRadius(second) + 7;&lt;br /&gt;
          var force = distance &amp;lt; minimumDistance&lt;br /&gt;
            ? (minimumDistance - distance) * 0.035 / distance&lt;br /&gt;
            : 1.8 / Math.max(80, distanceSquared);&lt;br /&gt;
          if (first !== dragging) {&lt;br /&gt;
            first.vx -= dx * force;&lt;br /&gt;
            first.vy -= dy * force;&lt;br /&gt;
          }&lt;br /&gt;
          if (second !== dragging) {&lt;br /&gt;
            second.vx += dx * force;&lt;br /&gt;
            second.vy += dy * force;&lt;br /&gt;
          }&lt;br /&gt;
        }&lt;br /&gt;
      }&lt;br /&gt;
      edges.forEach(function (edge) {&lt;br /&gt;
        var dx = edge.target.x - edge.source.x;&lt;br /&gt;
        var dy = edge.target.y - edge.source.y;&lt;br /&gt;
        var distance = Math.sqrt(dx * dx + dy * dy) || 1;&lt;br /&gt;
        var force = (distance - 64) * 0.0014;&lt;br /&gt;
        if (edge.source !== dragging) {&lt;br /&gt;
          edge.source.vx += dx * force;&lt;br /&gt;
          edge.source.vy += dy * force;&lt;br /&gt;
        }&lt;br /&gt;
        if (edge.target !== dragging) {&lt;br /&gt;
          edge.target.vx -= dx * force;&lt;br /&gt;
          edge.target.vy -= dy * force;&lt;br /&gt;
        }&lt;br /&gt;
      });&lt;br /&gt;
      nodes.forEach(function (node) {&lt;br /&gt;
        if (node === dragging) {&lt;br /&gt;
          return;&lt;br /&gt;
        }&lt;br /&gt;
        if (node.isSearchResult &amp;amp;&amp;amp; !node.motionActive) {&lt;br /&gt;
          node.vx = 0;&lt;br /&gt;
          node.vy = 0;&lt;br /&gt;
          return;&lt;br /&gt;
        }&lt;br /&gt;
        if (node.degree === 0 &amp;amp;&amp;amp; !node.motionActive) {&lt;br /&gt;
          node.vx = 0;&lt;br /&gt;
          node.vy = 0;&lt;br /&gt;
          return;&lt;br /&gt;
        }&lt;br /&gt;
        if (node.degree === 0) {&lt;br /&gt;
          node.vx += (node.homeX - node.x) * 0.0022;&lt;br /&gt;
          node.vy += (node.homeY - node.y) * 0.0022;&lt;br /&gt;
        } else {&lt;br /&gt;
          node.vx += -node.x * 0.0018;&lt;br /&gt;
          node.vy += -node.y * 0.0018;&lt;br /&gt;
        }&lt;br /&gt;
        node.vx *= 0.9;&lt;br /&gt;
        node.vy *= 0.9;&lt;br /&gt;
        node.x += node.vx;&lt;br /&gt;
        node.y += node.vy;&lt;br /&gt;
        if (node.motionActive &amp;amp;&amp;amp; Math.abs(node.vx) + Math.abs(node.vy) &amp;lt; 0.025) {&lt;br /&gt;
          delete node.motionActive;&lt;br /&gt;
        }&lt;br /&gt;
      });&lt;br /&gt;
      simulationFrames -= 1;&lt;br /&gt;
      draw();&lt;br /&gt;
      animationFrame = window.requestAnimationFrame(simulate);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function relationDefinition(key) {&lt;br /&gt;
      return RELATIONS.find(function (relation) { return relation.key === key; });&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function updateCounts() {&lt;br /&gt;
      if (searchCluster) {&lt;br /&gt;
        var relatedEdges = edges.filter(function (edge) {&lt;br /&gt;
          return edge.source.isSearchResult || edge.target.isSearchResult;&lt;br /&gt;
        }).length;&lt;br /&gt;
        counts.textContent = &#039;검색 결과 &#039; + searchCluster.nodes.length + &#039;개 · &#039; +&lt;br /&gt;
          relatedEdges + &#039;개 연결&#039;;&lt;br /&gt;
      } else {&lt;br /&gt;
        counts.textContent = nodes.length + &#039;개 메모 · &#039; + visibleEdges().length + &#039;개 연결&#039;;&lt;br /&gt;
      }&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function resetSearchCluster() {&lt;br /&gt;
      var addedKeys = Object.create(null);&lt;br /&gt;
      edges.forEach(function (edge) {&lt;br /&gt;
        if (edge.searchAdded) {&lt;br /&gt;
          edge.source.degree = Math.max(0, edge.source.degree - 1);&lt;br /&gt;
          edge.target.degree = Math.max(0, edge.target.degree - 1);&lt;br /&gt;
        }&lt;br /&gt;
      });&lt;br /&gt;
      nodes.forEach(function (node) {&lt;br /&gt;
        if (node.searchAdded) {&lt;br /&gt;
          addedKeys[canonicalId(node.id)] = true;&lt;br /&gt;
          delete byId[canonicalId(node.id)];&lt;br /&gt;
        } else if (node.preSearchPosition) {&lt;br /&gt;
          node.x = node.preSearchPosition.x;&lt;br /&gt;
          node.y = node.preSearchPosition.y;&lt;br /&gt;
          node.homeX = node.preSearchHome.x;&lt;br /&gt;
          node.homeY = node.preSearchHome.y;&lt;br /&gt;
        }&lt;br /&gt;
        delete node.isSearchResult;&lt;br /&gt;
        delete node.preSearchPosition;&lt;br /&gt;
        delete node.preSearchHome;&lt;br /&gt;
        delete node.motionActive;&lt;br /&gt;
      });&lt;br /&gt;
      nodes = nodes.filter(function (node) { return !node.searchAdded; });&lt;br /&gt;
      edges = edges.filter(function (edge) {&lt;br /&gt;
        return !edge.searchAdded &amp;amp;&amp;amp;&lt;br /&gt;
          !addedKeys[canonicalId(edge.source.id)] &amp;amp;&amp;amp;&lt;br /&gt;
          !addedKeys[canonicalId(edge.target.id)];&lt;br /&gt;
      });&lt;br /&gt;
      searchCluster = null;&lt;br /&gt;
      clearSearch.hidden = true;&lt;br /&gt;
      selected = null;&lt;br /&gt;
      showInspector(null);&lt;br /&gt;
      updateCounts();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function focusSearchResults(items, query) {&lt;br /&gt;
      resetSearchCluster();&lt;br /&gt;
      if (!items.length) {&lt;br /&gt;
        fitGraph();&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
&lt;br /&gt;
      var resultNodes = [];&lt;br /&gt;
      items.forEach(function (item, index) {&lt;br /&gt;
        var key = canonicalId(item.id);&lt;br /&gt;
        var node = byId[key];&lt;br /&gt;
        if (!node) {&lt;br /&gt;
          node = {&lt;br /&gt;
            id: item.id,&lt;br /&gt;
            title: item.title || String(item.id).replace(/^Lesson:/, &#039;&#039;),&lt;br /&gt;
            confidence: item.confidence || &#039;low&#039;,&lt;br /&gt;
            updatedAt: &#039;&#039;,&lt;br /&gt;
            x: 0,&lt;br /&gt;
            y: 0,&lt;br /&gt;
            vx: 0,&lt;br /&gt;
            vy: 0,&lt;br /&gt;
            degree: 0,&lt;br /&gt;
            homeX: 0,&lt;br /&gt;
            homeY: 0,&lt;br /&gt;
            searchAdded: true&lt;br /&gt;
          };&lt;br /&gt;
          nodes.push(node);&lt;br /&gt;
          byId[key] = node;&lt;br /&gt;
        } else {&lt;br /&gt;
          node.preSearchPosition = { x: node.x, y: node.y };&lt;br /&gt;
          node.preSearchHome = { x: node.homeX, y: node.homeY };&lt;br /&gt;
          node.title = item.title || node.title;&lt;br /&gt;
          node.confidence = item.confidence || node.confidence;&lt;br /&gt;
        }&lt;br /&gt;
        node.isSearchResult = true;&lt;br /&gt;
        node.searchRank = index + 1;&lt;br /&gt;
        resultNodes.push(node);&lt;br /&gt;
      });&lt;br /&gt;
&lt;br /&gt;
      items.forEach(function (item) {&lt;br /&gt;
        (Array.isArray(item.relations) ? item.relations : []).forEach(function (relationItem) {&lt;br /&gt;
          var source = byId[canonicalId(relationItem.from || item.id)];&lt;br /&gt;
          var target = byId[canonicalId(relationItem.to)];&lt;br /&gt;
          var relation = relationDefinition(relationItem.relation);&lt;br /&gt;
          if (!source || !target || !relation || source === target) {&lt;br /&gt;
            return;&lt;br /&gt;
          }&lt;br /&gt;
          var duplicate = edges.some(function (edge) {&lt;br /&gt;
            return canonicalId(edge.source.id) === canonicalId(source.id) &amp;amp;&amp;amp;&lt;br /&gt;
              canonicalId(edge.target.id) === canonicalId(target.id) &amp;amp;&amp;amp;&lt;br /&gt;
              edge.relation.key === relation.key;&lt;br /&gt;
          });&lt;br /&gt;
          if (!duplicate) {&lt;br /&gt;
            edges.push({&lt;br /&gt;
              source: source,&lt;br /&gt;
              target: target,&lt;br /&gt;
              relation: relation,&lt;br /&gt;
              searchAdded: true&lt;br /&gt;
            });&lt;br /&gt;
            source.degree += 1;&lt;br /&gt;
            target.degree += 1;&lt;br /&gt;
          }&lt;br /&gt;
        });&lt;br /&gt;
      });&lt;br /&gt;
&lt;br /&gt;
      var clusterRadius = resultNodes.length === 1 ? 0 :&lt;br /&gt;
        Math.max(82, Math.min(132, resultNodes.length * 22));&lt;br /&gt;
      resultNodes.forEach(function (node, index) {&lt;br /&gt;
        var angle = -Math.PI / 2 + (Math.PI * 2 * index / resultNodes.length);&lt;br /&gt;
        node.x = Math.cos(angle) * clusterRadius;&lt;br /&gt;
        node.y = Math.sin(angle) * clusterRadius;&lt;br /&gt;
        node.homeX = node.x;&lt;br /&gt;
        node.homeY = node.y;&lt;br /&gt;
        node.vx = 0;&lt;br /&gt;
        node.vy = 0;&lt;br /&gt;
      });&lt;br /&gt;
      searchCluster = {&lt;br /&gt;
        query: query,&lt;br /&gt;
        nodes: resultNodes,&lt;br /&gt;
        x: 0,&lt;br /&gt;
        y: 0,&lt;br /&gt;
        radius: clusterRadius&lt;br /&gt;
      };&lt;br /&gt;
      activeRelation = &#039;all&#039;;&lt;br /&gt;
      filter.value = &#039;all&#039;;&lt;br /&gt;
      clearSearch.hidden = false;&lt;br /&gt;
      transform.scale = Math.max(0.72, Math.min(&lt;br /&gt;
        1.35,&lt;br /&gt;
        Math.min(size.width, size.height) / Math.max(360, clusterRadius * 2 + 150)&lt;br /&gt;
      ));&lt;br /&gt;
      transform.x = size.width / 2;&lt;br /&gt;
      transform.y = size.height / 2;&lt;br /&gt;
      selectNode(resultNodes[0]);&lt;br /&gt;
      updateCounts();&lt;br /&gt;
      draw();&lt;br /&gt;
      viewport.scrollIntoView({ behavior: &#039;smooth&#039;, block: &#039;center&#039; });&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function connectionSummary(node) {&lt;br /&gt;
      var connected = edges.filter(function (edge) {&lt;br /&gt;
        return edge.source === node || edge.target === node;&lt;br /&gt;
      });&lt;br /&gt;
      var summary = Object.create(null);&lt;br /&gt;
      connected.forEach(function (edge) {&lt;br /&gt;
        summary[edge.relation.label] = (summary[edge.relation.label] || 0) + 1;&lt;br /&gt;
      });&lt;br /&gt;
      return { edges: connected, counts: summary };&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function showInspector(node) {&lt;br /&gt;
      inspector.replaceChildren();&lt;br /&gt;
      if (!node) {&lt;br /&gt;
        var welcome = makeElement(&#039;div&#039;, &#039;s3rm-inspector-welcome&#039;);&lt;br /&gt;
        welcome.appendChild(makeElement(&#039;span&#039;, &#039;s3rm-inspector-symbol&#039;, &#039;⌁&#039;));&lt;br /&gt;
        welcome.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;메모를 선택하세요&#039;));&lt;br /&gt;
        welcome.appendChild(makeElement(&#039;p&#039;, &#039;&#039;, &#039;점 하나를 누르면 메모 정보와 연결된 기록을 보여 드립니다.&#039;));&lt;br /&gt;
        inspector.appendChild(welcome);&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      var summary = connectionSummary(node);&lt;br /&gt;
      inspector.appendChild(makeElement(&lt;br /&gt;
        &#039;div&#039;,&lt;br /&gt;
        &#039;s3rm-inspector-kicker&#039;,&lt;br /&gt;
        node.isSearchResult ? &#039;검색 결과 &#039; + node.searchRank : &#039;선택한 메모&#039;&lt;br /&gt;
      ));&lt;br /&gt;
      inspector.appendChild(makeElement(&#039;h3&#039;, &#039;&#039;, node.title));&lt;br /&gt;
      var meta = makeElement(&#039;div&#039;, &#039;s3rm-inspector-meta&#039;);&lt;br /&gt;
      meta.appendChild(makeElement(&#039;span&#039;, &#039;s3rm-confidence s3rm-confidence-&#039; + node.confidence, confidenceLabel(node.confidence)));&lt;br /&gt;
      meta.appendChild(makeElement(&#039;span&#039;, &#039;&#039;, summary.edges.length + &#039;개 연결&#039;));&lt;br /&gt;
      inspector.appendChild(meta);&lt;br /&gt;
      if (Object.keys(summary.counts).length) {&lt;br /&gt;
        var relationList = makeElement(&#039;ul&#039;, &#039;s3rm-inspector-relations&#039;);&lt;br /&gt;
        Object.keys(summary.counts).forEach(function (label) {&lt;br /&gt;
          var item = makeElement(&#039;li&#039;, &#039;&#039;);&lt;br /&gt;
          item.append(makeElement(&#039;span&#039;, &#039;&#039;, label), makeElement(&#039;strong&#039;, &#039;&#039;, String(summary.counts[label])));&lt;br /&gt;
          relationList.appendChild(item);&lt;br /&gt;
        });&lt;br /&gt;
        inspector.appendChild(relationList);&lt;br /&gt;
      } else {&lt;br /&gt;
        inspector.appendChild(makeElement(&#039;p&#039;, &#039;s3rm-inspector-muted&#039;, &#039;아직 다른 메모와 연결되지 않았습니다.&#039;));&lt;br /&gt;
      }&lt;br /&gt;
      var open = makeElement(&#039;a&#039;, &#039;s3rm-graph-primary-link&#039;, &#039;메모 열기&#039;);&lt;br /&gt;
      open.href = mw.util.getUrl(node.id);&lt;br /&gt;
      inspector.appendChild(open);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function canvasPoint(event) {&lt;br /&gt;
      var rect = canvas.getBoundingClientRect();&lt;br /&gt;
      return { x: event.clientX - rect.left, y: event.clientY - rect.top };&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function worldPoint(event) {&lt;br /&gt;
      var point = canvasPoint(event);&lt;br /&gt;
      return {&lt;br /&gt;
        x: (point.x - transform.x) / transform.scale,&lt;br /&gt;
        y: (point.y - transform.y) / transform.scale&lt;br /&gt;
      };&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function hitNode(event) {&lt;br /&gt;
      var point = worldPoint(event);&lt;br /&gt;
      for (var i = nodes.length - 1; i &amp;gt;= 0; i -= 1) {&lt;br /&gt;
        var node = nodes[i];&lt;br /&gt;
        var dx = point.x - node.x;&lt;br /&gt;
        var dy = point.y - node.y;&lt;br /&gt;
        if (dx * dx + dy * dy &amp;lt;= Math.pow(nodeRadius(node) + 6 / transform.scale, 2)) {&lt;br /&gt;
          return node;&lt;br /&gt;
        }&lt;br /&gt;
      }&lt;br /&gt;
      return null;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function selectNode(node) {&lt;br /&gt;
      selected = node;&lt;br /&gt;
      showInspector(node);&lt;br /&gt;
      draw();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    canvas.addEventListener(&#039;pointerdown&#039;, function (event) {&lt;br /&gt;
      dragging = hitNode(event);&lt;br /&gt;
      panning = !dragging;&lt;br /&gt;
      pointerStart = canvasPoint(event);&lt;br /&gt;
      if (dragging) {&lt;br /&gt;
        var world = worldPoint(event);&lt;br /&gt;
        dragMotion = {&lt;br /&gt;
          x: world.x,&lt;br /&gt;
          y: world.y,&lt;br /&gt;
          time: event.timeStamp,&lt;br /&gt;
          vx: 0,&lt;br /&gt;
          vy: 0&lt;br /&gt;
        };&lt;br /&gt;
        dragging.motionActive = true;&lt;br /&gt;
        startSimulation(120, false);&lt;br /&gt;
      }&lt;br /&gt;
      canvas.setPointerCapture(event.pointerId);&lt;br /&gt;
      canvas.classList.add(&#039;is-dragging&#039;);&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;pointermove&#039;, function (event) {&lt;br /&gt;
      if (dragging) {&lt;br /&gt;
        var world = worldPoint(event);&lt;br /&gt;
        if (dragMotion) {&lt;br /&gt;
          var elapsed = Math.max(8, event.timeStamp - dragMotion.time);&lt;br /&gt;
          var frameRatio = 16.67 / elapsed;&lt;br /&gt;
          var instantVX = (world.x - dragMotion.x) * frameRatio;&lt;br /&gt;
          var instantVY = (world.y - dragMotion.y) * frameRatio;&lt;br /&gt;
          dragMotion.vx = dragMotion.vx * 0.45 + instantVX * 0.55;&lt;br /&gt;
          dragMotion.vy = dragMotion.vy * 0.45 + instantVY * 0.55;&lt;br /&gt;
          var speed = Math.sqrt(dragMotion.vx * dragMotion.vx + dragMotion.vy * dragMotion.vy);&lt;br /&gt;
          if (speed &amp;gt; 14) {&lt;br /&gt;
            dragMotion.vx = dragMotion.vx / speed * 14;&lt;br /&gt;
            dragMotion.vy = dragMotion.vy / speed * 14;&lt;br /&gt;
          }&lt;br /&gt;
          dragMotion.x = world.x;&lt;br /&gt;
          dragMotion.y = world.y;&lt;br /&gt;
          dragMotion.time = event.timeStamp;&lt;br /&gt;
        }&lt;br /&gt;
        dragging.x = world.x;&lt;br /&gt;
        dragging.y = world.y;&lt;br /&gt;
        startSimulation(120, false);&lt;br /&gt;
      } else if (panning &amp;amp;&amp;amp; pointerStart) {&lt;br /&gt;
        var point = canvasPoint(event);&lt;br /&gt;
        transform.x += point.x - pointerStart.x;&lt;br /&gt;
        transform.y += point.y - pointerStart.y;&lt;br /&gt;
        pointerStart = point;&lt;br /&gt;
      } else {&lt;br /&gt;
        hovered = hitNode(event);&lt;br /&gt;
        canvas.style.cursor = hovered ? &#039;pointer&#039; : &#039;grab&#039;;&lt;br /&gt;
      }&lt;br /&gt;
      draw();&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;pointerup&#039;, function (event) {&lt;br /&gt;
      var hit = hitNode(event);&lt;br /&gt;
      var releasedNode = dragging;&lt;br /&gt;
      if (releasedNode &amp;amp;&amp;amp; dragMotion) {&lt;br /&gt;
        releasedNode.vx = reduceMotion ? 0 : dragMotion.vx;&lt;br /&gt;
        releasedNode.vy = reduceMotion ? 0 : dragMotion.vy;&lt;br /&gt;
      }&lt;br /&gt;
      dragging = null;&lt;br /&gt;
      panning = false;&lt;br /&gt;
      pointerStart = null;&lt;br /&gt;
      dragMotion = null;&lt;br /&gt;
      canvas.classList.remove(&#039;is-dragging&#039;);&lt;br /&gt;
      if (releasedNode) {&lt;br /&gt;
        if (hit === releasedNode) {&lt;br /&gt;
          selectNode(releasedNode);&lt;br /&gt;
        }&lt;br /&gt;
        startSimulation(140, false);&lt;br /&gt;
      }&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;pointercancel&#039;, function () {&lt;br /&gt;
      if (dragging) {&lt;br /&gt;
        dragging.vx = 0;&lt;br /&gt;
        dragging.vy = 0;&lt;br /&gt;
        delete dragging.motionActive;&lt;br /&gt;
      }&lt;br /&gt;
      dragging = null;&lt;br /&gt;
      panning = false;&lt;br /&gt;
      pointerStart = null;&lt;br /&gt;
      dragMotion = null;&lt;br /&gt;
      canvas.classList.remove(&#039;is-dragging&#039;);&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;dblclick&#039;, function (event) {&lt;br /&gt;
      var node = hitNode(event);&lt;br /&gt;
      if (node) {&lt;br /&gt;
        window.location.href = mw.util.getUrl(node.id);&lt;br /&gt;
      }&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;wheel&#039;, function (event) {&lt;br /&gt;
      event.preventDefault();&lt;br /&gt;
      var point = canvasPoint(event);&lt;br /&gt;
      var oldScale = transform.scale;&lt;br /&gt;
      var nextScale = Math.max(0.25, Math.min(3.5, oldScale * Math.exp(-event.deltaY * 0.0012)));&lt;br /&gt;
      transform.x = point.x - ((point.x - transform.x) / oldScale) * nextScale;&lt;br /&gt;
      transform.y = point.y - ((point.y - transform.y) / oldScale) * nextScale;&lt;br /&gt;
      transform.scale = nextScale;&lt;br /&gt;
      draw();&lt;br /&gt;
    }, { passive: false });&lt;br /&gt;
&lt;br /&gt;
    search.addEventListener(&#039;input&#039;, function () {&lt;br /&gt;
      var query = search.value.trim().toLocaleLowerCase(&#039;ko&#039;);&lt;br /&gt;
      searchResults.replaceChildren();&lt;br /&gt;
      if (!query) {&lt;br /&gt;
        searchResults.hidden = true;&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      var matches = nodes.filter(function (node) {&lt;br /&gt;
        return node.title.toLocaleLowerCase(&#039;ko&#039;).includes(query) || node.id.toLowerCase().includes(query);&lt;br /&gt;
      }).slice(0, 6);&lt;br /&gt;
      matches.forEach(function (node) {&lt;br /&gt;
        var button = makeElement(&#039;button&#039;, &#039;&#039;, node.title);&lt;br /&gt;
        button.type = &#039;button&#039;;&lt;br /&gt;
        button.addEventListener(&#039;click&#039;, function () {&lt;br /&gt;
          selectNode(node);&lt;br /&gt;
          transform.x = size.width / 2 - node.x * transform.scale;&lt;br /&gt;
          transform.y = size.height / 2 - node.y * transform.scale;&lt;br /&gt;
          search.value = node.title;&lt;br /&gt;
          searchResults.hidden = true;&lt;br /&gt;
          draw();&lt;br /&gt;
        });&lt;br /&gt;
        searchResults.appendChild(button);&lt;br /&gt;
      });&lt;br /&gt;
      if (!matches.length) {&lt;br /&gt;
        searchResults.appendChild(makeElement(&#039;span&#039;, &#039;&#039;, &#039;일치하는 메모가 없습니다.&#039;));&lt;br /&gt;
      }&lt;br /&gt;
      searchResults.hidden = false;&lt;br /&gt;
    });&lt;br /&gt;
    search.addEventListener(&#039;keydown&#039;, function (event) {&lt;br /&gt;
      if (event.key === &#039;Escape&#039;) {&lt;br /&gt;
        searchResults.hidden = true;&lt;br /&gt;
      } else if (event.key === &#039;Enter&#039;) {&lt;br /&gt;
        var first = searchResults.querySelector(&#039;button&#039;);&lt;br /&gt;
        if (first) { first.click(); }&lt;br /&gt;
      }&lt;br /&gt;
    });&lt;br /&gt;
    filter.addEventListener(&#039;change&#039;, function () {&lt;br /&gt;
      activeRelation = filter.value;&lt;br /&gt;
      updateCounts();&lt;br /&gt;
      draw();&lt;br /&gt;
    });&lt;br /&gt;
    reset.addEventListener(&#039;click&#039;, fitGraph);&lt;br /&gt;
    clearSearch.addEventListener(&#039;click&#039;, function () {&lt;br /&gt;
      resetSearchCluster();&lt;br /&gt;
      fitGraph();&lt;br /&gt;
    });&lt;br /&gt;
&lt;br /&gt;
    showInspector(null);&lt;br /&gt;
    resize();&lt;br /&gt;
    var resizeObserver = window.ResizeObserver ? new ResizeObserver(resize) : null;&lt;br /&gt;
    if (resizeObserver) {&lt;br /&gt;
      resizeObserver.observe(viewport);&lt;br /&gt;
    } else {&lt;br /&gt;
      window.addEventListener(&#039;resize&#039;, resize);&lt;br /&gt;
    }&lt;br /&gt;
    graphSearchController = focusSearchResults;&lt;br /&gt;
    if (pendingGraphSearch) {&lt;br /&gt;
      focusSearchResults(pendingGraphSearch.items, pendingGraphSearch.query);&lt;br /&gt;
      pendingGraphSearch = null;&lt;br /&gt;
    }&lt;br /&gt;
    startSimulation(160, true);&lt;br /&gt;
    window.addEventListener(&#039;pagehide&#039;, function () {&lt;br /&gt;
      window.cancelAnimationFrame(animationFrame);&lt;br /&gt;
      if (resizeObserver) { resizeObserver.disconnect(); }&lt;br /&gt;
    }, { once: true });&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  var askQuery = &#039;[[Category:Lessons]]&#039; +&lt;br /&gt;
    &#039;|?Lesson title|?Confidence|?Updated at&#039; +&lt;br /&gt;
    RELATIONS.map(function (relation) { return &#039;|?&#039; + relation.property; }).join(&#039;&#039;) +&lt;br /&gt;
    &#039;|sort=Updated at|order=descending|limit=&#039; + NODE_LIMIT;&lt;br /&gt;
&lt;br /&gt;
  createSemanticSearch();&lt;br /&gt;
&lt;br /&gt;
  mw.loader.using([&#039;mediawiki.api&#039;, &#039;mediawiki.util&#039;]).then(function () {&lt;br /&gt;
    return new mw.Api().get({ action: &#039;ask&#039;, query: askQuery, formatversion: 2 });&lt;br /&gt;
  }).then(function (data) {&lt;br /&gt;
    var graph = parseGraph(data);&lt;br /&gt;
    if (!graph.nodes.length) {&lt;br /&gt;
      renderEmpty();&lt;br /&gt;
      return;&lt;br /&gt;
    }&lt;br /&gt;
    createGraph(graph);&lt;br /&gt;
  }).catch(renderError);&lt;br /&gt;
}());&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%EB%AF%B8%EB%94%94%EC%96%B4%EC%9C%84%ED%82%A4:Common.js&amp;diff=2744</id>
		<title>미디어위키:Common.js</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%EB%AF%B8%EB%94%94%EC%96%B4%EC%9C%84%ED%82%A4:Common.js&amp;diff=2744"/>
		<updated>2026-07-19T02:40:44Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 원본 스키마 동기화&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;(function () {&lt;br /&gt;
  &#039;use strict&#039;;&lt;br /&gt;
&lt;br /&gt;
  var root = document.getElementById(&#039;s3rm-knowledge-graph&#039;);&lt;br /&gt;
  var semanticRoot = document.getElementById(&#039;s3rm-semantic-search&#039;);&lt;br /&gt;
  if (!root || !window.mw) {&lt;br /&gt;
    return;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  var RELATIONS = [&lt;br /&gt;
    { property: &#039;Supports&#039;, key: &#039;supports&#039;, label: &#039;뒷받침함&#039;, color: &#039;#5f7ae6&#039; },&lt;br /&gt;
    { property: &#039;Contradicts&#039;, key: &#039;contradicts&#039;, label: &#039;상충함&#039;, color: &#039;#d0605b&#039; },&lt;br /&gt;
    { property: &#039;Analogous to&#039;, key: &#039;analogous_to&#039;, label: &#039;유사함&#039;, color: &#039;#a17bd7&#039; },&lt;br /&gt;
    { property: &#039;Failed because&#039;, key: &#039;failed_because&#039;, label: &#039;실패 원인&#039;, color: &#039;#dc8b45&#039; },&lt;br /&gt;
    { property: &#039;Works when&#039;, key: &#039;works_when&#039;, label: &#039;작동 조건&#039;, color: &#039;#43a27b&#039; },&lt;br /&gt;
    { property: &#039;Suggests&#039;, key: &#039;suggests&#039;, label: &#039;후속 제안&#039;, color: &#039;#3f9db7&#039; },&lt;br /&gt;
    { property: &#039;Supersedes&#039;, key: &#039;supersedes&#039;, label: &#039;대체함&#039;, color: &#039;#7d849e&#039; }&lt;br /&gt;
  ];&lt;br /&gt;
  var NODE_LIMIT = 160;&lt;br /&gt;
  var EDGE_LIMIT = 480;&lt;br /&gt;
  var mcpReady = null;&lt;br /&gt;
  var graphSearchController = null;&lt;br /&gt;
  var pendingGraphSearch = null;&lt;br /&gt;
&lt;br /&gt;
  function mcpRequest(payload, expectBody) {&lt;br /&gt;
    return window.fetch(&#039;/mcp&#039;, {&lt;br /&gt;
      method: &#039;POST&#039;,&lt;br /&gt;
      credentials: &#039;same-origin&#039;,&lt;br /&gt;
      headers: {&lt;br /&gt;
        &#039;Content-Type&#039;: &#039;application/json&#039;,&lt;br /&gt;
        &#039;Accept&#039;: &#039;application/json, text/event-stream&#039;&lt;br /&gt;
      },&lt;br /&gt;
      body: JSON.stringify(payload)&lt;br /&gt;
    }).then(function (response) {&lt;br /&gt;
      if (!response.ok) {&lt;br /&gt;
        throw new Error(&#039;MCP HTTP &#039; + response.status);&lt;br /&gt;
      }&lt;br /&gt;
      if (!expectBody) {&lt;br /&gt;
        return null;&lt;br /&gt;
      }&lt;br /&gt;
      return response.json();&lt;br /&gt;
    });&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function initializeMcp() {&lt;br /&gt;
    if (!mcpReady) {&lt;br /&gt;
      mcpReady = mcpRequest({&lt;br /&gt;
        jsonrpc: &#039;2.0&#039;,&lt;br /&gt;
        id: 1,&lt;br /&gt;
        method: &#039;initialize&#039;,&lt;br /&gt;
        params: {&lt;br /&gt;
          protocolVersion: &#039;2025-06-18&#039;,&lt;br /&gt;
          capabilities: {},&lt;br /&gt;
          clientInfo: { name: &#039;s3rm-wiki-search&#039;, version: &#039;1&#039; }&lt;br /&gt;
        }&lt;br /&gt;
      }, true).then(function (response) {&lt;br /&gt;
        if (!response || response.error) {&lt;br /&gt;
          throw new Error(&#039;MCP initialization failed&#039;);&lt;br /&gt;
        }&lt;br /&gt;
        return mcpRequest({&lt;br /&gt;
          jsonrpc: &#039;2.0&#039;,&lt;br /&gt;
          method: &#039;notifications/initialized&#039;,&lt;br /&gt;
          params: {}&lt;br /&gt;
        }, false);&lt;br /&gt;
      });&lt;br /&gt;
    }&lt;br /&gt;
    return mcpReady;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function searchSemantically(query) {&lt;br /&gt;
    return initializeMcp().then(function () {&lt;br /&gt;
      return mcpRequest({&lt;br /&gt;
        jsonrpc: &#039;2.0&#039;,&lt;br /&gt;
        id: 2,&lt;br /&gt;
        method: &#039;tools/call&#039;,&lt;br /&gt;
        params: {&lt;br /&gt;
          name: &#039;search_lessons&#039;,&lt;br /&gt;
          arguments: {&lt;br /&gt;
            query: query,&lt;br /&gt;
            retrieval_mode: &#039;hybrid_v1&#039;,&lt;br /&gt;
            fields: [&#039;title&#039;, &#039;question&#039;, &#039;reusable_lesson&#039;, &#039;confidence&#039;, &#039;relations&#039;],&lt;br /&gt;
            limit: 6&lt;br /&gt;
          }&lt;br /&gt;
        }&lt;br /&gt;
      }, true);&lt;br /&gt;
    }).then(function (response) {&lt;br /&gt;
      if (!response || response.error || !response.result || response.result.isError) {&lt;br /&gt;
        throw new Error(&#039;Semantic search failed&#039;);&lt;br /&gt;
      }&lt;br /&gt;
      return response.result.structuredContent || {};&lt;br /&gt;
    });&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function textValue(value) {&lt;br /&gt;
    if (value === null || value === undefined) {&lt;br /&gt;
      return &#039;&#039;;&lt;br /&gt;
    }&lt;br /&gt;
    if (typeof value === &#039;string&#039; || typeof value === &#039;number&#039;) {&lt;br /&gt;
      return String(value);&lt;br /&gt;
    }&lt;br /&gt;
    return String(value.fulltext || value.displaytitle || value.raw || value.timestamp || &#039;&#039;);&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function firstValue(values, fallback) {&lt;br /&gt;
    return values &amp;amp;&amp;amp; values.length ? textValue(values[0]) : fallback;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function confidenceLabel(confidence) {&lt;br /&gt;
    if (confidence === &#039;high&#039;) { return &#039;신뢰도: 높음&#039;; }&lt;br /&gt;
    if (confidence === &#039;medium&#039;) { return &#039;신뢰도: 중간&#039;; }&lt;br /&gt;
    return &#039;신뢰도: 낮음&#039;;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function parseGraph(data) {&lt;br /&gt;
    var rawResults = data &amp;amp;&amp;amp; data.query &amp;amp;&amp;amp; data.query.results ? data.query.results : {};&lt;br /&gt;
    var entries = Array.isArray(rawResults)&lt;br /&gt;
      ? rawResults.map(function (item) { return [item.fulltext, item]; })&lt;br /&gt;
      : Object.keys(rawResults).map(function (key) { return [key, rawResults[key]]; });&lt;br /&gt;
    var nodes = [];&lt;br /&gt;
    var byId = Object.create(null);&lt;br /&gt;
&lt;br /&gt;
    entries.slice(0, NODE_LIMIT).forEach(function (entry, index) {&lt;br /&gt;
      var id = entry[0];&lt;br /&gt;
      var result = entry[1] || {};&lt;br /&gt;
      var printouts = result.printouts || {};&lt;br /&gt;
      var angle = index * 2.399963229728653;&lt;br /&gt;
      var radius = 20 * Math.sqrt(index + 1);&lt;br /&gt;
      var node = {&lt;br /&gt;
        id: id,&lt;br /&gt;
        title: firstValue(printouts[&#039;Lesson title&#039;], id.replace(/^Lesson:/, &#039;&#039;)),&lt;br /&gt;
        confidence: firstValue(printouts.Confidence, &#039;low&#039;).toLowerCase(),&lt;br /&gt;
        updatedAt: firstValue(printouts[&#039;Updated at&#039;], &#039;&#039;),&lt;br /&gt;
        x: Math.cos(angle) * radius,&lt;br /&gt;
        y: Math.sin(angle) * radius,&lt;br /&gt;
        vx: 0,&lt;br /&gt;
        vy: 0,&lt;br /&gt;
        degree: 0,&lt;br /&gt;
        printouts: printouts&lt;br /&gt;
      };&lt;br /&gt;
      nodes.push(node);&lt;br /&gt;
      byId[id] = node;&lt;br /&gt;
    });&lt;br /&gt;
&lt;br /&gt;
    var edges = [];&lt;br /&gt;
    nodes.forEach(function (node) {&lt;br /&gt;
      RELATIONS.forEach(function (relation) {&lt;br /&gt;
        var targets = node.printouts[relation.property] || [];&lt;br /&gt;
        targets.forEach(function (target) {&lt;br /&gt;
          var targetId = textValue(target);&lt;br /&gt;
          if (edges.length &amp;lt; EDGE_LIMIT &amp;amp;&amp;amp; byId[targetId] &amp;amp;&amp;amp; targetId !== node.id) {&lt;br /&gt;
            edges.push({ source: node, target: byId[targetId], relation: relation });&lt;br /&gt;
            node.degree += 1;&lt;br /&gt;
            byId[targetId].degree += 1;&lt;br /&gt;
          }&lt;br /&gt;
        });&lt;br /&gt;
      });&lt;br /&gt;
      delete node.printouts;&lt;br /&gt;
    });&lt;br /&gt;
&lt;br /&gt;
    var connectedNodes = nodes.filter(function (node) { return node.degree &amp;gt; 0; });&lt;br /&gt;
    var isolatedNodes = nodes.filter(function (node) { return node.degree === 0; });&lt;br /&gt;
    connectedNodes.forEach(function (node, index) {&lt;br /&gt;
      var angle = index * 2.399963229728653;&lt;br /&gt;
      var radius = 16 * Math.sqrt(index + 1);&lt;br /&gt;
      node.x = Math.cos(angle) * radius;&lt;br /&gt;
      node.y = Math.sin(angle) * radius;&lt;br /&gt;
    });&lt;br /&gt;
    isolatedNodes.forEach(function (node, index) {&lt;br /&gt;
      var angle = index * 2.399963229728653 + 0.7;&lt;br /&gt;
      var radius = 92 + 10 * Math.sqrt(index + 1);&lt;br /&gt;
      node.x = Math.cos(angle) * radius;&lt;br /&gt;
      node.y = Math.sin(angle) * radius;&lt;br /&gt;
    });&lt;br /&gt;
    nodes.forEach(function (node) {&lt;br /&gt;
      node.homeX = node.x;&lt;br /&gt;
      node.homeY = node.y;&lt;br /&gt;
    });&lt;br /&gt;
    return { nodes: nodes, edges: edges };&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function makeElement(tag, className, text) {&lt;br /&gt;
    var element = document.createElement(tag);&lt;br /&gt;
    if (className) {&lt;br /&gt;
      element.className = className;&lt;br /&gt;
    }&lt;br /&gt;
    if (text !== undefined) {&lt;br /&gt;
      element.textContent = text;&lt;br /&gt;
    }&lt;br /&gt;
    return element;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function createSemanticSearch() {&lt;br /&gt;
    if (!semanticRoot) {&lt;br /&gt;
      return;&lt;br /&gt;
    }&lt;br /&gt;
    semanticRoot.replaceChildren();&lt;br /&gt;
    var intro = makeElement(&#039;div&#039;, &#039;s3rm-semantic-intro&#039;);&lt;br /&gt;
    intro.appendChild(makeElement(&#039;div&#039;, &#039;s3rm-semantic-icon&#039;, &#039;⌕&#039;));&lt;br /&gt;
    var introCopy = makeElement(&#039;div&#039;, &#039;&#039;);&lt;br /&gt;
    introCopy.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;검색&#039;));&lt;br /&gt;
    introCopy.appendChild(makeElement(&#039;p&#039;, &#039;&#039;, &#039;현상이나 궁금한 점을 문장으로 입력하세요. 표현이 달라도 관련 메모를 찾습니다.&#039;));&lt;br /&gt;
    intro.appendChild(introCopy);&lt;br /&gt;
&lt;br /&gt;
    var form = makeElement(&#039;form&#039;, &#039;s3rm-semantic-form&#039;);&lt;br /&gt;
    var input = makeElement(&#039;input&#039;, &#039;s3rm-semantic-input&#039;);&lt;br /&gt;
    input.type = &#039;search&#039;;&lt;br /&gt;
    input.name = &#039;query&#039;;&lt;br /&gt;
    input.maxLength = 500;&lt;br /&gt;
    input.placeholder = &#039;예: GPU 메모리는 남아 있는데 할당이 실패하는 이유는?&#039;;&lt;br /&gt;
    input.setAttribute(&#039;aria-label&#039;, &#039;메모 검색&#039;);&lt;br /&gt;
    var submit = makeElement(&#039;button&#039;, &#039;s3rm-semantic-submit&#039;, &#039;검색&#039;);&lt;br /&gt;
    submit.type = &#039;submit&#039;;&lt;br /&gt;
    form.append(input, submit);&lt;br /&gt;
&lt;br /&gt;
    var examples = makeElement(&#039;div&#039;, &#039;s3rm-semantic-examples&#039;);&lt;br /&gt;
    examples.appendChild(makeElement(&#039;span&#039;, &#039;&#039;, &#039;검색 예시&#039;));&lt;br /&gt;
    [&lt;br /&gt;
      &#039;재시도가 한꺼번에 몰리는 이유&#039;,&lt;br /&gt;
      &#039;GPU와 SSD 사이 병목을 줄이는 방법&#039;,&lt;br /&gt;
      &#039;긴 작업에서 지연이 갑자기 커지는 원인&#039;&lt;br /&gt;
    ].forEach(function (example) {&lt;br /&gt;
      var button = makeElement(&#039;button&#039;, &#039;&#039;, example);&lt;br /&gt;
      button.type = &#039;button&#039;;&lt;br /&gt;
      button.addEventListener(&#039;click&#039;, function () {&lt;br /&gt;
        input.value = example;&lt;br /&gt;
        form.requestSubmit();&lt;br /&gt;
      });&lt;br /&gt;
      examples.appendChild(button);&lt;br /&gt;
    });&lt;br /&gt;
&lt;br /&gt;
    var feedback = makeElement(&#039;div&#039;, &#039;s3rm-semantic-feedback&#039;);&lt;br /&gt;
    feedback.setAttribute(&#039;aria-live&#039;, &#039;polite&#039;);&lt;br /&gt;
    feedback.hidden = true;&lt;br /&gt;
    semanticRoot.append(intro, form, examples, feedback);&lt;br /&gt;
&lt;br /&gt;
    function renderSearchResults(payload, query) {&lt;br /&gt;
      var items = Array.isArray(payload.items) ? payload.items : [];&lt;br /&gt;
      if (graphSearchController) {&lt;br /&gt;
        graphSearchController(items, query);&lt;br /&gt;
      } else {&lt;br /&gt;
        pendingGraphSearch = { items: items, query: query };&lt;br /&gt;
      }&lt;br /&gt;
      feedback.replaceChildren();&lt;br /&gt;
      feedback.hidden = false;&lt;br /&gt;
      var resultHeader = makeElement(&#039;div&#039;, &#039;s3rm-semantic-result-header&#039;);&lt;br /&gt;
      resultHeader.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;“&#039; + query + &#039;” 검색 결과&#039;));&lt;br /&gt;
      resultHeader.appendChild(makeElement(&#039;span&#039;, &#039;s3rm-semantic-mode&#039;, items.length + &#039;개 메모&#039;));&lt;br /&gt;
      feedback.appendChild(resultHeader);&lt;br /&gt;
&lt;br /&gt;
      if (!items.length) {&lt;br /&gt;
        feedback.appendChild(makeElement(&#039;p&#039;, &#039;s3rm-semantic-empty&#039;, &#039;가까운 메모를 찾지 못했습니다. 표현을 조금 바꿔 다시 검색해 보세요.&#039;));&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      feedback.appendChild(makeElement(&#039;p&#039;, &#039;s3rm-semantic-graph-note&#039;, &#039;아래 그래프에 검색 결과를 모았습니다. 점을 선택하면 메모와 연결을 볼 수 있습니다.&#039;));&lt;br /&gt;
      var list = makeElement(&#039;div&#039;, &#039;s3rm-semantic-result-links&#039;);&lt;br /&gt;
      items.forEach(function (item) {&lt;br /&gt;
        var link = makeElement(&#039;a&#039;, &#039;&#039;, item.title || String(item.id).replace(/^Lesson:/, &#039;&#039;));&lt;br /&gt;
        link.href = mw.util.getUrl(item.id);&lt;br /&gt;
        list.appendChild(link);&lt;br /&gt;
      });&lt;br /&gt;
      feedback.appendChild(list);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    form.addEventListener(&#039;submit&#039;, function (event) {&lt;br /&gt;
      event.preventDefault();&lt;br /&gt;
      var query = input.value.trim();&lt;br /&gt;
      if (query.length &amp;lt; 2) {&lt;br /&gt;
        feedback.replaceChildren(makeElement(&#039;p&#039;, &#039;s3rm-semantic-error&#039;, &#039;두 글자 이상 입력해 주세요.&#039;));&lt;br /&gt;
        feedback.hidden = false;&lt;br /&gt;
        input.focus();&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      submit.disabled = true;&lt;br /&gt;
      submit.textContent = &#039;찾는 중…&#039;;&lt;br /&gt;
      feedback.replaceChildren(makeElement(&#039;div&#039;, &#039;s3rm-semantic-searching&#039;, &#039;관련 메모를 찾고 있습니다.&#039;));&lt;br /&gt;
      feedback.hidden = false;&lt;br /&gt;
      searchSemantically(query).then(function (payload) {&lt;br /&gt;
        renderSearchResults(payload, query);&lt;br /&gt;
      }).catch(function () {&lt;br /&gt;
        feedback.replaceChildren();&lt;br /&gt;
        var error = makeElement(&#039;div&#039;, &#039;s3rm-semantic-error&#039;);&lt;br /&gt;
        error.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;지금은 검색을 사용할 수 없습니다.&#039;));&lt;br /&gt;
        var fallback = makeElement(&#039;a&#039;, &#039;&#039;, &#039;검색 열기&#039;);&lt;br /&gt;
        fallback.href = mw.util.getUrl(&#039;Special:Search&#039;, { search: query });&lt;br /&gt;
        error.appendChild(fallback);&lt;br /&gt;
        feedback.appendChild(error);&lt;br /&gt;
      }).finally(function () {&lt;br /&gt;
        submit.disabled = false;&lt;br /&gt;
        submit.textContent = &#039;검색&#039;;&lt;br /&gt;
      });&lt;br /&gt;
    });&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function renderEmpty() {&lt;br /&gt;
    root.replaceChildren();&lt;br /&gt;
    var empty = makeElement(&#039;div&#039;, &#039;s3rm-graph-empty&#039;);&lt;br /&gt;
    empty.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;아직 연결할 메모가 없습니다&#039;));&lt;br /&gt;
    empty.appendChild(makeElement(&#039;p&#039;, &#039;&#039;, &#039;첫 메모를 남기면 이곳에서 지식의 연결을 볼 수 있습니다.&#039;));&lt;br /&gt;
    var link = makeElement(&#039;a&#039;, &#039;s3rm-graph-primary-link&#039;, &#039;첫 메모 작성하기&#039;);&lt;br /&gt;
    link.href = &#039;#quick-start&#039;;&lt;br /&gt;
    empty.appendChild(link);&lt;br /&gt;
    root.appendChild(empty);&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function renderError() {&lt;br /&gt;
    root.replaceChildren();&lt;br /&gt;
    var fallback = makeElement(&#039;div&#039;, &#039;s3rm-graph-empty&#039;);&lt;br /&gt;
    fallback.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;그래프를 불러오지 못했습니다&#039;));&lt;br /&gt;
    fallback.appendChild(makeElement(&#039;p&#039;, &#039;&#039;, &#039;최근 메모와 검색은 계속 사용할 수 있습니다. 잠시 뒤 다시 시도해 주세요.&#039;));&lt;br /&gt;
    var link = makeElement(&#039;a&#039;, &#039;s3rm-graph-primary-link&#039;, &#039;전체 메모 보기&#039;);&lt;br /&gt;
    link.href = mw.util.getUrl(&#039;Special:AllPages/Lesson:&#039;);&lt;br /&gt;
    fallback.appendChild(link);&lt;br /&gt;
    root.appendChild(fallback);&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function createGraph(graph) {&lt;br /&gt;
    var nodes = graph.nodes;&lt;br /&gt;
    var edges = graph.edges;&lt;br /&gt;
    var byId = Object.create(null);&lt;br /&gt;
    function canonicalId(id) {&lt;br /&gt;
      return String(id || &#039;&#039;).replace(/_/g, &#039; &#039;).toLocaleLowerCase(&#039;en&#039;);&lt;br /&gt;
    }&lt;br /&gt;
    nodes.forEach(function (node) { byId[canonicalId(node.id)] = node; });&lt;br /&gt;
    var selected = null;&lt;br /&gt;
    var hovered = null;&lt;br /&gt;
    var searchCluster = null;&lt;br /&gt;
    var activeRelation = &#039;all&#039;;&lt;br /&gt;
    var transform = { x: 0, y: 0, scale: 1 };&lt;br /&gt;
    var dragging = null;&lt;br /&gt;
    var panning = false;&lt;br /&gt;
    var pointerStart = null;&lt;br /&gt;
    var dragMotion = null;&lt;br /&gt;
    var animationFrame = 0;&lt;br /&gt;
    var simulationFrames = 0;&lt;br /&gt;
    var simulationRunning = false;&lt;br /&gt;
    var fitAfterSimulation = true;&lt;br /&gt;
    var reduceMotion = window.matchMedia &amp;amp;&amp;amp; window.matchMedia(&#039;(prefers-reduced-motion: reduce)&#039;).matches;&lt;br /&gt;
&lt;br /&gt;
    root.replaceChildren();&lt;br /&gt;
    var toolbar = makeElement(&#039;div&#039;, &#039;s3rm-graph-toolbar&#039;);&lt;br /&gt;
    var searchWrap = makeElement(&#039;div&#039;, &#039;s3rm-graph-search&#039;);&lt;br /&gt;
    var search = makeElement(&#039;input&#039;, &#039;s3rm-graph-search-input&#039;);&lt;br /&gt;
    search.type = &#039;search&#039;;&lt;br /&gt;
    search.placeholder = &#039;그래프에서 제목 필터&#039;;&lt;br /&gt;
    search.setAttribute(&#039;aria-label&#039;, &#039;그래프 제목 필터&#039;);&lt;br /&gt;
    search.autocomplete = &#039;off&#039;;&lt;br /&gt;
    var searchResults = makeElement(&#039;div&#039;, &#039;s3rm-graph-search-results&#039;);&lt;br /&gt;
    searchResults.hidden = true;&lt;br /&gt;
    searchWrap.append(search, searchResults);&lt;br /&gt;
&lt;br /&gt;
    var filter = makeElement(&#039;select&#039;, &#039;s3rm-graph-filter&#039;);&lt;br /&gt;
    filter.setAttribute(&#039;aria-label&#039;, &#039;관계 유형 필터&#039;);&lt;br /&gt;
    var allOption = makeElement(&#039;option&#039;, &#039;&#039;, &#039;모든 관계&#039;);&lt;br /&gt;
    allOption.value = &#039;all&#039;;&lt;br /&gt;
    filter.appendChild(allOption);&lt;br /&gt;
    RELATIONS.forEach(function (relation) {&lt;br /&gt;
      var option = makeElement(&#039;option&#039;, &#039;&#039;, relation.label);&lt;br /&gt;
      option.value = relation.key;&lt;br /&gt;
      filter.appendChild(option);&lt;br /&gt;
    });&lt;br /&gt;
    var reset = makeElement(&#039;button&#039;, &#039;s3rm-graph-reset&#039;, &#039;화면 맞춤&#039;);&lt;br /&gt;
    reset.type = &#039;button&#039;;&lt;br /&gt;
    var clearSearch = makeElement(&#039;button&#039;, &#039;s3rm-graph-clear-search&#039;, &#039;검색 결과 해제&#039;);&lt;br /&gt;
    clearSearch.type = &#039;button&#039;;&lt;br /&gt;
    clearSearch.hidden = true;&lt;br /&gt;
    toolbar.append(searchWrap, filter, reset, clearSearch);&lt;br /&gt;
&lt;br /&gt;
    var body = makeElement(&#039;div&#039;, &#039;s3rm-graph-body&#039;);&lt;br /&gt;
    var viewport = makeElement(&#039;div&#039;, &#039;s3rm-graph-viewport&#039;);&lt;br /&gt;
    var canvas = document.createElement(&#039;canvas&#039;);&lt;br /&gt;
    canvas.className = &#039;s3rm-graph-canvas&#039;;&lt;br /&gt;
    canvas.setAttribute(&#039;aria-hidden&#039;, &#039;true&#039;);&lt;br /&gt;
    viewport.appendChild(canvas);&lt;br /&gt;
    var hint = makeElement(&#039;div&#039;, &#039;s3rm-graph-hint&#039;, &#039;드래그하여 이동 · 스크롤하여 확대 · 두 번 눌러 메모 열기&#039;);&lt;br /&gt;
    viewport.appendChild(hint);&lt;br /&gt;
&lt;br /&gt;
    var inspector = makeElement(&#039;aside&#039;, &#039;s3rm-graph-inspector&#039;);&lt;br /&gt;
    inspector.setAttribute(&#039;aria-live&#039;, &#039;polite&#039;);&lt;br /&gt;
    body.append(viewport, inspector);&lt;br /&gt;
&lt;br /&gt;
    var footer = makeElement(&#039;div&#039;, &#039;s3rm-graph-footer&#039;);&lt;br /&gt;
    var counts = makeElement(&#039;span&#039;, &#039;s3rm-graph-counts&#039;, nodes.length + &#039;개 메모 · &#039; + edges.length + &#039;개 연결&#039;);&lt;br /&gt;
    var legend = makeElement(&#039;div&#039;, &#039;s3rm-graph-legend&#039;);&lt;br /&gt;
    RELATIONS.slice(0, 4).forEach(function (relation) {&lt;br /&gt;
      var item = makeElement(&#039;span&#039;, &#039;s3rm-graph-legend-item&#039;);&lt;br /&gt;
      var swatch = makeElement(&#039;i&#039;, &#039;&#039;);&lt;br /&gt;
      swatch.style.backgroundColor = relation.color;&lt;br /&gt;
      item.append(swatch, document.createTextNode(relation.label));&lt;br /&gt;
      legend.appendChild(item);&lt;br /&gt;
    });&lt;br /&gt;
    footer.append(counts, legend);&lt;br /&gt;
    root.append(toolbar, body, footer);&lt;br /&gt;
&lt;br /&gt;
    var context = canvas.getContext(&#039;2d&#039;);&lt;br /&gt;
    var size = { width: 0, height: 0, ratio: 1 };&lt;br /&gt;
&lt;br /&gt;
    function visibleEdges() {&lt;br /&gt;
      return activeRelation === &#039;all&#039;&lt;br /&gt;
        ? edges&lt;br /&gt;
        : edges.filter(function (edge) { return edge.relation.key === activeRelation; });&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function nodeRadius(node) {&lt;br /&gt;
      return 6 + Math.min(8, Math.sqrt(node.degree) * 2.2) + (node.isSearchResult ? 3 : 0);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function confidenceColor(confidence) {&lt;br /&gt;
      if (confidence === &#039;high&#039;) { return &#039;#53c18b&#039;; }&lt;br /&gt;
      if (confidence === &#039;medium&#039;) { return &#039;#8ca8ff&#039;; }&lt;br /&gt;
      return &#039;#c0c5d8&#039;;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function resize() {&lt;br /&gt;
      var rect = viewport.getBoundingClientRect();&lt;br /&gt;
      size.width = Math.max(320, rect.width);&lt;br /&gt;
      size.height = Math.max(360, rect.height);&lt;br /&gt;
      size.ratio = Math.min(window.devicePixelRatio || 1, 2);&lt;br /&gt;
      canvas.width = Math.round(size.width * size.ratio);&lt;br /&gt;
      canvas.height = Math.round(size.height * size.ratio);&lt;br /&gt;
      canvas.style.width = size.width + &#039;px&#039;;&lt;br /&gt;
      canvas.style.height = size.height + &#039;px&#039;;&lt;br /&gt;
      draw();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function fitGraph() {&lt;br /&gt;
      if (!nodes.length) { return; }&lt;br /&gt;
      var xs = nodes.map(function (node) { return node.x; });&lt;br /&gt;
      var ys = nodes.map(function (node) { return node.y; });&lt;br /&gt;
      var minX = Math.min.apply(null, xs) - 38;&lt;br /&gt;
      var maxX = Math.max.apply(null, xs) + 38;&lt;br /&gt;
      var minY = Math.min.apply(null, ys) - 38;&lt;br /&gt;
      var maxY = Math.max.apply(null, ys) + 38;&lt;br /&gt;
      transform.scale = Math.max(0.2, Math.min(1.25, Math.min(&lt;br /&gt;
        size.width / Math.max(1, maxX - minX),&lt;br /&gt;
        size.height / Math.max(1, maxY - minY)&lt;br /&gt;
      )));&lt;br /&gt;
      transform.x = size.width / 2 - ((minX + maxX) / 2) * transform.scale;&lt;br /&gt;
      transform.y = size.height / 2 - ((minY + maxY) / 2) * transform.scale;&lt;br /&gt;
      draw();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function draw() {&lt;br /&gt;
      context.setTransform(size.ratio, 0, 0, size.ratio, 0, 0);&lt;br /&gt;
      context.clearRect(0, 0, size.width, size.height);&lt;br /&gt;
      context.save();&lt;br /&gt;
      context.translate(transform.x, transform.y);&lt;br /&gt;
      context.scale(transform.scale, transform.scale);&lt;br /&gt;
&lt;br /&gt;
      if (searchCluster) {&lt;br /&gt;
        context.beginPath();&lt;br /&gt;
        context.arc(&lt;br /&gt;
          searchCluster.x,&lt;br /&gt;
          searchCluster.y,&lt;br /&gt;
          searchCluster.radius + 48,&lt;br /&gt;
          0,&lt;br /&gt;
          Math.PI * 2&lt;br /&gt;
        );&lt;br /&gt;
        context.fillStyle = &#039;rgba(104, 126, 233, 0.12)&#039;;&lt;br /&gt;
        context.fill();&lt;br /&gt;
        context.setLineDash([7 / transform.scale, 6 / transform.scale]);&lt;br /&gt;
        context.lineWidth = 1.5 / transform.scale;&lt;br /&gt;
        context.strokeStyle = &#039;rgba(140, 168, 255, 0.75)&#039;;&lt;br /&gt;
        context.stroke();&lt;br /&gt;
        context.setLineDash([]);&lt;br /&gt;
        context.font = &#039;700 &#039; + (12 / transform.scale) + &#039;px sans-serif&#039;;&lt;br /&gt;
        context.textAlign = &#039;center&#039;;&lt;br /&gt;
        context.textBaseline = &#039;bottom&#039;;&lt;br /&gt;
        context.fillStyle = &#039;#b9c7ff&#039;;&lt;br /&gt;
        context.fillText(&lt;br /&gt;
          &#039;검색 결과 · &#039; + searchCluster.nodes.length + &#039;개 메모&#039;,&lt;br /&gt;
          searchCluster.x,&lt;br /&gt;
          searchCluster.y - searchCluster.radius - 58 / transform.scale&lt;br /&gt;
        );&lt;br /&gt;
      }&lt;br /&gt;
&lt;br /&gt;
      visibleEdges().forEach(function (edge) {&lt;br /&gt;
        var emphasized = selected &amp;amp;&amp;amp; (edge.source === selected || edge.target === selected);&lt;br /&gt;
        var searchEdge = searchCluster &amp;amp;&amp;amp;&lt;br /&gt;
          (edge.source.isSearchResult || edge.target.isSearchResult);&lt;br /&gt;
        context.globalAlpha = searchCluster &amp;amp;&amp;amp; !searchEdge ? 0.1 : 1;&lt;br /&gt;
        context.beginPath();&lt;br /&gt;
        context.moveTo(edge.source.x, edge.source.y);&lt;br /&gt;
        context.lineTo(edge.target.x, edge.target.y);&lt;br /&gt;
        context.strokeStyle = emphasized ? edge.relation.color : edge.relation.color + &#039;55&#039;;&lt;br /&gt;
        context.lineWidth = (emphasized || searchEdge ? 2.2 : 1) / transform.scale;&lt;br /&gt;
        context.stroke();&lt;br /&gt;
      });&lt;br /&gt;
      context.globalAlpha = 1;&lt;br /&gt;
&lt;br /&gt;
      nodes.forEach(function (node) {&lt;br /&gt;
        var isSelected = node === selected;&lt;br /&gt;
        var isHovered = node === hovered;&lt;br /&gt;
        var isSearchResult = Boolean(node.isSearchResult);&lt;br /&gt;
        var radius = nodeRadius(node);&lt;br /&gt;
        context.globalAlpha = searchCluster &amp;amp;&amp;amp; !isSearchResult ? 0.12 : 1;&lt;br /&gt;
        context.beginPath();&lt;br /&gt;
        context.arc(node.x, node.y, radius + (isSelected ? 4 : 0), 0, Math.PI * 2);&lt;br /&gt;
        context.fillStyle = isSelected || isSearchResult ? &#039;#ffffff&#039; : confidenceColor(node.confidence);&lt;br /&gt;
        context.fill();&lt;br /&gt;
        context.lineWidth = (isSelected || isSearchResult ? 3 : 1.5) / transform.scale;&lt;br /&gt;
        context.strokeStyle = isSelected || isSearchResult ? &#039;#8ca8ff&#039; : &#039;#11182e&#039;;&lt;br /&gt;
        context.stroke();&lt;br /&gt;
&lt;br /&gt;
        if (isSelected || isHovered || isSearchResult ||&lt;br /&gt;
          (transform.scale &amp;gt; 0.68 &amp;amp;&amp;amp; node.degree &amp;gt; 0)) {&lt;br /&gt;
          context.font = (isSelected || isSearchResult ? &#039;600 &#039; : &#039;&#039;) + (12 / transform.scale) + &#039;px sans-serif&#039;;&lt;br /&gt;
          context.textAlign = &#039;center&#039;;&lt;br /&gt;
          context.textBaseline = &#039;top&#039;;&lt;br /&gt;
          context.fillStyle = &#039;#f5f7ff&#039;;&lt;br /&gt;
          context.shadowColor = &#039;#080b16&#039;;&lt;br /&gt;
          context.shadowBlur = 5 / transform.scale;&lt;br /&gt;
          var label = node.title.length &amp;gt; 26 ? node.title.slice(0, 25) + &#039;…&#039; : node.title;&lt;br /&gt;
          context.fillText(label, node.x, node.y + radius + 7 / transform.scale);&lt;br /&gt;
          context.shadowBlur = 0;&lt;br /&gt;
        }&lt;br /&gt;
      });&lt;br /&gt;
      context.globalAlpha = 1;&lt;br /&gt;
      context.restore();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function startSimulation(frames, shouldFit) {&lt;br /&gt;
      simulationFrames = Math.max(simulationFrames, reduceMotion ? 1 : frames);&lt;br /&gt;
      fitAfterSimulation = fitAfterSimulation || Boolean(shouldFit);&lt;br /&gt;
      if (!simulationRunning) {&lt;br /&gt;
        simulationRunning = true;&lt;br /&gt;
        animationFrame = window.requestAnimationFrame(simulate);&lt;br /&gt;
      }&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function simulate() {&lt;br /&gt;
      if (simulationFrames &amp;lt;= 0) {&lt;br /&gt;
        simulationRunning = false;&lt;br /&gt;
        if (fitAfterSimulation &amp;amp;&amp;amp; !searchCluster) {&lt;br /&gt;
          fitGraph();&lt;br /&gt;
        } else {&lt;br /&gt;
          draw();&lt;br /&gt;
        }&lt;br /&gt;
        fitAfterSimulation = false;&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
&lt;br /&gt;
      for (var i = 0; i &amp;lt; nodes.length; i += 1) {&lt;br /&gt;
        for (var j = i + 1; j &amp;lt; nodes.length; j += 1) {&lt;br /&gt;
          var first = nodes[i];&lt;br /&gt;
          var second = nodes[j];&lt;br /&gt;
          var isolatedPair = first.degree === 0 &amp;amp;&amp;amp; second.degree === 0;&lt;br /&gt;
          if (isolatedPair &amp;amp;&amp;amp; !first.motionActive &amp;amp;&amp;amp; !second.motionActive &amp;amp;&amp;amp;&lt;br /&gt;
            first !== dragging &amp;amp;&amp;amp; second !== dragging) {&lt;br /&gt;
            continue;&lt;br /&gt;
          }&lt;br /&gt;
          var dx = second.x - first.x || 0.01;&lt;br /&gt;
          var dy = second.y - first.y || 0.01;&lt;br /&gt;
          var distanceSquared = dx * dx + dy * dy;&lt;br /&gt;
          var distance = Math.sqrt(distanceSquared) || 0.01;&lt;br /&gt;
          var minimumDistance = nodeRadius(first) + nodeRadius(second) + 7;&lt;br /&gt;
          var force = distance &amp;lt; minimumDistance&lt;br /&gt;
            ? (minimumDistance - distance) * 0.035 / distance&lt;br /&gt;
            : 1.8 / Math.max(80, distanceSquared);&lt;br /&gt;
          if (first !== dragging) {&lt;br /&gt;
            first.vx -= dx * force;&lt;br /&gt;
            first.vy -= dy * force;&lt;br /&gt;
          }&lt;br /&gt;
          if (second !== dragging) {&lt;br /&gt;
            second.vx += dx * force;&lt;br /&gt;
            second.vy += dy * force;&lt;br /&gt;
          }&lt;br /&gt;
        }&lt;br /&gt;
      }&lt;br /&gt;
      edges.forEach(function (edge) {&lt;br /&gt;
        var dx = edge.target.x - edge.source.x;&lt;br /&gt;
        var dy = edge.target.y - edge.source.y;&lt;br /&gt;
        var distance = Math.sqrt(dx * dx + dy * dy) || 1;&lt;br /&gt;
        var force = (distance - 64) * 0.0014;&lt;br /&gt;
        if (edge.source !== dragging) {&lt;br /&gt;
          edge.source.vx += dx * force;&lt;br /&gt;
          edge.source.vy += dy * force;&lt;br /&gt;
        }&lt;br /&gt;
        if (edge.target !== dragging) {&lt;br /&gt;
          edge.target.vx -= dx * force;&lt;br /&gt;
          edge.target.vy -= dy * force;&lt;br /&gt;
        }&lt;br /&gt;
      });&lt;br /&gt;
      nodes.forEach(function (node) {&lt;br /&gt;
        if (node === dragging) {&lt;br /&gt;
          return;&lt;br /&gt;
        }&lt;br /&gt;
        if (node.isSearchResult &amp;amp;&amp;amp; !node.motionActive) {&lt;br /&gt;
          node.vx = 0;&lt;br /&gt;
          node.vy = 0;&lt;br /&gt;
          return;&lt;br /&gt;
        }&lt;br /&gt;
        if (node.degree === 0 &amp;amp;&amp;amp; !node.motionActive) {&lt;br /&gt;
          node.vx = 0;&lt;br /&gt;
          node.vy = 0;&lt;br /&gt;
          return;&lt;br /&gt;
        }&lt;br /&gt;
        if (node.degree === 0) {&lt;br /&gt;
          node.vx += (node.homeX - node.x) * 0.0022;&lt;br /&gt;
          node.vy += (node.homeY - node.y) * 0.0022;&lt;br /&gt;
        } else {&lt;br /&gt;
          node.vx += -node.x * 0.0018;&lt;br /&gt;
          node.vy += -node.y * 0.0018;&lt;br /&gt;
        }&lt;br /&gt;
        node.vx *= 0.9;&lt;br /&gt;
        node.vy *= 0.9;&lt;br /&gt;
        node.x += node.vx;&lt;br /&gt;
        node.y += node.vy;&lt;br /&gt;
        if (node.motionActive &amp;amp;&amp;amp; Math.abs(node.vx) + Math.abs(node.vy) &amp;lt; 0.025) {&lt;br /&gt;
          delete node.motionActive;&lt;br /&gt;
        }&lt;br /&gt;
      });&lt;br /&gt;
      simulationFrames -= 1;&lt;br /&gt;
      draw();&lt;br /&gt;
      animationFrame = window.requestAnimationFrame(simulate);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function relationDefinition(key) {&lt;br /&gt;
      return RELATIONS.find(function (relation) { return relation.key === key; });&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function updateCounts() {&lt;br /&gt;
      if (searchCluster) {&lt;br /&gt;
        var relatedEdges = edges.filter(function (edge) {&lt;br /&gt;
          return edge.source.isSearchResult || edge.target.isSearchResult;&lt;br /&gt;
        }).length;&lt;br /&gt;
        counts.textContent = &#039;검색 결과 &#039; + searchCluster.nodes.length + &#039;개 · &#039; +&lt;br /&gt;
          relatedEdges + &#039;개 연결&#039;;&lt;br /&gt;
      } else {&lt;br /&gt;
        counts.textContent = nodes.length + &#039;개 메모 · &#039; + visibleEdges().length + &#039;개 연결&#039;;&lt;br /&gt;
      }&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function resetSearchCluster() {&lt;br /&gt;
      var addedKeys = Object.create(null);&lt;br /&gt;
      edges.forEach(function (edge) {&lt;br /&gt;
        if (edge.searchAdded) {&lt;br /&gt;
          edge.source.degree = Math.max(0, edge.source.degree - 1);&lt;br /&gt;
          edge.target.degree = Math.max(0, edge.target.degree - 1);&lt;br /&gt;
        }&lt;br /&gt;
      });&lt;br /&gt;
      nodes.forEach(function (node) {&lt;br /&gt;
        if (node.searchAdded) {&lt;br /&gt;
          addedKeys[canonicalId(node.id)] = true;&lt;br /&gt;
          delete byId[canonicalId(node.id)];&lt;br /&gt;
        } else if (node.preSearchPosition) {&lt;br /&gt;
          node.x = node.preSearchPosition.x;&lt;br /&gt;
          node.y = node.preSearchPosition.y;&lt;br /&gt;
          node.homeX = node.preSearchHome.x;&lt;br /&gt;
          node.homeY = node.preSearchHome.y;&lt;br /&gt;
        }&lt;br /&gt;
        delete node.isSearchResult;&lt;br /&gt;
        delete node.preSearchPosition;&lt;br /&gt;
        delete node.preSearchHome;&lt;br /&gt;
        delete node.motionActive;&lt;br /&gt;
      });&lt;br /&gt;
      nodes = nodes.filter(function (node) { return !node.searchAdded; });&lt;br /&gt;
      edges = edges.filter(function (edge) {&lt;br /&gt;
        return !edge.searchAdded &amp;amp;&amp;amp;&lt;br /&gt;
          !addedKeys[canonicalId(edge.source.id)] &amp;amp;&amp;amp;&lt;br /&gt;
          !addedKeys[canonicalId(edge.target.id)];&lt;br /&gt;
      });&lt;br /&gt;
      searchCluster = null;&lt;br /&gt;
      clearSearch.hidden = true;&lt;br /&gt;
      selected = null;&lt;br /&gt;
      showInspector(null);&lt;br /&gt;
      updateCounts();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function focusSearchResults(items, query) {&lt;br /&gt;
      resetSearchCluster();&lt;br /&gt;
      if (!items.length) {&lt;br /&gt;
        fitGraph();&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
&lt;br /&gt;
      var resultNodes = [];&lt;br /&gt;
      items.forEach(function (item, index) {&lt;br /&gt;
        var key = canonicalId(item.id);&lt;br /&gt;
        var node = byId[key];&lt;br /&gt;
        if (!node) {&lt;br /&gt;
          node = {&lt;br /&gt;
            id: item.id,&lt;br /&gt;
            title: item.title || String(item.id).replace(/^Lesson:/, &#039;&#039;),&lt;br /&gt;
            confidence: item.confidence || &#039;low&#039;,&lt;br /&gt;
            updatedAt: &#039;&#039;,&lt;br /&gt;
            x: 0,&lt;br /&gt;
            y: 0,&lt;br /&gt;
            vx: 0,&lt;br /&gt;
            vy: 0,&lt;br /&gt;
            degree: 0,&lt;br /&gt;
            homeX: 0,&lt;br /&gt;
            homeY: 0,&lt;br /&gt;
            searchAdded: true&lt;br /&gt;
          };&lt;br /&gt;
          nodes.push(node);&lt;br /&gt;
          byId[key] = node;&lt;br /&gt;
        } else {&lt;br /&gt;
          node.preSearchPosition = { x: node.x, y: node.y };&lt;br /&gt;
          node.preSearchHome = { x: node.homeX, y: node.homeY };&lt;br /&gt;
          node.title = item.title || node.title;&lt;br /&gt;
          node.confidence = item.confidence || node.confidence;&lt;br /&gt;
        }&lt;br /&gt;
        node.isSearchResult = true;&lt;br /&gt;
        node.searchRank = index + 1;&lt;br /&gt;
        resultNodes.push(node);&lt;br /&gt;
      });&lt;br /&gt;
&lt;br /&gt;
      items.forEach(function (item) {&lt;br /&gt;
        (Array.isArray(item.relations) ? item.relations : []).forEach(function (relationItem) {&lt;br /&gt;
          var source = byId[canonicalId(relationItem.from || item.id)];&lt;br /&gt;
          var target = byId[canonicalId(relationItem.to)];&lt;br /&gt;
          var relation = relationDefinition(relationItem.relation);&lt;br /&gt;
          if (!source || !target || !relation || source === target) {&lt;br /&gt;
            return;&lt;br /&gt;
          }&lt;br /&gt;
          var duplicate = edges.some(function (edge) {&lt;br /&gt;
            return canonicalId(edge.source.id) === canonicalId(source.id) &amp;amp;&amp;amp;&lt;br /&gt;
              canonicalId(edge.target.id) === canonicalId(target.id) &amp;amp;&amp;amp;&lt;br /&gt;
              edge.relation.key === relation.key;&lt;br /&gt;
          });&lt;br /&gt;
          if (!duplicate) {&lt;br /&gt;
            edges.push({&lt;br /&gt;
              source: source,&lt;br /&gt;
              target: target,&lt;br /&gt;
              relation: relation,&lt;br /&gt;
              searchAdded: true&lt;br /&gt;
            });&lt;br /&gt;
            source.degree += 1;&lt;br /&gt;
            target.degree += 1;&lt;br /&gt;
          }&lt;br /&gt;
        });&lt;br /&gt;
      });&lt;br /&gt;
&lt;br /&gt;
      var clusterRadius = resultNodes.length === 1 ? 0 :&lt;br /&gt;
        Math.max(82, Math.min(132, resultNodes.length * 22));&lt;br /&gt;
      resultNodes.forEach(function (node, index) {&lt;br /&gt;
        var angle = -Math.PI / 2 + (Math.PI * 2 * index / resultNodes.length);&lt;br /&gt;
        node.x = Math.cos(angle) * clusterRadius;&lt;br /&gt;
        node.y = Math.sin(angle) * clusterRadius;&lt;br /&gt;
        node.homeX = node.x;&lt;br /&gt;
        node.homeY = node.y;&lt;br /&gt;
        node.vx = 0;&lt;br /&gt;
        node.vy = 0;&lt;br /&gt;
      });&lt;br /&gt;
      searchCluster = {&lt;br /&gt;
        query: query,&lt;br /&gt;
        nodes: resultNodes,&lt;br /&gt;
        x: 0,&lt;br /&gt;
        y: 0,&lt;br /&gt;
        radius: clusterRadius&lt;br /&gt;
      };&lt;br /&gt;
      activeRelation = &#039;all&#039;;&lt;br /&gt;
      filter.value = &#039;all&#039;;&lt;br /&gt;
      clearSearch.hidden = false;&lt;br /&gt;
      transform.scale = Math.max(0.72, Math.min(&lt;br /&gt;
        1.35,&lt;br /&gt;
        Math.min(size.width, size.height) / Math.max(360, clusterRadius * 2 + 150)&lt;br /&gt;
      ));&lt;br /&gt;
      transform.x = size.width / 2;&lt;br /&gt;
      transform.y = size.height / 2;&lt;br /&gt;
      selectNode(resultNodes[0]);&lt;br /&gt;
      updateCounts();&lt;br /&gt;
      draw();&lt;br /&gt;
      viewport.scrollIntoView({ behavior: &#039;smooth&#039;, block: &#039;center&#039; });&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function connectionSummary(node) {&lt;br /&gt;
      var connected = edges.filter(function (edge) {&lt;br /&gt;
        return edge.source === node || edge.target === node;&lt;br /&gt;
      });&lt;br /&gt;
      var summary = Object.create(null);&lt;br /&gt;
      connected.forEach(function (edge) {&lt;br /&gt;
        summary[edge.relation.label] = (summary[edge.relation.label] || 0) + 1;&lt;br /&gt;
      });&lt;br /&gt;
      return { edges: connected, counts: summary };&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function showInspector(node) {&lt;br /&gt;
      inspector.replaceChildren();&lt;br /&gt;
      if (!node) {&lt;br /&gt;
        var welcome = makeElement(&#039;div&#039;, &#039;s3rm-inspector-welcome&#039;);&lt;br /&gt;
        welcome.appendChild(makeElement(&#039;span&#039;, &#039;s3rm-inspector-symbol&#039;, &#039;⌁&#039;));&lt;br /&gt;
        welcome.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;메모를 선택하세요&#039;));&lt;br /&gt;
        welcome.appendChild(makeElement(&#039;p&#039;, &#039;&#039;, &#039;점 하나를 누르면 메모 정보와 연결된 기록을 보여 드립니다.&#039;));&lt;br /&gt;
        inspector.appendChild(welcome);&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      var summary = connectionSummary(node);&lt;br /&gt;
      inspector.appendChild(makeElement(&lt;br /&gt;
        &#039;div&#039;,&lt;br /&gt;
        &#039;s3rm-inspector-kicker&#039;,&lt;br /&gt;
        node.isSearchResult ? &#039;검색 결과 &#039; + node.searchRank : &#039;선택한 메모&#039;&lt;br /&gt;
      ));&lt;br /&gt;
      inspector.appendChild(makeElement(&#039;h3&#039;, &#039;&#039;, node.title));&lt;br /&gt;
      var meta = makeElement(&#039;div&#039;, &#039;s3rm-inspector-meta&#039;);&lt;br /&gt;
      meta.appendChild(makeElement(&#039;span&#039;, &#039;s3rm-confidence s3rm-confidence-&#039; + node.confidence, confidenceLabel(node.confidence)));&lt;br /&gt;
      meta.appendChild(makeElement(&#039;span&#039;, &#039;&#039;, summary.edges.length + &#039;개 연결&#039;));&lt;br /&gt;
      inspector.appendChild(meta);&lt;br /&gt;
      if (Object.keys(summary.counts).length) {&lt;br /&gt;
        var relationList = makeElement(&#039;ul&#039;, &#039;s3rm-inspector-relations&#039;);&lt;br /&gt;
        Object.keys(summary.counts).forEach(function (label) {&lt;br /&gt;
          var item = makeElement(&#039;li&#039;, &#039;&#039;);&lt;br /&gt;
          item.append(makeElement(&#039;span&#039;, &#039;&#039;, label), makeElement(&#039;strong&#039;, &#039;&#039;, String(summary.counts[label])));&lt;br /&gt;
          relationList.appendChild(item);&lt;br /&gt;
        });&lt;br /&gt;
        inspector.appendChild(relationList);&lt;br /&gt;
      } else {&lt;br /&gt;
        inspector.appendChild(makeElement(&#039;p&#039;, &#039;s3rm-inspector-muted&#039;, &#039;아직 다른 메모와 연결되지 않았습니다.&#039;));&lt;br /&gt;
      }&lt;br /&gt;
      var open = makeElement(&#039;a&#039;, &#039;s3rm-graph-primary-link&#039;, &#039;메모 열기&#039;);&lt;br /&gt;
      open.href = mw.util.getUrl(node.id);&lt;br /&gt;
      inspector.appendChild(open);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function canvasPoint(event) {&lt;br /&gt;
      var rect = canvas.getBoundingClientRect();&lt;br /&gt;
      return { x: event.clientX - rect.left, y: event.clientY - rect.top };&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function worldPoint(event) {&lt;br /&gt;
      var point = canvasPoint(event);&lt;br /&gt;
      return {&lt;br /&gt;
        x: (point.x - transform.x) / transform.scale,&lt;br /&gt;
        y: (point.y - transform.y) / transform.scale&lt;br /&gt;
      };&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function hitNode(event) {&lt;br /&gt;
      var point = worldPoint(event);&lt;br /&gt;
      for (var i = nodes.length - 1; i &amp;gt;= 0; i -= 1) {&lt;br /&gt;
        var node = nodes[i];&lt;br /&gt;
        var dx = point.x - node.x;&lt;br /&gt;
        var dy = point.y - node.y;&lt;br /&gt;
        if (dx * dx + dy * dy &amp;lt;= Math.pow(nodeRadius(node) + 6 / transform.scale, 2)) {&lt;br /&gt;
          return node;&lt;br /&gt;
        }&lt;br /&gt;
      }&lt;br /&gt;
      return null;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function selectNode(node) {&lt;br /&gt;
      selected = node;&lt;br /&gt;
      showInspector(node);&lt;br /&gt;
      draw();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    canvas.addEventListener(&#039;pointerdown&#039;, function (event) {&lt;br /&gt;
      dragging = hitNode(event);&lt;br /&gt;
      panning = !dragging;&lt;br /&gt;
      pointerStart = canvasPoint(event);&lt;br /&gt;
      if (dragging) {&lt;br /&gt;
        var world = worldPoint(event);&lt;br /&gt;
        dragMotion = {&lt;br /&gt;
          x: world.x,&lt;br /&gt;
          y: world.y,&lt;br /&gt;
          time: event.timeStamp,&lt;br /&gt;
          vx: 0,&lt;br /&gt;
          vy: 0&lt;br /&gt;
        };&lt;br /&gt;
        dragging.motionActive = true;&lt;br /&gt;
        startSimulation(120, false);&lt;br /&gt;
      }&lt;br /&gt;
      canvas.setPointerCapture(event.pointerId);&lt;br /&gt;
      canvas.classList.add(&#039;is-dragging&#039;);&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;pointermove&#039;, function (event) {&lt;br /&gt;
      if (dragging) {&lt;br /&gt;
        var world = worldPoint(event);&lt;br /&gt;
        if (dragMotion) {&lt;br /&gt;
          var elapsed = Math.max(8, event.timeStamp - dragMotion.time);&lt;br /&gt;
          var frameRatio = 16.67 / elapsed;&lt;br /&gt;
          var instantVX = (world.x - dragMotion.x) * frameRatio;&lt;br /&gt;
          var instantVY = (world.y - dragMotion.y) * frameRatio;&lt;br /&gt;
          dragMotion.vx = dragMotion.vx * 0.45 + instantVX * 0.55;&lt;br /&gt;
          dragMotion.vy = dragMotion.vy * 0.45 + instantVY * 0.55;&lt;br /&gt;
          var speed = Math.sqrt(dragMotion.vx * dragMotion.vx + dragMotion.vy * dragMotion.vy);&lt;br /&gt;
          if (speed &amp;gt; 14) {&lt;br /&gt;
            dragMotion.vx = dragMotion.vx / speed * 14;&lt;br /&gt;
            dragMotion.vy = dragMotion.vy / speed * 14;&lt;br /&gt;
          }&lt;br /&gt;
          dragMotion.x = world.x;&lt;br /&gt;
          dragMotion.y = world.y;&lt;br /&gt;
          dragMotion.time = event.timeStamp;&lt;br /&gt;
        }&lt;br /&gt;
        dragging.x = world.x;&lt;br /&gt;
        dragging.y = world.y;&lt;br /&gt;
        startSimulation(120, false);&lt;br /&gt;
      } else if (panning &amp;amp;&amp;amp; pointerStart) {&lt;br /&gt;
        var point = canvasPoint(event);&lt;br /&gt;
        transform.x += point.x - pointerStart.x;&lt;br /&gt;
        transform.y += point.y - pointerStart.y;&lt;br /&gt;
        pointerStart = point;&lt;br /&gt;
      } else {&lt;br /&gt;
        hovered = hitNode(event);&lt;br /&gt;
        canvas.style.cursor = hovered ? &#039;pointer&#039; : &#039;grab&#039;;&lt;br /&gt;
      }&lt;br /&gt;
      draw();&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;pointerup&#039;, function (event) {&lt;br /&gt;
      var hit = hitNode(event);&lt;br /&gt;
      var releasedNode = dragging;&lt;br /&gt;
      if (releasedNode &amp;amp;&amp;amp; dragMotion) {&lt;br /&gt;
        releasedNode.vx = reduceMotion ? 0 : dragMotion.vx;&lt;br /&gt;
        releasedNode.vy = reduceMotion ? 0 : dragMotion.vy;&lt;br /&gt;
      }&lt;br /&gt;
      dragging = null;&lt;br /&gt;
      panning = false;&lt;br /&gt;
      pointerStart = null;&lt;br /&gt;
      dragMotion = null;&lt;br /&gt;
      canvas.classList.remove(&#039;is-dragging&#039;);&lt;br /&gt;
      if (releasedNode) {&lt;br /&gt;
        if (hit === releasedNode) {&lt;br /&gt;
          selectNode(releasedNode);&lt;br /&gt;
        }&lt;br /&gt;
        startSimulation(140, false);&lt;br /&gt;
      }&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;pointercancel&#039;, function () {&lt;br /&gt;
      if (dragging) {&lt;br /&gt;
        dragging.vx = 0;&lt;br /&gt;
        dragging.vy = 0;&lt;br /&gt;
        delete dragging.motionActive;&lt;br /&gt;
      }&lt;br /&gt;
      dragging = null;&lt;br /&gt;
      panning = false;&lt;br /&gt;
      pointerStart = null;&lt;br /&gt;
      dragMotion = null;&lt;br /&gt;
      canvas.classList.remove(&#039;is-dragging&#039;);&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;dblclick&#039;, function (event) {&lt;br /&gt;
      var node = hitNode(event);&lt;br /&gt;
      if (node) {&lt;br /&gt;
        window.location.href = mw.util.getUrl(node.id);&lt;br /&gt;
      }&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;wheel&#039;, function (event) {&lt;br /&gt;
      event.preventDefault();&lt;br /&gt;
      var point = canvasPoint(event);&lt;br /&gt;
      var oldScale = transform.scale;&lt;br /&gt;
      var nextScale = Math.max(0.25, Math.min(3.5, oldScale * Math.exp(-event.deltaY * 0.0012)));&lt;br /&gt;
      transform.x = point.x - ((point.x - transform.x) / oldScale) * nextScale;&lt;br /&gt;
      transform.y = point.y - ((point.y - transform.y) / oldScale) * nextScale;&lt;br /&gt;
      transform.scale = nextScale;&lt;br /&gt;
      draw();&lt;br /&gt;
    }, { passive: false });&lt;br /&gt;
&lt;br /&gt;
    search.addEventListener(&#039;input&#039;, function () {&lt;br /&gt;
      var query = search.value.trim().toLocaleLowerCase(&#039;ko&#039;);&lt;br /&gt;
      searchResults.replaceChildren();&lt;br /&gt;
      if (!query) {&lt;br /&gt;
        searchResults.hidden = true;&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      var matches = nodes.filter(function (node) {&lt;br /&gt;
        return node.title.toLocaleLowerCase(&#039;ko&#039;).includes(query) || node.id.toLowerCase().includes(query);&lt;br /&gt;
      }).slice(0, 6);&lt;br /&gt;
      matches.forEach(function (node) {&lt;br /&gt;
        var button = makeElement(&#039;button&#039;, &#039;&#039;, node.title);&lt;br /&gt;
        button.type = &#039;button&#039;;&lt;br /&gt;
        button.addEventListener(&#039;click&#039;, function () {&lt;br /&gt;
          selectNode(node);&lt;br /&gt;
          transform.x = size.width / 2 - node.x * transform.scale;&lt;br /&gt;
          transform.y = size.height / 2 - node.y * transform.scale;&lt;br /&gt;
          search.value = node.title;&lt;br /&gt;
          searchResults.hidden = true;&lt;br /&gt;
          draw();&lt;br /&gt;
        });&lt;br /&gt;
        searchResults.appendChild(button);&lt;br /&gt;
      });&lt;br /&gt;
      if (!matches.length) {&lt;br /&gt;
        searchResults.appendChild(makeElement(&#039;span&#039;, &#039;&#039;, &#039;일치하는 메모가 없습니다.&#039;));&lt;br /&gt;
      }&lt;br /&gt;
      searchResults.hidden = false;&lt;br /&gt;
    });&lt;br /&gt;
    search.addEventListener(&#039;keydown&#039;, function (event) {&lt;br /&gt;
      if (event.key === &#039;Escape&#039;) {&lt;br /&gt;
        searchResults.hidden = true;&lt;br /&gt;
      } else if (event.key === &#039;Enter&#039;) {&lt;br /&gt;
        var first = searchResults.querySelector(&#039;button&#039;);&lt;br /&gt;
        if (first) { first.click(); }&lt;br /&gt;
      }&lt;br /&gt;
    });&lt;br /&gt;
    filter.addEventListener(&#039;change&#039;, function () {&lt;br /&gt;
      activeRelation = filter.value;&lt;br /&gt;
      updateCounts();&lt;br /&gt;
      draw();&lt;br /&gt;
    });&lt;br /&gt;
    reset.addEventListener(&#039;click&#039;, fitGraph);&lt;br /&gt;
    clearSearch.addEventListener(&#039;click&#039;, function () {&lt;br /&gt;
      resetSearchCluster();&lt;br /&gt;
      fitGraph();&lt;br /&gt;
    });&lt;br /&gt;
&lt;br /&gt;
    showInspector(null);&lt;br /&gt;
    resize();&lt;br /&gt;
    var resizeObserver = window.ResizeObserver ? new ResizeObserver(resize) : null;&lt;br /&gt;
    if (resizeObserver) {&lt;br /&gt;
      resizeObserver.observe(viewport);&lt;br /&gt;
    } else {&lt;br /&gt;
      window.addEventListener(&#039;resize&#039;, resize);&lt;br /&gt;
    }&lt;br /&gt;
    graphSearchController = focusSearchResults;&lt;br /&gt;
    if (pendingGraphSearch) {&lt;br /&gt;
      focusSearchResults(pendingGraphSearch.items, pendingGraphSearch.query);&lt;br /&gt;
      pendingGraphSearch = null;&lt;br /&gt;
    }&lt;br /&gt;
    startSimulation(160, true);&lt;br /&gt;
    window.addEventListener(&#039;pagehide&#039;, function () {&lt;br /&gt;
      window.cancelAnimationFrame(animationFrame);&lt;br /&gt;
      if (resizeObserver) { resizeObserver.disconnect(); }&lt;br /&gt;
    }, { once: true });&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  var askQuery = &#039;[[Category:Lessons]]&#039; +&lt;br /&gt;
    &#039;|?Lesson title|?Confidence|?Updated at&#039; +&lt;br /&gt;
    RELATIONS.map(function (relation) { return &#039;|?&#039; + relation.property; }).join(&#039;&#039;) +&lt;br /&gt;
    &#039;|sort=Updated at|order=descending|limit=&#039; + NODE_LIMIT;&lt;br /&gt;
&lt;br /&gt;
  createSemanticSearch();&lt;br /&gt;
&lt;br /&gt;
  mw.loader.using([&#039;mediawiki.api&#039;, &#039;mediawiki.util&#039;]).then(function () {&lt;br /&gt;
    return new mw.Api().get({ action: &#039;ask&#039;, query: askQuery, formatversion: 2 });&lt;br /&gt;
  }).then(function (data) {&lt;br /&gt;
    var graph = parseGraph(data);&lt;br /&gt;
    if (!graph.nodes.length) {&lt;br /&gt;
      renderEmpty();&lt;br /&gt;
      return;&lt;br /&gt;
    }&lt;br /&gt;
    createGraph(graph);&lt;br /&gt;
  }).catch(renderError);&lt;br /&gt;
}());&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%EB%AF%B8%EB%94%94%EC%96%B4%EC%9C%84%ED%82%A4:Common.js&amp;diff=2743</id>
		<title>미디어위키:Common.js</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%EB%AF%B8%EB%94%94%EC%96%B4%EC%9C%84%ED%82%A4:Common.js&amp;diff=2743"/>
		<updated>2026-07-19T02:38:44Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 원본 스키마 동기화&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;(function () {&lt;br /&gt;
  &#039;use strict&#039;;&lt;br /&gt;
&lt;br /&gt;
  var root = document.getElementById(&#039;s3rm-knowledge-graph&#039;);&lt;br /&gt;
  var semanticRoot = document.getElementById(&#039;s3rm-semantic-search&#039;);&lt;br /&gt;
  if (!root || !window.mw) {&lt;br /&gt;
    return;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  var RELATIONS = [&lt;br /&gt;
    { property: &#039;Supports&#039;, key: &#039;supports&#039;, label: &#039;뒷받침함&#039;, color: &#039;#5f7ae6&#039; },&lt;br /&gt;
    { property: &#039;Contradicts&#039;, key: &#039;contradicts&#039;, label: &#039;상충함&#039;, color: &#039;#d0605b&#039; },&lt;br /&gt;
    { property: &#039;Analogous to&#039;, key: &#039;analogous_to&#039;, label: &#039;유사함&#039;, color: &#039;#a17bd7&#039; },&lt;br /&gt;
    { property: &#039;Failed because&#039;, key: &#039;failed_because&#039;, label: &#039;실패 원인&#039;, color: &#039;#dc8b45&#039; },&lt;br /&gt;
    { property: &#039;Works when&#039;, key: &#039;works_when&#039;, label: &#039;작동 조건&#039;, color: &#039;#43a27b&#039; },&lt;br /&gt;
    { property: &#039;Suggests&#039;, key: &#039;suggests&#039;, label: &#039;후속 제안&#039;, color: &#039;#3f9db7&#039; },&lt;br /&gt;
    { property: &#039;Supersedes&#039;, key: &#039;supersedes&#039;, label: &#039;대체함&#039;, color: &#039;#7d849e&#039; }&lt;br /&gt;
  ];&lt;br /&gt;
  var NODE_LIMIT = 160;&lt;br /&gt;
  var EDGE_LIMIT = 480;&lt;br /&gt;
  var mcpReady = null;&lt;br /&gt;
  var graphSearchController = null;&lt;br /&gt;
  var pendingGraphSearch = null;&lt;br /&gt;
&lt;br /&gt;
  function mcpRequest(payload, expectBody) {&lt;br /&gt;
    return window.fetch(&#039;/mcp&#039;, {&lt;br /&gt;
      method: &#039;POST&#039;,&lt;br /&gt;
      credentials: &#039;same-origin&#039;,&lt;br /&gt;
      headers: {&lt;br /&gt;
        &#039;Content-Type&#039;: &#039;application/json&#039;,&lt;br /&gt;
        &#039;Accept&#039;: &#039;application/json, text/event-stream&#039;&lt;br /&gt;
      },&lt;br /&gt;
      body: JSON.stringify(payload)&lt;br /&gt;
    }).then(function (response) {&lt;br /&gt;
      if (!response.ok) {&lt;br /&gt;
        throw new Error(&#039;MCP HTTP &#039; + response.status);&lt;br /&gt;
      }&lt;br /&gt;
      if (!expectBody) {&lt;br /&gt;
        return null;&lt;br /&gt;
      }&lt;br /&gt;
      return response.json();&lt;br /&gt;
    });&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function initializeMcp() {&lt;br /&gt;
    if (!mcpReady) {&lt;br /&gt;
      mcpReady = mcpRequest({&lt;br /&gt;
        jsonrpc: &#039;2.0&#039;,&lt;br /&gt;
        id: 1,&lt;br /&gt;
        method: &#039;initialize&#039;,&lt;br /&gt;
        params: {&lt;br /&gt;
          protocolVersion: &#039;2025-06-18&#039;,&lt;br /&gt;
          capabilities: {},&lt;br /&gt;
          clientInfo: { name: &#039;s3rm-wiki-search&#039;, version: &#039;1&#039; }&lt;br /&gt;
        }&lt;br /&gt;
      }, true).then(function (response) {&lt;br /&gt;
        if (!response || response.error) {&lt;br /&gt;
          throw new Error(&#039;MCP initialization failed&#039;);&lt;br /&gt;
        }&lt;br /&gt;
        return mcpRequest({&lt;br /&gt;
          jsonrpc: &#039;2.0&#039;,&lt;br /&gt;
          method: &#039;notifications/initialized&#039;,&lt;br /&gt;
          params: {}&lt;br /&gt;
        }, false);&lt;br /&gt;
      });&lt;br /&gt;
    }&lt;br /&gt;
    return mcpReady;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function searchSemantically(query) {&lt;br /&gt;
    return initializeMcp().then(function () {&lt;br /&gt;
      return mcpRequest({&lt;br /&gt;
        jsonrpc: &#039;2.0&#039;,&lt;br /&gt;
        id: 2,&lt;br /&gt;
        method: &#039;tools/call&#039;,&lt;br /&gt;
        params: {&lt;br /&gt;
          name: &#039;search_lessons&#039;,&lt;br /&gt;
          arguments: {&lt;br /&gt;
            query: query,&lt;br /&gt;
            retrieval_mode: &#039;hybrid_v1&#039;,&lt;br /&gt;
            fields: [&#039;title&#039;, &#039;question&#039;, &#039;reusable_lesson&#039;, &#039;confidence&#039;, &#039;relations&#039;],&lt;br /&gt;
            limit: 6&lt;br /&gt;
          }&lt;br /&gt;
        }&lt;br /&gt;
      }, true);&lt;br /&gt;
    }).then(function (response) {&lt;br /&gt;
      if (!response || response.error || !response.result || response.result.isError) {&lt;br /&gt;
        throw new Error(&#039;Semantic search failed&#039;);&lt;br /&gt;
      }&lt;br /&gt;
      return response.result.structuredContent || {};&lt;br /&gt;
    });&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function textValue(value) {&lt;br /&gt;
    if (value === null || value === undefined) {&lt;br /&gt;
      return &#039;&#039;;&lt;br /&gt;
    }&lt;br /&gt;
    if (typeof value === &#039;string&#039; || typeof value === &#039;number&#039;) {&lt;br /&gt;
      return String(value);&lt;br /&gt;
    }&lt;br /&gt;
    return String(value.fulltext || value.displaytitle || value.raw || value.timestamp || &#039;&#039;);&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function firstValue(values, fallback) {&lt;br /&gt;
    return values &amp;amp;&amp;amp; values.length ? textValue(values[0]) : fallback;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function confidenceLabel(confidence) {&lt;br /&gt;
    if (confidence === &#039;high&#039;) { return &#039;신뢰도: 높음&#039;; }&lt;br /&gt;
    if (confidence === &#039;medium&#039;) { return &#039;신뢰도: 중간&#039;; }&lt;br /&gt;
    return &#039;신뢰도: 낮음&#039;;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function parseGraph(data) {&lt;br /&gt;
    var rawResults = data &amp;amp;&amp;amp; data.query &amp;amp;&amp;amp; data.query.results ? data.query.results : {};&lt;br /&gt;
    var entries = Array.isArray(rawResults)&lt;br /&gt;
      ? rawResults.map(function (item) { return [item.fulltext, item]; })&lt;br /&gt;
      : Object.keys(rawResults).map(function (key) { return [key, rawResults[key]]; });&lt;br /&gt;
    var nodes = [];&lt;br /&gt;
    var byId = Object.create(null);&lt;br /&gt;
&lt;br /&gt;
    entries.slice(0, NODE_LIMIT).forEach(function (entry, index) {&lt;br /&gt;
      var id = entry[0];&lt;br /&gt;
      var result = entry[1] || {};&lt;br /&gt;
      var printouts = result.printouts || {};&lt;br /&gt;
      var angle = index * 2.399963229728653;&lt;br /&gt;
      var radius = 20 * Math.sqrt(index + 1);&lt;br /&gt;
      var node = {&lt;br /&gt;
        id: id,&lt;br /&gt;
        title: firstValue(printouts[&#039;Lesson title&#039;], id.replace(/^Lesson:/, &#039;&#039;)),&lt;br /&gt;
        confidence: firstValue(printouts.Confidence, &#039;low&#039;).toLowerCase(),&lt;br /&gt;
        updatedAt: firstValue(printouts[&#039;Updated at&#039;], &#039;&#039;),&lt;br /&gt;
        x: Math.cos(angle) * radius,&lt;br /&gt;
        y: Math.sin(angle) * radius,&lt;br /&gt;
        vx: 0,&lt;br /&gt;
        vy: 0,&lt;br /&gt;
        degree: 0,&lt;br /&gt;
        printouts: printouts&lt;br /&gt;
      };&lt;br /&gt;
      nodes.push(node);&lt;br /&gt;
      byId[id] = node;&lt;br /&gt;
    });&lt;br /&gt;
&lt;br /&gt;
    var edges = [];&lt;br /&gt;
    nodes.forEach(function (node) {&lt;br /&gt;
      RELATIONS.forEach(function (relation) {&lt;br /&gt;
        var targets = node.printouts[relation.property] || [];&lt;br /&gt;
        targets.forEach(function (target) {&lt;br /&gt;
          var targetId = textValue(target);&lt;br /&gt;
          if (edges.length &amp;lt; EDGE_LIMIT &amp;amp;&amp;amp; byId[targetId] &amp;amp;&amp;amp; targetId !== node.id) {&lt;br /&gt;
            edges.push({ source: node, target: byId[targetId], relation: relation });&lt;br /&gt;
            node.degree += 1;&lt;br /&gt;
            byId[targetId].degree += 1;&lt;br /&gt;
          }&lt;br /&gt;
        });&lt;br /&gt;
      });&lt;br /&gt;
      delete node.printouts;&lt;br /&gt;
    });&lt;br /&gt;
&lt;br /&gt;
    var connectedNodes = nodes.filter(function (node) { return node.degree &amp;gt; 0; });&lt;br /&gt;
    var isolatedNodes = nodes.filter(function (node) { return node.degree === 0; });&lt;br /&gt;
    connectedNodes.forEach(function (node, index) {&lt;br /&gt;
      var angle = index * 2.399963229728653;&lt;br /&gt;
      var radius = 16 * Math.sqrt(index + 1);&lt;br /&gt;
      node.x = Math.cos(angle) * radius;&lt;br /&gt;
      node.y = Math.sin(angle) * radius;&lt;br /&gt;
    });&lt;br /&gt;
    isolatedNodes.forEach(function (node, index) {&lt;br /&gt;
      var angle = index * 2.399963229728653 + 0.7;&lt;br /&gt;
      var radius = 92 + 10 * Math.sqrt(index + 1);&lt;br /&gt;
      node.x = Math.cos(angle) * radius;&lt;br /&gt;
      node.y = Math.sin(angle) * radius;&lt;br /&gt;
    });&lt;br /&gt;
    nodes.forEach(function (node) {&lt;br /&gt;
      node.homeX = node.x;&lt;br /&gt;
      node.homeY = node.y;&lt;br /&gt;
    });&lt;br /&gt;
    return { nodes: nodes, edges: edges };&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function makeElement(tag, className, text) {&lt;br /&gt;
    var element = document.createElement(tag);&lt;br /&gt;
    if (className) {&lt;br /&gt;
      element.className = className;&lt;br /&gt;
    }&lt;br /&gt;
    if (text !== undefined) {&lt;br /&gt;
      element.textContent = text;&lt;br /&gt;
    }&lt;br /&gt;
    return element;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function createSemanticSearch() {&lt;br /&gt;
    if (!semanticRoot) {&lt;br /&gt;
      return;&lt;br /&gt;
    }&lt;br /&gt;
    semanticRoot.replaceChildren();&lt;br /&gt;
    var intro = makeElement(&#039;div&#039;, &#039;s3rm-semantic-intro&#039;);&lt;br /&gt;
    intro.appendChild(makeElement(&#039;div&#039;, &#039;s3rm-semantic-icon&#039;, &#039;⌕&#039;));&lt;br /&gt;
    var introCopy = makeElement(&#039;div&#039;, &#039;&#039;);&lt;br /&gt;
    introCopy.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;검색&#039;));&lt;br /&gt;
    introCopy.appendChild(makeElement(&#039;p&#039;, &#039;&#039;, &#039;현상이나 궁금한 점을 문장으로 입력하세요. 표현이 달라도 관련 메모를 찾습니다.&#039;));&lt;br /&gt;
    intro.appendChild(introCopy);&lt;br /&gt;
&lt;br /&gt;
    var form = makeElement(&#039;form&#039;, &#039;s3rm-semantic-form&#039;);&lt;br /&gt;
    var input = makeElement(&#039;input&#039;, &#039;s3rm-semantic-input&#039;);&lt;br /&gt;
    input.type = &#039;search&#039;;&lt;br /&gt;
    input.name = &#039;query&#039;;&lt;br /&gt;
    input.maxLength = 500;&lt;br /&gt;
    input.placeholder = &#039;예: GPU 메모리는 남아 있는데 할당이 실패하는 이유는?&#039;;&lt;br /&gt;
    input.setAttribute(&#039;aria-label&#039;, &#039;메모 검색&#039;);&lt;br /&gt;
    var submit = makeElement(&#039;button&#039;, &#039;s3rm-semantic-submit&#039;, &#039;검색&#039;);&lt;br /&gt;
    submit.type = &#039;submit&#039;;&lt;br /&gt;
    form.append(input, submit);&lt;br /&gt;
&lt;br /&gt;
    var examples = makeElement(&#039;div&#039;, &#039;s3rm-semantic-examples&#039;);&lt;br /&gt;
    examples.appendChild(makeElement(&#039;span&#039;, &#039;&#039;, &#039;검색 예시&#039;));&lt;br /&gt;
    [&lt;br /&gt;
      &#039;재시도가 한꺼번에 몰리는 이유&#039;,&lt;br /&gt;
      &#039;GPU와 SSD 사이 병목을 줄이는 방법&#039;,&lt;br /&gt;
      &#039;긴 작업에서 지연이 갑자기 커지는 원인&#039;&lt;br /&gt;
    ].forEach(function (example) {&lt;br /&gt;
      var button = makeElement(&#039;button&#039;, &#039;&#039;, example);&lt;br /&gt;
      button.type = &#039;button&#039;;&lt;br /&gt;
      button.addEventListener(&#039;click&#039;, function () {&lt;br /&gt;
        input.value = example;&lt;br /&gt;
        form.requestSubmit();&lt;br /&gt;
      });&lt;br /&gt;
      examples.appendChild(button);&lt;br /&gt;
    });&lt;br /&gt;
&lt;br /&gt;
    var feedback = makeElement(&#039;div&#039;, &#039;s3rm-semantic-feedback&#039;);&lt;br /&gt;
    feedback.setAttribute(&#039;aria-live&#039;, &#039;polite&#039;);&lt;br /&gt;
    feedback.hidden = true;&lt;br /&gt;
    semanticRoot.append(intro, form, examples, feedback);&lt;br /&gt;
&lt;br /&gt;
    function renderSearchResults(payload, query) {&lt;br /&gt;
      var items = Array.isArray(payload.items) ? payload.items : [];&lt;br /&gt;
      if (graphSearchController) {&lt;br /&gt;
        graphSearchController(items, query);&lt;br /&gt;
      } else {&lt;br /&gt;
        pendingGraphSearch = { items: items, query: query };&lt;br /&gt;
      }&lt;br /&gt;
      feedback.replaceChildren();&lt;br /&gt;
      feedback.hidden = false;&lt;br /&gt;
      var resultHeader = makeElement(&#039;div&#039;, &#039;s3rm-semantic-result-header&#039;);&lt;br /&gt;
      resultHeader.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;“&#039; + query + &#039;” 검색 결과&#039;));&lt;br /&gt;
      resultHeader.appendChild(makeElement(&#039;span&#039;, &#039;s3rm-semantic-mode&#039;, items.length + &#039;개 메모&#039;));&lt;br /&gt;
      feedback.appendChild(resultHeader);&lt;br /&gt;
&lt;br /&gt;
      if (!items.length) {&lt;br /&gt;
        feedback.appendChild(makeElement(&#039;p&#039;, &#039;s3rm-semantic-empty&#039;, &#039;가까운 메모를 찾지 못했습니다. 표현을 조금 바꿔 다시 검색해 보세요.&#039;));&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      feedback.appendChild(makeElement(&#039;p&#039;, &#039;s3rm-semantic-graph-note&#039;, &#039;아래 그래프에 검색 결과를 모았습니다. 점을 선택하면 메모와 연결을 볼 수 있습니다.&#039;));&lt;br /&gt;
      var list = makeElement(&#039;div&#039;, &#039;s3rm-semantic-result-links&#039;);&lt;br /&gt;
      items.forEach(function (item) {&lt;br /&gt;
        var link = makeElement(&#039;a&#039;, &#039;&#039;, item.title || String(item.id).replace(/^Lesson:/, &#039;&#039;));&lt;br /&gt;
        link.href = mw.util.getUrl(item.id);&lt;br /&gt;
        list.appendChild(link);&lt;br /&gt;
      });&lt;br /&gt;
      feedback.appendChild(list);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    form.addEventListener(&#039;submit&#039;, function (event) {&lt;br /&gt;
      event.preventDefault();&lt;br /&gt;
      var query = input.value.trim();&lt;br /&gt;
      if (query.length &amp;lt; 2) {&lt;br /&gt;
        feedback.replaceChildren(makeElement(&#039;p&#039;, &#039;s3rm-semantic-error&#039;, &#039;두 글자 이상 입력해 주세요.&#039;));&lt;br /&gt;
        feedback.hidden = false;&lt;br /&gt;
        input.focus();&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      submit.disabled = true;&lt;br /&gt;
      submit.textContent = &#039;찾는 중…&#039;;&lt;br /&gt;
      feedback.replaceChildren(makeElement(&#039;div&#039;, &#039;s3rm-semantic-searching&#039;, &#039;관련 메모를 찾고 있습니다.&#039;));&lt;br /&gt;
      feedback.hidden = false;&lt;br /&gt;
      searchSemantically(query).then(function (payload) {&lt;br /&gt;
        renderSearchResults(payload, query);&lt;br /&gt;
      }).catch(function () {&lt;br /&gt;
        feedback.replaceChildren();&lt;br /&gt;
        var error = makeElement(&#039;div&#039;, &#039;s3rm-semantic-error&#039;);&lt;br /&gt;
        error.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;지금은 검색을 사용할 수 없습니다.&#039;));&lt;br /&gt;
        var fallback = makeElement(&#039;a&#039;, &#039;&#039;, &#039;검색 열기&#039;);&lt;br /&gt;
        fallback.href = mw.util.getUrl(&#039;Special:Search&#039;, { search: query });&lt;br /&gt;
        error.appendChild(fallback);&lt;br /&gt;
        feedback.appendChild(error);&lt;br /&gt;
      }).finally(function () {&lt;br /&gt;
        submit.disabled = false;&lt;br /&gt;
        submit.textContent = &#039;검색&#039;;&lt;br /&gt;
      });&lt;br /&gt;
    });&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function renderEmpty() {&lt;br /&gt;
    root.replaceChildren();&lt;br /&gt;
    var empty = makeElement(&#039;div&#039;, &#039;s3rm-graph-empty&#039;);&lt;br /&gt;
    empty.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;아직 연결할 메모가 없습니다&#039;));&lt;br /&gt;
    empty.appendChild(makeElement(&#039;p&#039;, &#039;&#039;, &#039;첫 메모를 남기면 이곳에서 지식의 연결을 볼 수 있습니다.&#039;));&lt;br /&gt;
    var link = makeElement(&#039;a&#039;, &#039;s3rm-graph-primary-link&#039;, &#039;첫 메모 작성하기&#039;);&lt;br /&gt;
    link.href = &#039;#quick-start&#039;;&lt;br /&gt;
    empty.appendChild(link);&lt;br /&gt;
    root.appendChild(empty);&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function renderError() {&lt;br /&gt;
    root.replaceChildren();&lt;br /&gt;
    var fallback = makeElement(&#039;div&#039;, &#039;s3rm-graph-empty&#039;);&lt;br /&gt;
    fallback.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;그래프를 불러오지 못했습니다&#039;));&lt;br /&gt;
    fallback.appendChild(makeElement(&#039;p&#039;, &#039;&#039;, &#039;최근 메모와 검색은 계속 사용할 수 있습니다. 잠시 뒤 다시 시도해 주세요.&#039;));&lt;br /&gt;
    var link = makeElement(&#039;a&#039;, &#039;s3rm-graph-primary-link&#039;, &#039;전체 메모 보기&#039;);&lt;br /&gt;
    link.href = mw.util.getUrl(&#039;Special:AllPages/Lesson:&#039;);&lt;br /&gt;
    fallback.appendChild(link);&lt;br /&gt;
    root.appendChild(fallback);&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function createGraph(graph) {&lt;br /&gt;
    var nodes = graph.nodes;&lt;br /&gt;
    var edges = graph.edges;&lt;br /&gt;
    var byId = Object.create(null);&lt;br /&gt;
    function canonicalId(id) {&lt;br /&gt;
      return String(id || &#039;&#039;).replace(/_/g, &#039; &#039;).toLocaleLowerCase(&#039;en&#039;);&lt;br /&gt;
    }&lt;br /&gt;
    nodes.forEach(function (node) { byId[canonicalId(node.id)] = node; });&lt;br /&gt;
    var selected = null;&lt;br /&gt;
    var hovered = null;&lt;br /&gt;
    var searchCluster = null;&lt;br /&gt;
    var activeRelation = &#039;all&#039;;&lt;br /&gt;
    var transform = { x: 0, y: 0, scale: 1 };&lt;br /&gt;
    var dragging = null;&lt;br /&gt;
    var panning = false;&lt;br /&gt;
    var pointerStart = null;&lt;br /&gt;
    var dragMotion = null;&lt;br /&gt;
    var animationFrame = 0;&lt;br /&gt;
    var simulationFrames = 0;&lt;br /&gt;
    var simulationRunning = false;&lt;br /&gt;
    var fitAfterSimulation = true;&lt;br /&gt;
    var reduceMotion = window.matchMedia &amp;amp;&amp;amp; window.matchMedia(&#039;(prefers-reduced-motion: reduce)&#039;).matches;&lt;br /&gt;
&lt;br /&gt;
    root.replaceChildren();&lt;br /&gt;
    var toolbar = makeElement(&#039;div&#039;, &#039;s3rm-graph-toolbar&#039;);&lt;br /&gt;
    var searchWrap = makeElement(&#039;div&#039;, &#039;s3rm-graph-search&#039;);&lt;br /&gt;
    var search = makeElement(&#039;input&#039;, &#039;s3rm-graph-search-input&#039;);&lt;br /&gt;
    search.type = &#039;search&#039;;&lt;br /&gt;
    search.placeholder = &#039;그래프에서 제목 필터&#039;;&lt;br /&gt;
    search.setAttribute(&#039;aria-label&#039;, &#039;그래프 제목 필터&#039;);&lt;br /&gt;
    search.autocomplete = &#039;off&#039;;&lt;br /&gt;
    var searchResults = makeElement(&#039;div&#039;, &#039;s3rm-graph-search-results&#039;);&lt;br /&gt;
    searchResults.hidden = true;&lt;br /&gt;
    searchWrap.append(search, searchResults);&lt;br /&gt;
&lt;br /&gt;
    var filter = makeElement(&#039;select&#039;, &#039;s3rm-graph-filter&#039;);&lt;br /&gt;
    filter.setAttribute(&#039;aria-label&#039;, &#039;관계 유형 필터&#039;);&lt;br /&gt;
    var allOption = makeElement(&#039;option&#039;, &#039;&#039;, &#039;모든 관계&#039;);&lt;br /&gt;
    allOption.value = &#039;all&#039;;&lt;br /&gt;
    filter.appendChild(allOption);&lt;br /&gt;
    RELATIONS.forEach(function (relation) {&lt;br /&gt;
      var option = makeElement(&#039;option&#039;, &#039;&#039;, relation.label);&lt;br /&gt;
      option.value = relation.key;&lt;br /&gt;
      filter.appendChild(option);&lt;br /&gt;
    });&lt;br /&gt;
    var reset = makeElement(&#039;button&#039;, &#039;s3rm-graph-reset&#039;, &#039;화면 맞춤&#039;);&lt;br /&gt;
    reset.type = &#039;button&#039;;&lt;br /&gt;
    var clearSearch = makeElement(&#039;button&#039;, &#039;s3rm-graph-clear-search&#039;, &#039;검색 결과 해제&#039;);&lt;br /&gt;
    clearSearch.type = &#039;button&#039;;&lt;br /&gt;
    clearSearch.hidden = true;&lt;br /&gt;
    toolbar.append(searchWrap, filter, reset, clearSearch);&lt;br /&gt;
&lt;br /&gt;
    var body = makeElement(&#039;div&#039;, &#039;s3rm-graph-body&#039;);&lt;br /&gt;
    var viewport = makeElement(&#039;div&#039;, &#039;s3rm-graph-viewport&#039;);&lt;br /&gt;
    var canvas = document.createElement(&#039;canvas&#039;);&lt;br /&gt;
    canvas.className = &#039;s3rm-graph-canvas&#039;;&lt;br /&gt;
    canvas.setAttribute(&#039;aria-hidden&#039;, &#039;true&#039;);&lt;br /&gt;
    viewport.appendChild(canvas);&lt;br /&gt;
    var hint = makeElement(&#039;div&#039;, &#039;s3rm-graph-hint&#039;, &#039;드래그하여 이동 · 스크롤하여 확대 · 두 번 눌러 메모 열기&#039;);&lt;br /&gt;
    viewport.appendChild(hint);&lt;br /&gt;
&lt;br /&gt;
    var inspector = makeElement(&#039;aside&#039;, &#039;s3rm-graph-inspector&#039;);&lt;br /&gt;
    inspector.setAttribute(&#039;aria-live&#039;, &#039;polite&#039;);&lt;br /&gt;
    body.append(viewport, inspector);&lt;br /&gt;
&lt;br /&gt;
    var footer = makeElement(&#039;div&#039;, &#039;s3rm-graph-footer&#039;);&lt;br /&gt;
    var counts = makeElement(&#039;span&#039;, &#039;s3rm-graph-counts&#039;, nodes.length + &#039;개 메모 · &#039; + edges.length + &#039;개 연결&#039;);&lt;br /&gt;
    var legend = makeElement(&#039;div&#039;, &#039;s3rm-graph-legend&#039;);&lt;br /&gt;
    RELATIONS.slice(0, 4).forEach(function (relation) {&lt;br /&gt;
      var item = makeElement(&#039;span&#039;, &#039;s3rm-graph-legend-item&#039;);&lt;br /&gt;
      var swatch = makeElement(&#039;i&#039;, &#039;&#039;);&lt;br /&gt;
      swatch.style.backgroundColor = relation.color;&lt;br /&gt;
      item.append(swatch, document.createTextNode(relation.label));&lt;br /&gt;
      legend.appendChild(item);&lt;br /&gt;
    });&lt;br /&gt;
    footer.append(counts, legend);&lt;br /&gt;
    root.append(toolbar, body, footer);&lt;br /&gt;
&lt;br /&gt;
    var context = canvas.getContext(&#039;2d&#039;);&lt;br /&gt;
    var size = { width: 0, height: 0, ratio: 1 };&lt;br /&gt;
&lt;br /&gt;
    function visibleEdges() {&lt;br /&gt;
      return activeRelation === &#039;all&#039;&lt;br /&gt;
        ? edges&lt;br /&gt;
        : edges.filter(function (edge) { return edge.relation.key === activeRelation; });&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function nodeRadius(node) {&lt;br /&gt;
      return 6 + Math.min(8, Math.sqrt(node.degree) * 2.2) + (node.isSearchResult ? 3 : 0);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function confidenceColor(confidence) {&lt;br /&gt;
      if (confidence === &#039;high&#039;) { return &#039;#53c18b&#039;; }&lt;br /&gt;
      if (confidence === &#039;medium&#039;) { return &#039;#8ca8ff&#039;; }&lt;br /&gt;
      return &#039;#c0c5d8&#039;;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function resize() {&lt;br /&gt;
      var rect = viewport.getBoundingClientRect();&lt;br /&gt;
      size.width = Math.max(320, rect.width);&lt;br /&gt;
      size.height = Math.max(360, rect.height);&lt;br /&gt;
      size.ratio = Math.min(window.devicePixelRatio || 1, 2);&lt;br /&gt;
      canvas.width = Math.round(size.width * size.ratio);&lt;br /&gt;
      canvas.height = Math.round(size.height * size.ratio);&lt;br /&gt;
      canvas.style.width = size.width + &#039;px&#039;;&lt;br /&gt;
      canvas.style.height = size.height + &#039;px&#039;;&lt;br /&gt;
      draw();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function fitGraph() {&lt;br /&gt;
      if (!nodes.length) { return; }&lt;br /&gt;
      var xs = nodes.map(function (node) { return node.x; });&lt;br /&gt;
      var ys = nodes.map(function (node) { return node.y; });&lt;br /&gt;
      var minX = Math.min.apply(null, xs) - 38;&lt;br /&gt;
      var maxX = Math.max.apply(null, xs) + 38;&lt;br /&gt;
      var minY = Math.min.apply(null, ys) - 38;&lt;br /&gt;
      var maxY = Math.max.apply(null, ys) + 38;&lt;br /&gt;
      transform.scale = Math.max(0.2, Math.min(1.25, Math.min(&lt;br /&gt;
        size.width / Math.max(1, maxX - minX),&lt;br /&gt;
        size.height / Math.max(1, maxY - minY)&lt;br /&gt;
      )));&lt;br /&gt;
      transform.x = size.width / 2 - ((minX + maxX) / 2) * transform.scale;&lt;br /&gt;
      transform.y = size.height / 2 - ((minY + maxY) / 2) * transform.scale;&lt;br /&gt;
      draw();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function draw() {&lt;br /&gt;
      context.setTransform(size.ratio, 0, 0, size.ratio, 0, 0);&lt;br /&gt;
      context.clearRect(0, 0, size.width, size.height);&lt;br /&gt;
      context.save();&lt;br /&gt;
      context.translate(transform.x, transform.y);&lt;br /&gt;
      context.scale(transform.scale, transform.scale);&lt;br /&gt;
&lt;br /&gt;
      if (searchCluster) {&lt;br /&gt;
        context.beginPath();&lt;br /&gt;
        context.arc(&lt;br /&gt;
          searchCluster.x,&lt;br /&gt;
          searchCluster.y,&lt;br /&gt;
          searchCluster.radius + 48,&lt;br /&gt;
          0,&lt;br /&gt;
          Math.PI * 2&lt;br /&gt;
        );&lt;br /&gt;
        context.fillStyle = &#039;rgba(104, 126, 233, 0.12)&#039;;&lt;br /&gt;
        context.fill();&lt;br /&gt;
        context.setLineDash([7 / transform.scale, 6 / transform.scale]);&lt;br /&gt;
        context.lineWidth = 1.5 / transform.scale;&lt;br /&gt;
        context.strokeStyle = &#039;rgba(140, 168, 255, 0.75)&#039;;&lt;br /&gt;
        context.stroke();&lt;br /&gt;
        context.setLineDash([]);&lt;br /&gt;
        context.font = &#039;700 &#039; + (12 / transform.scale) + &#039;px sans-serif&#039;;&lt;br /&gt;
        context.textAlign = &#039;center&#039;;&lt;br /&gt;
        context.textBaseline = &#039;bottom&#039;;&lt;br /&gt;
        context.fillStyle = &#039;#b9c7ff&#039;;&lt;br /&gt;
        context.fillText(&lt;br /&gt;
          &#039;검색 결과 · &#039; + searchCluster.nodes.length + &#039;개 메모&#039;,&lt;br /&gt;
          searchCluster.x,&lt;br /&gt;
          searchCluster.y - searchCluster.radius - 58 / transform.scale&lt;br /&gt;
        );&lt;br /&gt;
      }&lt;br /&gt;
&lt;br /&gt;
      visibleEdges().forEach(function (edge) {&lt;br /&gt;
        var emphasized = selected &amp;amp;&amp;amp; (edge.source === selected || edge.target === selected);&lt;br /&gt;
        var searchEdge = searchCluster &amp;amp;&amp;amp;&lt;br /&gt;
          (edge.source.isSearchResult || edge.target.isSearchResult);&lt;br /&gt;
        context.globalAlpha = searchCluster &amp;amp;&amp;amp; !searchEdge ? 0.1 : 1;&lt;br /&gt;
        context.beginPath();&lt;br /&gt;
        context.moveTo(edge.source.x, edge.source.y);&lt;br /&gt;
        context.lineTo(edge.target.x, edge.target.y);&lt;br /&gt;
        context.strokeStyle = emphasized ? edge.relation.color : edge.relation.color + &#039;55&#039;;&lt;br /&gt;
        context.lineWidth = (emphasized || searchEdge ? 2.2 : 1) / transform.scale;&lt;br /&gt;
        context.stroke();&lt;br /&gt;
      });&lt;br /&gt;
      context.globalAlpha = 1;&lt;br /&gt;
&lt;br /&gt;
      nodes.forEach(function (node) {&lt;br /&gt;
        var isSelected = node === selected;&lt;br /&gt;
        var isHovered = node === hovered;&lt;br /&gt;
        var isSearchResult = Boolean(node.isSearchResult);&lt;br /&gt;
        var radius = nodeRadius(node);&lt;br /&gt;
        context.globalAlpha = searchCluster &amp;amp;&amp;amp; !isSearchResult ? 0.12 : 1;&lt;br /&gt;
        context.beginPath();&lt;br /&gt;
        context.arc(node.x, node.y, radius + (isSelected ? 4 : 0), 0, Math.PI * 2);&lt;br /&gt;
        context.fillStyle = isSelected || isSearchResult ? &#039;#ffffff&#039; : confidenceColor(node.confidence);&lt;br /&gt;
        context.fill();&lt;br /&gt;
        context.lineWidth = (isSelected || isSearchResult ? 3 : 1.5) / transform.scale;&lt;br /&gt;
        context.strokeStyle = isSelected || isSearchResult ? &#039;#8ca8ff&#039; : &#039;#11182e&#039;;&lt;br /&gt;
        context.stroke();&lt;br /&gt;
&lt;br /&gt;
        if (isSelected || isHovered || isSearchResult ||&lt;br /&gt;
          (transform.scale &amp;gt; 0.68 &amp;amp;&amp;amp; node.degree &amp;gt; 0)) {&lt;br /&gt;
          context.font = (isSelected || isSearchResult ? &#039;600 &#039; : &#039;&#039;) + (12 / transform.scale) + &#039;px sans-serif&#039;;&lt;br /&gt;
          context.textAlign = &#039;center&#039;;&lt;br /&gt;
          context.textBaseline = &#039;top&#039;;&lt;br /&gt;
          context.fillStyle = &#039;#f5f7ff&#039;;&lt;br /&gt;
          context.shadowColor = &#039;#080b16&#039;;&lt;br /&gt;
          context.shadowBlur = 5 / transform.scale;&lt;br /&gt;
          var label = node.title.length &amp;gt; 26 ? node.title.slice(0, 25) + &#039;…&#039; : node.title;&lt;br /&gt;
          context.fillText(label, node.x, node.y + radius + 7 / transform.scale);&lt;br /&gt;
          context.shadowBlur = 0;&lt;br /&gt;
        }&lt;br /&gt;
      });&lt;br /&gt;
      context.globalAlpha = 1;&lt;br /&gt;
      context.restore();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function startSimulation(frames, shouldFit) {&lt;br /&gt;
      simulationFrames = Math.max(simulationFrames, reduceMotion ? 1 : frames);&lt;br /&gt;
      fitAfterSimulation = fitAfterSimulation || Boolean(shouldFit);&lt;br /&gt;
      if (!simulationRunning) {&lt;br /&gt;
        simulationRunning = true;&lt;br /&gt;
        animationFrame = window.requestAnimationFrame(simulate);&lt;br /&gt;
      }&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function simulate() {&lt;br /&gt;
      if (simulationFrames &amp;lt;= 0) {&lt;br /&gt;
        simulationRunning = false;&lt;br /&gt;
        if (fitAfterSimulation &amp;amp;&amp;amp; !searchCluster) {&lt;br /&gt;
          fitGraph();&lt;br /&gt;
        } else {&lt;br /&gt;
          draw();&lt;br /&gt;
        }&lt;br /&gt;
        fitAfterSimulation = false;&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
&lt;br /&gt;
      for (var i = 0; i &amp;lt; nodes.length; i += 1) {&lt;br /&gt;
        for (var j = i + 1; j &amp;lt; nodes.length; j += 1) {&lt;br /&gt;
          var first = nodes[i];&lt;br /&gt;
          var second = nodes[j];&lt;br /&gt;
          var isolatedPair = first.degree === 0 &amp;amp;&amp;amp; second.degree === 0;&lt;br /&gt;
          if (isolatedPair &amp;amp;&amp;amp; !first.motionActive &amp;amp;&amp;amp; !second.motionActive &amp;amp;&amp;amp;&lt;br /&gt;
            first !== dragging &amp;amp;&amp;amp; second !== dragging) {&lt;br /&gt;
            continue;&lt;br /&gt;
          }&lt;br /&gt;
          var dx = second.x - first.x || 0.01;&lt;br /&gt;
          var dy = second.y - first.y || 0.01;&lt;br /&gt;
          var distanceSquared = dx * dx + dy * dy;&lt;br /&gt;
          var distance = Math.sqrt(distanceSquared) || 0.01;&lt;br /&gt;
          var minimumDistance = nodeRadius(first) + nodeRadius(second) + 7;&lt;br /&gt;
          var force = distance &amp;lt; minimumDistance&lt;br /&gt;
            ? (minimumDistance - distance) * 0.035 / distance&lt;br /&gt;
            : 1.8 / Math.max(80, distanceSquared);&lt;br /&gt;
          if (first !== dragging) {&lt;br /&gt;
            first.vx -= dx * force;&lt;br /&gt;
            first.vy -= dy * force;&lt;br /&gt;
          }&lt;br /&gt;
          if (second !== dragging) {&lt;br /&gt;
            second.vx += dx * force;&lt;br /&gt;
            second.vy += dy * force;&lt;br /&gt;
          }&lt;br /&gt;
        }&lt;br /&gt;
      }&lt;br /&gt;
      edges.forEach(function (edge) {&lt;br /&gt;
        var dx = edge.target.x - edge.source.x;&lt;br /&gt;
        var dy = edge.target.y - edge.source.y;&lt;br /&gt;
        var distance = Math.sqrt(dx * dx + dy * dy) || 1;&lt;br /&gt;
        var force = (distance - 64) * 0.0014;&lt;br /&gt;
        if (edge.source !== dragging) {&lt;br /&gt;
          edge.source.vx += dx * force;&lt;br /&gt;
          edge.source.vy += dy * force;&lt;br /&gt;
        }&lt;br /&gt;
        if (edge.target !== dragging) {&lt;br /&gt;
          edge.target.vx -= dx * force;&lt;br /&gt;
          edge.target.vy -= dy * force;&lt;br /&gt;
        }&lt;br /&gt;
      });&lt;br /&gt;
      nodes.forEach(function (node) {&lt;br /&gt;
        if (node === dragging) {&lt;br /&gt;
          return;&lt;br /&gt;
        }&lt;br /&gt;
        if (node.isSearchResult &amp;amp;&amp;amp; !node.motionActive) {&lt;br /&gt;
          node.vx = 0;&lt;br /&gt;
          node.vy = 0;&lt;br /&gt;
          return;&lt;br /&gt;
        }&lt;br /&gt;
        if (node.degree === 0 &amp;amp;&amp;amp; !node.motionActive) {&lt;br /&gt;
          node.vx = 0;&lt;br /&gt;
          node.vy = 0;&lt;br /&gt;
          return;&lt;br /&gt;
        }&lt;br /&gt;
        if (node.degree === 0) {&lt;br /&gt;
          node.vx += (node.homeX - node.x) * 0.0022;&lt;br /&gt;
          node.vy += (node.homeY - node.y) * 0.0022;&lt;br /&gt;
        } else {&lt;br /&gt;
          node.vx += -node.x * 0.0018;&lt;br /&gt;
          node.vy += -node.y * 0.0018;&lt;br /&gt;
        }&lt;br /&gt;
        node.vx *= 0.92;&lt;br /&gt;
        node.vy *= 0.92;&lt;br /&gt;
        node.x += node.vx;&lt;br /&gt;
        node.y += node.vy;&lt;br /&gt;
        if (node.motionActive &amp;amp;&amp;amp; Math.abs(node.vx) + Math.abs(node.vy) &amp;lt; 0.025) {&lt;br /&gt;
          delete node.motionActive;&lt;br /&gt;
        }&lt;br /&gt;
      });&lt;br /&gt;
      simulationFrames -= 1;&lt;br /&gt;
      draw();&lt;br /&gt;
      animationFrame = window.requestAnimationFrame(simulate);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function relationDefinition(key) {&lt;br /&gt;
      return RELATIONS.find(function (relation) { return relation.key === key; });&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function updateCounts() {&lt;br /&gt;
      if (searchCluster) {&lt;br /&gt;
        var relatedEdges = edges.filter(function (edge) {&lt;br /&gt;
          return edge.source.isSearchResult || edge.target.isSearchResult;&lt;br /&gt;
        }).length;&lt;br /&gt;
        counts.textContent = &#039;검색 결과 &#039; + searchCluster.nodes.length + &#039;개 · &#039; +&lt;br /&gt;
          relatedEdges + &#039;개 연결&#039;;&lt;br /&gt;
      } else {&lt;br /&gt;
        counts.textContent = nodes.length + &#039;개 메모 · &#039; + visibleEdges().length + &#039;개 연결&#039;;&lt;br /&gt;
      }&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function resetSearchCluster() {&lt;br /&gt;
      var addedKeys = Object.create(null);&lt;br /&gt;
      edges.forEach(function (edge) {&lt;br /&gt;
        if (edge.searchAdded) {&lt;br /&gt;
          edge.source.degree = Math.max(0, edge.source.degree - 1);&lt;br /&gt;
          edge.target.degree = Math.max(0, edge.target.degree - 1);&lt;br /&gt;
        }&lt;br /&gt;
      });&lt;br /&gt;
      nodes.forEach(function (node) {&lt;br /&gt;
        if (node.searchAdded) {&lt;br /&gt;
          addedKeys[canonicalId(node.id)] = true;&lt;br /&gt;
          delete byId[canonicalId(node.id)];&lt;br /&gt;
        } else if (node.preSearchPosition) {&lt;br /&gt;
          node.x = node.preSearchPosition.x;&lt;br /&gt;
          node.y = node.preSearchPosition.y;&lt;br /&gt;
          node.homeX = node.preSearchHome.x;&lt;br /&gt;
          node.homeY = node.preSearchHome.y;&lt;br /&gt;
        }&lt;br /&gt;
        delete node.isSearchResult;&lt;br /&gt;
        delete node.preSearchPosition;&lt;br /&gt;
        delete node.preSearchHome;&lt;br /&gt;
        delete node.motionActive;&lt;br /&gt;
      });&lt;br /&gt;
      nodes = nodes.filter(function (node) { return !node.searchAdded; });&lt;br /&gt;
      edges = edges.filter(function (edge) {&lt;br /&gt;
        return !edge.searchAdded &amp;amp;&amp;amp;&lt;br /&gt;
          !addedKeys[canonicalId(edge.source.id)] &amp;amp;&amp;amp;&lt;br /&gt;
          !addedKeys[canonicalId(edge.target.id)];&lt;br /&gt;
      });&lt;br /&gt;
      searchCluster = null;&lt;br /&gt;
      clearSearch.hidden = true;&lt;br /&gt;
      selected = null;&lt;br /&gt;
      showInspector(null);&lt;br /&gt;
      updateCounts();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function focusSearchResults(items, query) {&lt;br /&gt;
      resetSearchCluster();&lt;br /&gt;
      if (!items.length) {&lt;br /&gt;
        fitGraph();&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
&lt;br /&gt;
      var resultNodes = [];&lt;br /&gt;
      items.forEach(function (item, index) {&lt;br /&gt;
        var key = canonicalId(item.id);&lt;br /&gt;
        var node = byId[key];&lt;br /&gt;
        if (!node) {&lt;br /&gt;
          node = {&lt;br /&gt;
            id: item.id,&lt;br /&gt;
            title: item.title || String(item.id).replace(/^Lesson:/, &#039;&#039;),&lt;br /&gt;
            confidence: item.confidence || &#039;low&#039;,&lt;br /&gt;
            updatedAt: &#039;&#039;,&lt;br /&gt;
            x: 0,&lt;br /&gt;
            y: 0,&lt;br /&gt;
            vx: 0,&lt;br /&gt;
            vy: 0,&lt;br /&gt;
            degree: 0,&lt;br /&gt;
            homeX: 0,&lt;br /&gt;
            homeY: 0,&lt;br /&gt;
            searchAdded: true&lt;br /&gt;
          };&lt;br /&gt;
          nodes.push(node);&lt;br /&gt;
          byId[key] = node;&lt;br /&gt;
        } else {&lt;br /&gt;
          node.preSearchPosition = { x: node.x, y: node.y };&lt;br /&gt;
          node.preSearchHome = { x: node.homeX, y: node.homeY };&lt;br /&gt;
          node.title = item.title || node.title;&lt;br /&gt;
          node.confidence = item.confidence || node.confidence;&lt;br /&gt;
        }&lt;br /&gt;
        node.isSearchResult = true;&lt;br /&gt;
        node.searchRank = index + 1;&lt;br /&gt;
        resultNodes.push(node);&lt;br /&gt;
      });&lt;br /&gt;
&lt;br /&gt;
      items.forEach(function (item) {&lt;br /&gt;
        (Array.isArray(item.relations) ? item.relations : []).forEach(function (relationItem) {&lt;br /&gt;
          var source = byId[canonicalId(relationItem.from || item.id)];&lt;br /&gt;
          var target = byId[canonicalId(relationItem.to)];&lt;br /&gt;
          var relation = relationDefinition(relationItem.relation);&lt;br /&gt;
          if (!source || !target || !relation || source === target) {&lt;br /&gt;
            return;&lt;br /&gt;
          }&lt;br /&gt;
          var duplicate = edges.some(function (edge) {&lt;br /&gt;
            return canonicalId(edge.source.id) === canonicalId(source.id) &amp;amp;&amp;amp;&lt;br /&gt;
              canonicalId(edge.target.id) === canonicalId(target.id) &amp;amp;&amp;amp;&lt;br /&gt;
              edge.relation.key === relation.key;&lt;br /&gt;
          });&lt;br /&gt;
          if (!duplicate) {&lt;br /&gt;
            edges.push({&lt;br /&gt;
              source: source,&lt;br /&gt;
              target: target,&lt;br /&gt;
              relation: relation,&lt;br /&gt;
              searchAdded: true&lt;br /&gt;
            });&lt;br /&gt;
            source.degree += 1;&lt;br /&gt;
            target.degree += 1;&lt;br /&gt;
          }&lt;br /&gt;
        });&lt;br /&gt;
      });&lt;br /&gt;
&lt;br /&gt;
      var clusterRadius = resultNodes.length === 1 ? 0 :&lt;br /&gt;
        Math.max(82, Math.min(132, resultNodes.length * 22));&lt;br /&gt;
      resultNodes.forEach(function (node, index) {&lt;br /&gt;
        var angle = -Math.PI / 2 + (Math.PI * 2 * index / resultNodes.length);&lt;br /&gt;
        node.x = Math.cos(angle) * clusterRadius;&lt;br /&gt;
        node.y = Math.sin(angle) * clusterRadius;&lt;br /&gt;
        node.homeX = node.x;&lt;br /&gt;
        node.homeY = node.y;&lt;br /&gt;
        node.vx = 0;&lt;br /&gt;
        node.vy = 0;&lt;br /&gt;
      });&lt;br /&gt;
      searchCluster = {&lt;br /&gt;
        query: query,&lt;br /&gt;
        nodes: resultNodes,&lt;br /&gt;
        x: 0,&lt;br /&gt;
        y: 0,&lt;br /&gt;
        radius: clusterRadius&lt;br /&gt;
      };&lt;br /&gt;
      activeRelation = &#039;all&#039;;&lt;br /&gt;
      filter.value = &#039;all&#039;;&lt;br /&gt;
      clearSearch.hidden = false;&lt;br /&gt;
      transform.scale = Math.max(0.72, Math.min(&lt;br /&gt;
        1.35,&lt;br /&gt;
        Math.min(size.width, size.height) / Math.max(360, clusterRadius * 2 + 150)&lt;br /&gt;
      ));&lt;br /&gt;
      transform.x = size.width / 2;&lt;br /&gt;
      transform.y = size.height / 2;&lt;br /&gt;
      selectNode(resultNodes[0]);&lt;br /&gt;
      updateCounts();&lt;br /&gt;
      draw();&lt;br /&gt;
      viewport.scrollIntoView({ behavior: &#039;smooth&#039;, block: &#039;center&#039; });&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function connectionSummary(node) {&lt;br /&gt;
      var connected = edges.filter(function (edge) {&lt;br /&gt;
        return edge.source === node || edge.target === node;&lt;br /&gt;
      });&lt;br /&gt;
      var summary = Object.create(null);&lt;br /&gt;
      connected.forEach(function (edge) {&lt;br /&gt;
        summary[edge.relation.label] = (summary[edge.relation.label] || 0) + 1;&lt;br /&gt;
      });&lt;br /&gt;
      return { edges: connected, counts: summary };&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function showInspector(node) {&lt;br /&gt;
      inspector.replaceChildren();&lt;br /&gt;
      if (!node) {&lt;br /&gt;
        var welcome = makeElement(&#039;div&#039;, &#039;s3rm-inspector-welcome&#039;);&lt;br /&gt;
        welcome.appendChild(makeElement(&#039;span&#039;, &#039;s3rm-inspector-symbol&#039;, &#039;⌁&#039;));&lt;br /&gt;
        welcome.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;메모를 선택하세요&#039;));&lt;br /&gt;
        welcome.appendChild(makeElement(&#039;p&#039;, &#039;&#039;, &#039;점 하나를 누르면 메모 정보와 연결된 기록을 보여 드립니다.&#039;));&lt;br /&gt;
        inspector.appendChild(welcome);&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      var summary = connectionSummary(node);&lt;br /&gt;
      inspector.appendChild(makeElement(&lt;br /&gt;
        &#039;div&#039;,&lt;br /&gt;
        &#039;s3rm-inspector-kicker&#039;,&lt;br /&gt;
        node.isSearchResult ? &#039;검색 결과 &#039; + node.searchRank : &#039;선택한 메모&#039;&lt;br /&gt;
      ));&lt;br /&gt;
      inspector.appendChild(makeElement(&#039;h3&#039;, &#039;&#039;, node.title));&lt;br /&gt;
      var meta = makeElement(&#039;div&#039;, &#039;s3rm-inspector-meta&#039;);&lt;br /&gt;
      meta.appendChild(makeElement(&#039;span&#039;, &#039;s3rm-confidence s3rm-confidence-&#039; + node.confidence, confidenceLabel(node.confidence)));&lt;br /&gt;
      meta.appendChild(makeElement(&#039;span&#039;, &#039;&#039;, summary.edges.length + &#039;개 연결&#039;));&lt;br /&gt;
      inspector.appendChild(meta);&lt;br /&gt;
      if (Object.keys(summary.counts).length) {&lt;br /&gt;
        var relationList = makeElement(&#039;ul&#039;, &#039;s3rm-inspector-relations&#039;);&lt;br /&gt;
        Object.keys(summary.counts).forEach(function (label) {&lt;br /&gt;
          var item = makeElement(&#039;li&#039;, &#039;&#039;);&lt;br /&gt;
          item.append(makeElement(&#039;span&#039;, &#039;&#039;, label), makeElement(&#039;strong&#039;, &#039;&#039;, String(summary.counts[label])));&lt;br /&gt;
          relationList.appendChild(item);&lt;br /&gt;
        });&lt;br /&gt;
        inspector.appendChild(relationList);&lt;br /&gt;
      } else {&lt;br /&gt;
        inspector.appendChild(makeElement(&#039;p&#039;, &#039;s3rm-inspector-muted&#039;, &#039;아직 다른 메모와 연결되지 않았습니다.&#039;));&lt;br /&gt;
      }&lt;br /&gt;
      var open = makeElement(&#039;a&#039;, &#039;s3rm-graph-primary-link&#039;, &#039;메모 열기&#039;);&lt;br /&gt;
      open.href = mw.util.getUrl(node.id);&lt;br /&gt;
      inspector.appendChild(open);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function canvasPoint(event) {&lt;br /&gt;
      var rect = canvas.getBoundingClientRect();&lt;br /&gt;
      return { x: event.clientX - rect.left, y: event.clientY - rect.top };&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function worldPoint(event) {&lt;br /&gt;
      var point = canvasPoint(event);&lt;br /&gt;
      return {&lt;br /&gt;
        x: (point.x - transform.x) / transform.scale,&lt;br /&gt;
        y: (point.y - transform.y) / transform.scale&lt;br /&gt;
      };&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function hitNode(event) {&lt;br /&gt;
      var point = worldPoint(event);&lt;br /&gt;
      for (var i = nodes.length - 1; i &amp;gt;= 0; i -= 1) {&lt;br /&gt;
        var node = nodes[i];&lt;br /&gt;
        var dx = point.x - node.x;&lt;br /&gt;
        var dy = point.y - node.y;&lt;br /&gt;
        if (dx * dx + dy * dy &amp;lt;= Math.pow(nodeRadius(node) + 6 / transform.scale, 2)) {&lt;br /&gt;
          return node;&lt;br /&gt;
        }&lt;br /&gt;
      }&lt;br /&gt;
      return null;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function selectNode(node) {&lt;br /&gt;
      selected = node;&lt;br /&gt;
      showInspector(node);&lt;br /&gt;
      draw();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    canvas.addEventListener(&#039;pointerdown&#039;, function (event) {&lt;br /&gt;
      dragging = hitNode(event);&lt;br /&gt;
      panning = !dragging;&lt;br /&gt;
      pointerStart = canvasPoint(event);&lt;br /&gt;
      if (dragging) {&lt;br /&gt;
        var world = worldPoint(event);&lt;br /&gt;
        dragMotion = {&lt;br /&gt;
          x: world.x,&lt;br /&gt;
          y: world.y,&lt;br /&gt;
          time: event.timeStamp,&lt;br /&gt;
          vx: 0,&lt;br /&gt;
          vy: 0&lt;br /&gt;
        };&lt;br /&gt;
        dragging.motionActive = true;&lt;br /&gt;
        startSimulation(120, false);&lt;br /&gt;
      }&lt;br /&gt;
      canvas.setPointerCapture(event.pointerId);&lt;br /&gt;
      canvas.classList.add(&#039;is-dragging&#039;);&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;pointermove&#039;, function (event) {&lt;br /&gt;
      if (dragging) {&lt;br /&gt;
        var world = worldPoint(event);&lt;br /&gt;
        if (dragMotion) {&lt;br /&gt;
          var elapsed = Math.max(8, event.timeStamp - dragMotion.time);&lt;br /&gt;
          var frameRatio = 16.67 / elapsed;&lt;br /&gt;
          var instantVX = (world.x - dragMotion.x) * frameRatio;&lt;br /&gt;
          var instantVY = (world.y - dragMotion.y) * frameRatio;&lt;br /&gt;
          dragMotion.vx = dragMotion.vx * 0.45 + instantVX * 0.55;&lt;br /&gt;
          dragMotion.vy = dragMotion.vy * 0.45 + instantVY * 0.55;&lt;br /&gt;
          var speed = Math.sqrt(dragMotion.vx * dragMotion.vx + dragMotion.vy * dragMotion.vy);&lt;br /&gt;
          if (speed &amp;gt; 24) {&lt;br /&gt;
            dragMotion.vx = dragMotion.vx / speed * 24;&lt;br /&gt;
            dragMotion.vy = dragMotion.vy / speed * 24;&lt;br /&gt;
          }&lt;br /&gt;
          dragMotion.x = world.x;&lt;br /&gt;
          dragMotion.y = world.y;&lt;br /&gt;
          dragMotion.time = event.timeStamp;&lt;br /&gt;
        }&lt;br /&gt;
        dragging.x = world.x;&lt;br /&gt;
        dragging.y = world.y;&lt;br /&gt;
        startSimulation(120, false);&lt;br /&gt;
      } else if (panning &amp;amp;&amp;amp; pointerStart) {&lt;br /&gt;
        var point = canvasPoint(event);&lt;br /&gt;
        transform.x += point.x - pointerStart.x;&lt;br /&gt;
        transform.y += point.y - pointerStart.y;&lt;br /&gt;
        pointerStart = point;&lt;br /&gt;
      } else {&lt;br /&gt;
        hovered = hitNode(event);&lt;br /&gt;
        canvas.style.cursor = hovered ? &#039;pointer&#039; : &#039;grab&#039;;&lt;br /&gt;
      }&lt;br /&gt;
      draw();&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;pointerup&#039;, function (event) {&lt;br /&gt;
      var hit = hitNode(event);&lt;br /&gt;
      var releasedNode = dragging;&lt;br /&gt;
      if (releasedNode &amp;amp;&amp;amp; dragMotion) {&lt;br /&gt;
        releasedNode.vx = reduceMotion ? 0 : dragMotion.vx;&lt;br /&gt;
        releasedNode.vy = reduceMotion ? 0 : dragMotion.vy;&lt;br /&gt;
      }&lt;br /&gt;
      dragging = null;&lt;br /&gt;
      panning = false;&lt;br /&gt;
      pointerStart = null;&lt;br /&gt;
      dragMotion = null;&lt;br /&gt;
      canvas.classList.remove(&#039;is-dragging&#039;);&lt;br /&gt;
      if (releasedNode) {&lt;br /&gt;
        if (hit === releasedNode) {&lt;br /&gt;
          selectNode(releasedNode);&lt;br /&gt;
        }&lt;br /&gt;
        startSimulation(140, false);&lt;br /&gt;
      }&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;pointercancel&#039;, function () {&lt;br /&gt;
      if (dragging) {&lt;br /&gt;
        dragging.vx = 0;&lt;br /&gt;
        dragging.vy = 0;&lt;br /&gt;
        delete dragging.motionActive;&lt;br /&gt;
      }&lt;br /&gt;
      dragging = null;&lt;br /&gt;
      panning = false;&lt;br /&gt;
      pointerStart = null;&lt;br /&gt;
      dragMotion = null;&lt;br /&gt;
      canvas.classList.remove(&#039;is-dragging&#039;);&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;dblclick&#039;, function (event) {&lt;br /&gt;
      var node = hitNode(event);&lt;br /&gt;
      if (node) {&lt;br /&gt;
        window.location.href = mw.util.getUrl(node.id);&lt;br /&gt;
      }&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;wheel&#039;, function (event) {&lt;br /&gt;
      event.preventDefault();&lt;br /&gt;
      var point = canvasPoint(event);&lt;br /&gt;
      var oldScale = transform.scale;&lt;br /&gt;
      var nextScale = Math.max(0.25, Math.min(3.5, oldScale * Math.exp(-event.deltaY * 0.0012)));&lt;br /&gt;
      transform.x = point.x - ((point.x - transform.x) / oldScale) * nextScale;&lt;br /&gt;
      transform.y = point.y - ((point.y - transform.y) / oldScale) * nextScale;&lt;br /&gt;
      transform.scale = nextScale;&lt;br /&gt;
      draw();&lt;br /&gt;
    }, { passive: false });&lt;br /&gt;
&lt;br /&gt;
    search.addEventListener(&#039;input&#039;, function () {&lt;br /&gt;
      var query = search.value.trim().toLocaleLowerCase(&#039;ko&#039;);&lt;br /&gt;
      searchResults.replaceChildren();&lt;br /&gt;
      if (!query) {&lt;br /&gt;
        searchResults.hidden = true;&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      var matches = nodes.filter(function (node) {&lt;br /&gt;
        return node.title.toLocaleLowerCase(&#039;ko&#039;).includes(query) || node.id.toLowerCase().includes(query);&lt;br /&gt;
      }).slice(0, 6);&lt;br /&gt;
      matches.forEach(function (node) {&lt;br /&gt;
        var button = makeElement(&#039;button&#039;, &#039;&#039;, node.title);&lt;br /&gt;
        button.type = &#039;button&#039;;&lt;br /&gt;
        button.addEventListener(&#039;click&#039;, function () {&lt;br /&gt;
          selectNode(node);&lt;br /&gt;
          transform.x = size.width / 2 - node.x * transform.scale;&lt;br /&gt;
          transform.y = size.height / 2 - node.y * transform.scale;&lt;br /&gt;
          search.value = node.title;&lt;br /&gt;
          searchResults.hidden = true;&lt;br /&gt;
          draw();&lt;br /&gt;
        });&lt;br /&gt;
        searchResults.appendChild(button);&lt;br /&gt;
      });&lt;br /&gt;
      if (!matches.length) {&lt;br /&gt;
        searchResults.appendChild(makeElement(&#039;span&#039;, &#039;&#039;, &#039;일치하는 메모가 없습니다.&#039;));&lt;br /&gt;
      }&lt;br /&gt;
      searchResults.hidden = false;&lt;br /&gt;
    });&lt;br /&gt;
    search.addEventListener(&#039;keydown&#039;, function (event) {&lt;br /&gt;
      if (event.key === &#039;Escape&#039;) {&lt;br /&gt;
        searchResults.hidden = true;&lt;br /&gt;
      } else if (event.key === &#039;Enter&#039;) {&lt;br /&gt;
        var first = searchResults.querySelector(&#039;button&#039;);&lt;br /&gt;
        if (first) { first.click(); }&lt;br /&gt;
      }&lt;br /&gt;
    });&lt;br /&gt;
    filter.addEventListener(&#039;change&#039;, function () {&lt;br /&gt;
      activeRelation = filter.value;&lt;br /&gt;
      updateCounts();&lt;br /&gt;
      draw();&lt;br /&gt;
    });&lt;br /&gt;
    reset.addEventListener(&#039;click&#039;, fitGraph);&lt;br /&gt;
    clearSearch.addEventListener(&#039;click&#039;, function () {&lt;br /&gt;
      resetSearchCluster();&lt;br /&gt;
      fitGraph();&lt;br /&gt;
    });&lt;br /&gt;
&lt;br /&gt;
    showInspector(null);&lt;br /&gt;
    resize();&lt;br /&gt;
    var resizeObserver = window.ResizeObserver ? new ResizeObserver(resize) : null;&lt;br /&gt;
    if (resizeObserver) {&lt;br /&gt;
      resizeObserver.observe(viewport);&lt;br /&gt;
    } else {&lt;br /&gt;
      window.addEventListener(&#039;resize&#039;, resize);&lt;br /&gt;
    }&lt;br /&gt;
    graphSearchController = focusSearchResults;&lt;br /&gt;
    if (pendingGraphSearch) {&lt;br /&gt;
      focusSearchResults(pendingGraphSearch.items, pendingGraphSearch.query);&lt;br /&gt;
      pendingGraphSearch = null;&lt;br /&gt;
    }&lt;br /&gt;
    startSimulation(160, true);&lt;br /&gt;
    window.addEventListener(&#039;pagehide&#039;, function () {&lt;br /&gt;
      window.cancelAnimationFrame(animationFrame);&lt;br /&gt;
      if (resizeObserver) { resizeObserver.disconnect(); }&lt;br /&gt;
    }, { once: true });&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  var askQuery = &#039;[[Category:Lessons]]&#039; +&lt;br /&gt;
    &#039;|?Lesson title|?Confidence|?Updated at&#039; +&lt;br /&gt;
    RELATIONS.map(function (relation) { return &#039;|?&#039; + relation.property; }).join(&#039;&#039;) +&lt;br /&gt;
    &#039;|sort=Updated at|order=descending|limit=&#039; + NODE_LIMIT;&lt;br /&gt;
&lt;br /&gt;
  createSemanticSearch();&lt;br /&gt;
&lt;br /&gt;
  mw.loader.using([&#039;mediawiki.api&#039;, &#039;mediawiki.util&#039;]).then(function () {&lt;br /&gt;
    return new mw.Api().get({ action: &#039;ask&#039;, query: askQuery, formatversion: 2 });&lt;br /&gt;
  }).then(function (data) {&lt;br /&gt;
    var graph = parseGraph(data);&lt;br /&gt;
    if (!graph.nodes.length) {&lt;br /&gt;
      renderEmpty();&lt;br /&gt;
      return;&lt;br /&gt;
    }&lt;br /&gt;
    createGraph(graph);&lt;br /&gt;
  }).catch(renderError);&lt;br /&gt;
}());&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%EB%AF%B8%EB%94%94%EC%96%B4%EC%9C%84%ED%82%A4:Common.js&amp;diff=2742</id>
		<title>미디어위키:Common.js</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%EB%AF%B8%EB%94%94%EC%96%B4%EC%9C%84%ED%82%A4:Common.js&amp;diff=2742"/>
		<updated>2026-07-19T02:16:39Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 원본 스키마 동기화&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;(function () {&lt;br /&gt;
  &#039;use strict&#039;;&lt;br /&gt;
&lt;br /&gt;
  var root = document.getElementById(&#039;s3rm-knowledge-graph&#039;);&lt;br /&gt;
  var semanticRoot = document.getElementById(&#039;s3rm-semantic-search&#039;);&lt;br /&gt;
  if (!root || !window.mw) {&lt;br /&gt;
    return;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  var RELATIONS = [&lt;br /&gt;
    { property: &#039;Supports&#039;, key: &#039;supports&#039;, label: &#039;뒷받침함&#039;, color: &#039;#5f7ae6&#039; },&lt;br /&gt;
    { property: &#039;Contradicts&#039;, key: &#039;contradicts&#039;, label: &#039;상충함&#039;, color: &#039;#d0605b&#039; },&lt;br /&gt;
    { property: &#039;Analogous to&#039;, key: &#039;analogous_to&#039;, label: &#039;유사함&#039;, color: &#039;#a17bd7&#039; },&lt;br /&gt;
    { property: &#039;Failed because&#039;, key: &#039;failed_because&#039;, label: &#039;실패 원인&#039;, color: &#039;#dc8b45&#039; },&lt;br /&gt;
    { property: &#039;Works when&#039;, key: &#039;works_when&#039;, label: &#039;작동 조건&#039;, color: &#039;#43a27b&#039; },&lt;br /&gt;
    { property: &#039;Suggests&#039;, key: &#039;suggests&#039;, label: &#039;후속 제안&#039;, color: &#039;#3f9db7&#039; },&lt;br /&gt;
    { property: &#039;Supersedes&#039;, key: &#039;supersedes&#039;, label: &#039;대체함&#039;, color: &#039;#7d849e&#039; }&lt;br /&gt;
  ];&lt;br /&gt;
  var NODE_LIMIT = 160;&lt;br /&gt;
  var EDGE_LIMIT = 480;&lt;br /&gt;
  var mcpReady = null;&lt;br /&gt;
  var graphSearchController = null;&lt;br /&gt;
  var pendingGraphSearch = null;&lt;br /&gt;
&lt;br /&gt;
  function mcpRequest(payload, expectBody) {&lt;br /&gt;
    return window.fetch(&#039;/mcp&#039;, {&lt;br /&gt;
      method: &#039;POST&#039;,&lt;br /&gt;
      credentials: &#039;same-origin&#039;,&lt;br /&gt;
      headers: {&lt;br /&gt;
        &#039;Content-Type&#039;: &#039;application/json&#039;,&lt;br /&gt;
        &#039;Accept&#039;: &#039;application/json, text/event-stream&#039;&lt;br /&gt;
      },&lt;br /&gt;
      body: JSON.stringify(payload)&lt;br /&gt;
    }).then(function (response) {&lt;br /&gt;
      if (!response.ok) {&lt;br /&gt;
        throw new Error(&#039;MCP HTTP &#039; + response.status);&lt;br /&gt;
      }&lt;br /&gt;
      if (!expectBody) {&lt;br /&gt;
        return null;&lt;br /&gt;
      }&lt;br /&gt;
      return response.json();&lt;br /&gt;
    });&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function initializeMcp() {&lt;br /&gt;
    if (!mcpReady) {&lt;br /&gt;
      mcpReady = mcpRequest({&lt;br /&gt;
        jsonrpc: &#039;2.0&#039;,&lt;br /&gt;
        id: 1,&lt;br /&gt;
        method: &#039;initialize&#039;,&lt;br /&gt;
        params: {&lt;br /&gt;
          protocolVersion: &#039;2025-06-18&#039;,&lt;br /&gt;
          capabilities: {},&lt;br /&gt;
          clientInfo: { name: &#039;s3rm-wiki-search&#039;, version: &#039;1&#039; }&lt;br /&gt;
        }&lt;br /&gt;
      }, true).then(function (response) {&lt;br /&gt;
        if (!response || response.error) {&lt;br /&gt;
          throw new Error(&#039;MCP initialization failed&#039;);&lt;br /&gt;
        }&lt;br /&gt;
        return mcpRequest({&lt;br /&gt;
          jsonrpc: &#039;2.0&#039;,&lt;br /&gt;
          method: &#039;notifications/initialized&#039;,&lt;br /&gt;
          params: {}&lt;br /&gt;
        }, false);&lt;br /&gt;
      });&lt;br /&gt;
    }&lt;br /&gt;
    return mcpReady;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function searchSemantically(query) {&lt;br /&gt;
    return initializeMcp().then(function () {&lt;br /&gt;
      return mcpRequest({&lt;br /&gt;
        jsonrpc: &#039;2.0&#039;,&lt;br /&gt;
        id: 2,&lt;br /&gt;
        method: &#039;tools/call&#039;,&lt;br /&gt;
        params: {&lt;br /&gt;
          name: &#039;search_lessons&#039;,&lt;br /&gt;
          arguments: {&lt;br /&gt;
            query: query,&lt;br /&gt;
            retrieval_mode: &#039;hybrid_v1&#039;,&lt;br /&gt;
            fields: [&#039;title&#039;, &#039;question&#039;, &#039;reusable_lesson&#039;, &#039;confidence&#039;, &#039;relations&#039;],&lt;br /&gt;
            limit: 6&lt;br /&gt;
          }&lt;br /&gt;
        }&lt;br /&gt;
      }, true);&lt;br /&gt;
    }).then(function (response) {&lt;br /&gt;
      if (!response || response.error || !response.result || response.result.isError) {&lt;br /&gt;
        throw new Error(&#039;Semantic search failed&#039;);&lt;br /&gt;
      }&lt;br /&gt;
      return response.result.structuredContent || {};&lt;br /&gt;
    });&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function textValue(value) {&lt;br /&gt;
    if (value === null || value === undefined) {&lt;br /&gt;
      return &#039;&#039;;&lt;br /&gt;
    }&lt;br /&gt;
    if (typeof value === &#039;string&#039; || typeof value === &#039;number&#039;) {&lt;br /&gt;
      return String(value);&lt;br /&gt;
    }&lt;br /&gt;
    return String(value.fulltext || value.displaytitle || value.raw || value.timestamp || &#039;&#039;);&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function firstValue(values, fallback) {&lt;br /&gt;
    return values &amp;amp;&amp;amp; values.length ? textValue(values[0]) : fallback;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function confidenceLabel(confidence) {&lt;br /&gt;
    if (confidence === &#039;high&#039;) { return &#039;신뢰도: 높음&#039;; }&lt;br /&gt;
    if (confidence === &#039;medium&#039;) { return &#039;신뢰도: 중간&#039;; }&lt;br /&gt;
    return &#039;신뢰도: 낮음&#039;;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function parseGraph(data) {&lt;br /&gt;
    var rawResults = data &amp;amp;&amp;amp; data.query &amp;amp;&amp;amp; data.query.results ? data.query.results : {};&lt;br /&gt;
    var entries = Array.isArray(rawResults)&lt;br /&gt;
      ? rawResults.map(function (item) { return [item.fulltext, item]; })&lt;br /&gt;
      : Object.keys(rawResults).map(function (key) { return [key, rawResults[key]]; });&lt;br /&gt;
    var nodes = [];&lt;br /&gt;
    var byId = Object.create(null);&lt;br /&gt;
&lt;br /&gt;
    entries.slice(0, NODE_LIMIT).forEach(function (entry, index) {&lt;br /&gt;
      var id = entry[0];&lt;br /&gt;
      var result = entry[1] || {};&lt;br /&gt;
      var printouts = result.printouts || {};&lt;br /&gt;
      var angle = index * 2.399963229728653;&lt;br /&gt;
      var radius = 20 * Math.sqrt(index + 1);&lt;br /&gt;
      var node = {&lt;br /&gt;
        id: id,&lt;br /&gt;
        title: firstValue(printouts[&#039;Lesson title&#039;], id.replace(/^Lesson:/, &#039;&#039;)),&lt;br /&gt;
        confidence: firstValue(printouts.Confidence, &#039;low&#039;).toLowerCase(),&lt;br /&gt;
        updatedAt: firstValue(printouts[&#039;Updated at&#039;], &#039;&#039;),&lt;br /&gt;
        x: Math.cos(angle) * radius,&lt;br /&gt;
        y: Math.sin(angle) * radius,&lt;br /&gt;
        vx: 0,&lt;br /&gt;
        vy: 0,&lt;br /&gt;
        degree: 0,&lt;br /&gt;
        printouts: printouts&lt;br /&gt;
      };&lt;br /&gt;
      nodes.push(node);&lt;br /&gt;
      byId[id] = node;&lt;br /&gt;
    });&lt;br /&gt;
&lt;br /&gt;
    var edges = [];&lt;br /&gt;
    nodes.forEach(function (node) {&lt;br /&gt;
      RELATIONS.forEach(function (relation) {&lt;br /&gt;
        var targets = node.printouts[relation.property] || [];&lt;br /&gt;
        targets.forEach(function (target) {&lt;br /&gt;
          var targetId = textValue(target);&lt;br /&gt;
          if (edges.length &amp;lt; EDGE_LIMIT &amp;amp;&amp;amp; byId[targetId] &amp;amp;&amp;amp; targetId !== node.id) {&lt;br /&gt;
            edges.push({ source: node, target: byId[targetId], relation: relation });&lt;br /&gt;
            node.degree += 1;&lt;br /&gt;
            byId[targetId].degree += 1;&lt;br /&gt;
          }&lt;br /&gt;
        });&lt;br /&gt;
      });&lt;br /&gt;
      delete node.printouts;&lt;br /&gt;
    });&lt;br /&gt;
&lt;br /&gt;
    var connectedNodes = nodes.filter(function (node) { return node.degree &amp;gt; 0; });&lt;br /&gt;
    var isolatedNodes = nodes.filter(function (node) { return node.degree === 0; });&lt;br /&gt;
    connectedNodes.forEach(function (node, index) {&lt;br /&gt;
      var angle = index * 2.399963229728653;&lt;br /&gt;
      var radius = 16 * Math.sqrt(index + 1);&lt;br /&gt;
      node.x = Math.cos(angle) * radius;&lt;br /&gt;
      node.y = Math.sin(angle) * radius;&lt;br /&gt;
    });&lt;br /&gt;
    isolatedNodes.forEach(function (node, index) {&lt;br /&gt;
      var angle = index * 2.399963229728653 + 0.7;&lt;br /&gt;
      var radius = 92 + 10 * Math.sqrt(index + 1);&lt;br /&gt;
      node.x = Math.cos(angle) * radius;&lt;br /&gt;
      node.y = Math.sin(angle) * radius;&lt;br /&gt;
    });&lt;br /&gt;
    return { nodes: nodes, edges: edges };&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function makeElement(tag, className, text) {&lt;br /&gt;
    var element = document.createElement(tag);&lt;br /&gt;
    if (className) {&lt;br /&gt;
      element.className = className;&lt;br /&gt;
    }&lt;br /&gt;
    if (text !== undefined) {&lt;br /&gt;
      element.textContent = text;&lt;br /&gt;
    }&lt;br /&gt;
    return element;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function createSemanticSearch() {&lt;br /&gt;
    if (!semanticRoot) {&lt;br /&gt;
      return;&lt;br /&gt;
    }&lt;br /&gt;
    semanticRoot.replaceChildren();&lt;br /&gt;
    var intro = makeElement(&#039;div&#039;, &#039;s3rm-semantic-intro&#039;);&lt;br /&gt;
    intro.appendChild(makeElement(&#039;div&#039;, &#039;s3rm-semantic-icon&#039;, &#039;⌕&#039;));&lt;br /&gt;
    var introCopy = makeElement(&#039;div&#039;, &#039;&#039;);&lt;br /&gt;
    introCopy.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;검색&#039;));&lt;br /&gt;
    introCopy.appendChild(makeElement(&#039;p&#039;, &#039;&#039;, &#039;현상이나 궁금한 점을 문장으로 입력하세요. 표현이 달라도 관련 메모를 찾습니다.&#039;));&lt;br /&gt;
    intro.appendChild(introCopy);&lt;br /&gt;
&lt;br /&gt;
    var form = makeElement(&#039;form&#039;, &#039;s3rm-semantic-form&#039;);&lt;br /&gt;
    var input = makeElement(&#039;input&#039;, &#039;s3rm-semantic-input&#039;);&lt;br /&gt;
    input.type = &#039;search&#039;;&lt;br /&gt;
    input.name = &#039;query&#039;;&lt;br /&gt;
    input.maxLength = 500;&lt;br /&gt;
    input.placeholder = &#039;예: GPU 메모리는 남아 있는데 할당이 실패하는 이유는?&#039;;&lt;br /&gt;
    input.setAttribute(&#039;aria-label&#039;, &#039;메모 검색&#039;);&lt;br /&gt;
    var submit = makeElement(&#039;button&#039;, &#039;s3rm-semantic-submit&#039;, &#039;검색&#039;);&lt;br /&gt;
    submit.type = &#039;submit&#039;;&lt;br /&gt;
    form.append(input, submit);&lt;br /&gt;
&lt;br /&gt;
    var examples = makeElement(&#039;div&#039;, &#039;s3rm-semantic-examples&#039;);&lt;br /&gt;
    examples.appendChild(makeElement(&#039;span&#039;, &#039;&#039;, &#039;검색 예시&#039;));&lt;br /&gt;
    [&lt;br /&gt;
      &#039;재시도가 한꺼번에 몰리는 이유&#039;,&lt;br /&gt;
      &#039;GPU와 SSD 사이 병목을 줄이는 방법&#039;,&lt;br /&gt;
      &#039;긴 작업에서 지연이 갑자기 커지는 원인&#039;&lt;br /&gt;
    ].forEach(function (example) {&lt;br /&gt;
      var button = makeElement(&#039;button&#039;, &#039;&#039;, example);&lt;br /&gt;
      button.type = &#039;button&#039;;&lt;br /&gt;
      button.addEventListener(&#039;click&#039;, function () {&lt;br /&gt;
        input.value = example;&lt;br /&gt;
        form.requestSubmit();&lt;br /&gt;
      });&lt;br /&gt;
      examples.appendChild(button);&lt;br /&gt;
    });&lt;br /&gt;
&lt;br /&gt;
    var feedback = makeElement(&#039;div&#039;, &#039;s3rm-semantic-feedback&#039;);&lt;br /&gt;
    feedback.setAttribute(&#039;aria-live&#039;, &#039;polite&#039;);&lt;br /&gt;
    feedback.hidden = true;&lt;br /&gt;
    semanticRoot.append(intro, form, examples, feedback);&lt;br /&gt;
&lt;br /&gt;
    function renderSearchResults(payload, query) {&lt;br /&gt;
      var items = Array.isArray(payload.items) ? payload.items : [];&lt;br /&gt;
      if (graphSearchController) {&lt;br /&gt;
        graphSearchController(items, query);&lt;br /&gt;
      } else {&lt;br /&gt;
        pendingGraphSearch = { items: items, query: query };&lt;br /&gt;
      }&lt;br /&gt;
      feedback.replaceChildren();&lt;br /&gt;
      feedback.hidden = false;&lt;br /&gt;
      var resultHeader = makeElement(&#039;div&#039;, &#039;s3rm-semantic-result-header&#039;);&lt;br /&gt;
      resultHeader.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;“&#039; + query + &#039;” 검색 결과&#039;));&lt;br /&gt;
      resultHeader.appendChild(makeElement(&#039;span&#039;, &#039;s3rm-semantic-mode&#039;, items.length + &#039;개 메모&#039;));&lt;br /&gt;
      feedback.appendChild(resultHeader);&lt;br /&gt;
&lt;br /&gt;
      if (!items.length) {&lt;br /&gt;
        feedback.appendChild(makeElement(&#039;p&#039;, &#039;s3rm-semantic-empty&#039;, &#039;가까운 메모를 찾지 못했습니다. 표현을 조금 바꿔 다시 검색해 보세요.&#039;));&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      feedback.appendChild(makeElement(&#039;p&#039;, &#039;s3rm-semantic-graph-note&#039;, &#039;아래 그래프에 검색 결과를 모았습니다. 점을 선택하면 메모와 연결을 볼 수 있습니다.&#039;));&lt;br /&gt;
      var list = makeElement(&#039;div&#039;, &#039;s3rm-semantic-result-links&#039;);&lt;br /&gt;
      items.forEach(function (item) {&lt;br /&gt;
        var link = makeElement(&#039;a&#039;, &#039;&#039;, item.title || String(item.id).replace(/^Lesson:/, &#039;&#039;));&lt;br /&gt;
        link.href = mw.util.getUrl(item.id);&lt;br /&gt;
        list.appendChild(link);&lt;br /&gt;
      });&lt;br /&gt;
      feedback.appendChild(list);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    form.addEventListener(&#039;submit&#039;, function (event) {&lt;br /&gt;
      event.preventDefault();&lt;br /&gt;
      var query = input.value.trim();&lt;br /&gt;
      if (query.length &amp;lt; 2) {&lt;br /&gt;
        feedback.replaceChildren(makeElement(&#039;p&#039;, &#039;s3rm-semantic-error&#039;, &#039;두 글자 이상 입력해 주세요.&#039;));&lt;br /&gt;
        feedback.hidden = false;&lt;br /&gt;
        input.focus();&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      submit.disabled = true;&lt;br /&gt;
      submit.textContent = &#039;찾는 중…&#039;;&lt;br /&gt;
      feedback.replaceChildren(makeElement(&#039;div&#039;, &#039;s3rm-semantic-searching&#039;, &#039;관련 메모를 찾고 있습니다.&#039;));&lt;br /&gt;
      feedback.hidden = false;&lt;br /&gt;
      searchSemantically(query).then(function (payload) {&lt;br /&gt;
        renderSearchResults(payload, query);&lt;br /&gt;
      }).catch(function () {&lt;br /&gt;
        feedback.replaceChildren();&lt;br /&gt;
        var error = makeElement(&#039;div&#039;, &#039;s3rm-semantic-error&#039;);&lt;br /&gt;
        error.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;지금은 검색을 사용할 수 없습니다.&#039;));&lt;br /&gt;
        var fallback = makeElement(&#039;a&#039;, &#039;&#039;, &#039;검색 열기&#039;);&lt;br /&gt;
        fallback.href = mw.util.getUrl(&#039;Special:Search&#039;, { search: query });&lt;br /&gt;
        error.appendChild(fallback);&lt;br /&gt;
        feedback.appendChild(error);&lt;br /&gt;
      }).finally(function () {&lt;br /&gt;
        submit.disabled = false;&lt;br /&gt;
        submit.textContent = &#039;검색&#039;;&lt;br /&gt;
      });&lt;br /&gt;
    });&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function renderEmpty() {&lt;br /&gt;
    root.replaceChildren();&lt;br /&gt;
    var empty = makeElement(&#039;div&#039;, &#039;s3rm-graph-empty&#039;);&lt;br /&gt;
    empty.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;아직 연결할 메모가 없습니다&#039;));&lt;br /&gt;
    empty.appendChild(makeElement(&#039;p&#039;, &#039;&#039;, &#039;첫 메모를 남기면 이곳에서 지식의 연결을 볼 수 있습니다.&#039;));&lt;br /&gt;
    var link = makeElement(&#039;a&#039;, &#039;s3rm-graph-primary-link&#039;, &#039;첫 메모 작성하기&#039;);&lt;br /&gt;
    link.href = &#039;#quick-start&#039;;&lt;br /&gt;
    empty.appendChild(link);&lt;br /&gt;
    root.appendChild(empty);&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function renderError() {&lt;br /&gt;
    root.replaceChildren();&lt;br /&gt;
    var fallback = makeElement(&#039;div&#039;, &#039;s3rm-graph-empty&#039;);&lt;br /&gt;
    fallback.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;그래프를 불러오지 못했습니다&#039;));&lt;br /&gt;
    fallback.appendChild(makeElement(&#039;p&#039;, &#039;&#039;, &#039;최근 메모와 검색은 계속 사용할 수 있습니다. 잠시 뒤 다시 시도해 주세요.&#039;));&lt;br /&gt;
    var link = makeElement(&#039;a&#039;, &#039;s3rm-graph-primary-link&#039;, &#039;전체 메모 보기&#039;);&lt;br /&gt;
    link.href = mw.util.getUrl(&#039;Special:AllPages/Lesson:&#039;);&lt;br /&gt;
    fallback.appendChild(link);&lt;br /&gt;
    root.appendChild(fallback);&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function createGraph(graph) {&lt;br /&gt;
    var nodes = graph.nodes;&lt;br /&gt;
    var edges = graph.edges;&lt;br /&gt;
    var byId = Object.create(null);&lt;br /&gt;
    function canonicalId(id) {&lt;br /&gt;
      return String(id || &#039;&#039;).replace(/_/g, &#039; &#039;).toLocaleLowerCase(&#039;en&#039;);&lt;br /&gt;
    }&lt;br /&gt;
    nodes.forEach(function (node) { byId[canonicalId(node.id)] = node; });&lt;br /&gt;
    var selected = null;&lt;br /&gt;
    var hovered = null;&lt;br /&gt;
    var searchCluster = null;&lt;br /&gt;
    var activeRelation = &#039;all&#039;;&lt;br /&gt;
    var transform = { x: 0, y: 0, scale: 1 };&lt;br /&gt;
    var dragging = null;&lt;br /&gt;
    var panning = false;&lt;br /&gt;
    var pointerStart = null;&lt;br /&gt;
    var animationFrame = 0;&lt;br /&gt;
    var simulationTicks = 0;&lt;br /&gt;
&lt;br /&gt;
    root.replaceChildren();&lt;br /&gt;
    var toolbar = makeElement(&#039;div&#039;, &#039;s3rm-graph-toolbar&#039;);&lt;br /&gt;
    var searchWrap = makeElement(&#039;div&#039;, &#039;s3rm-graph-search&#039;);&lt;br /&gt;
    var search = makeElement(&#039;input&#039;, &#039;s3rm-graph-search-input&#039;);&lt;br /&gt;
    search.type = &#039;search&#039;;&lt;br /&gt;
    search.placeholder = &#039;그래프에서 제목 필터&#039;;&lt;br /&gt;
    search.setAttribute(&#039;aria-label&#039;, &#039;그래프 제목 필터&#039;);&lt;br /&gt;
    search.autocomplete = &#039;off&#039;;&lt;br /&gt;
    var searchResults = makeElement(&#039;div&#039;, &#039;s3rm-graph-search-results&#039;);&lt;br /&gt;
    searchResults.hidden = true;&lt;br /&gt;
    searchWrap.append(search, searchResults);&lt;br /&gt;
&lt;br /&gt;
    var filter = makeElement(&#039;select&#039;, &#039;s3rm-graph-filter&#039;);&lt;br /&gt;
    filter.setAttribute(&#039;aria-label&#039;, &#039;관계 유형 필터&#039;);&lt;br /&gt;
    var allOption = makeElement(&#039;option&#039;, &#039;&#039;, &#039;모든 관계&#039;);&lt;br /&gt;
    allOption.value = &#039;all&#039;;&lt;br /&gt;
    filter.appendChild(allOption);&lt;br /&gt;
    RELATIONS.forEach(function (relation) {&lt;br /&gt;
      var option = makeElement(&#039;option&#039;, &#039;&#039;, relation.label);&lt;br /&gt;
      option.value = relation.key;&lt;br /&gt;
      filter.appendChild(option);&lt;br /&gt;
    });&lt;br /&gt;
    var reset = makeElement(&#039;button&#039;, &#039;s3rm-graph-reset&#039;, &#039;화면 맞춤&#039;);&lt;br /&gt;
    reset.type = &#039;button&#039;;&lt;br /&gt;
    var clearSearch = makeElement(&#039;button&#039;, &#039;s3rm-graph-clear-search&#039;, &#039;검색 결과 해제&#039;);&lt;br /&gt;
    clearSearch.type = &#039;button&#039;;&lt;br /&gt;
    clearSearch.hidden = true;&lt;br /&gt;
    toolbar.append(searchWrap, filter, reset, clearSearch);&lt;br /&gt;
&lt;br /&gt;
    var body = makeElement(&#039;div&#039;, &#039;s3rm-graph-body&#039;);&lt;br /&gt;
    var viewport = makeElement(&#039;div&#039;, &#039;s3rm-graph-viewport&#039;);&lt;br /&gt;
    var canvas = document.createElement(&#039;canvas&#039;);&lt;br /&gt;
    canvas.className = &#039;s3rm-graph-canvas&#039;;&lt;br /&gt;
    canvas.setAttribute(&#039;aria-hidden&#039;, &#039;true&#039;);&lt;br /&gt;
    viewport.appendChild(canvas);&lt;br /&gt;
    var hint = makeElement(&#039;div&#039;, &#039;s3rm-graph-hint&#039;, &#039;드래그하여 이동 · 스크롤하여 확대 · 두 번 눌러 메모 열기&#039;);&lt;br /&gt;
    viewport.appendChild(hint);&lt;br /&gt;
&lt;br /&gt;
    var inspector = makeElement(&#039;aside&#039;, &#039;s3rm-graph-inspector&#039;);&lt;br /&gt;
    inspector.setAttribute(&#039;aria-live&#039;, &#039;polite&#039;);&lt;br /&gt;
    body.append(viewport, inspector);&lt;br /&gt;
&lt;br /&gt;
    var footer = makeElement(&#039;div&#039;, &#039;s3rm-graph-footer&#039;);&lt;br /&gt;
    var counts = makeElement(&#039;span&#039;, &#039;s3rm-graph-counts&#039;, nodes.length + &#039;개 메모 · &#039; + edges.length + &#039;개 연결&#039;);&lt;br /&gt;
    var legend = makeElement(&#039;div&#039;, &#039;s3rm-graph-legend&#039;);&lt;br /&gt;
    RELATIONS.slice(0, 4).forEach(function (relation) {&lt;br /&gt;
      var item = makeElement(&#039;span&#039;, &#039;s3rm-graph-legend-item&#039;);&lt;br /&gt;
      var swatch = makeElement(&#039;i&#039;, &#039;&#039;);&lt;br /&gt;
      swatch.style.backgroundColor = relation.color;&lt;br /&gt;
      item.append(swatch, document.createTextNode(relation.label));&lt;br /&gt;
      legend.appendChild(item);&lt;br /&gt;
    });&lt;br /&gt;
    footer.append(counts, legend);&lt;br /&gt;
    root.append(toolbar, body, footer);&lt;br /&gt;
&lt;br /&gt;
    var context = canvas.getContext(&#039;2d&#039;);&lt;br /&gt;
    var size = { width: 0, height: 0, ratio: 1 };&lt;br /&gt;
&lt;br /&gt;
    function visibleEdges() {&lt;br /&gt;
      return activeRelation === &#039;all&#039;&lt;br /&gt;
        ? edges&lt;br /&gt;
        : edges.filter(function (edge) { return edge.relation.key === activeRelation; });&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function nodeRadius(node) {&lt;br /&gt;
      return 6 + Math.min(8, Math.sqrt(node.degree) * 2.2) + (node.isSearchResult ? 3 : 0);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function confidenceColor(confidence) {&lt;br /&gt;
      if (confidence === &#039;high&#039;) { return &#039;#53c18b&#039;; }&lt;br /&gt;
      if (confidence === &#039;medium&#039;) { return &#039;#8ca8ff&#039;; }&lt;br /&gt;
      return &#039;#c0c5d8&#039;;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function resize() {&lt;br /&gt;
      var rect = viewport.getBoundingClientRect();&lt;br /&gt;
      size.width = Math.max(320, rect.width);&lt;br /&gt;
      size.height = Math.max(360, rect.height);&lt;br /&gt;
      size.ratio = Math.min(window.devicePixelRatio || 1, 2);&lt;br /&gt;
      canvas.width = Math.round(size.width * size.ratio);&lt;br /&gt;
      canvas.height = Math.round(size.height * size.ratio);&lt;br /&gt;
      canvas.style.width = size.width + &#039;px&#039;;&lt;br /&gt;
      canvas.style.height = size.height + &#039;px&#039;;&lt;br /&gt;
      draw();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function fitGraph() {&lt;br /&gt;
      if (!nodes.length) { return; }&lt;br /&gt;
      var xs = nodes.map(function (node) { return node.x; });&lt;br /&gt;
      var ys = nodes.map(function (node) { return node.y; });&lt;br /&gt;
      var minX = Math.min.apply(null, xs) - 38;&lt;br /&gt;
      var maxX = Math.max.apply(null, xs) + 38;&lt;br /&gt;
      var minY = Math.min.apply(null, ys) - 38;&lt;br /&gt;
      var maxY = Math.max.apply(null, ys) + 38;&lt;br /&gt;
      transform.scale = Math.max(0.2, Math.min(1.25, Math.min(&lt;br /&gt;
        size.width / Math.max(1, maxX - minX),&lt;br /&gt;
        size.height / Math.max(1, maxY - minY)&lt;br /&gt;
      )));&lt;br /&gt;
      transform.x = size.width / 2 - ((minX + maxX) / 2) * transform.scale;&lt;br /&gt;
      transform.y = size.height / 2 - ((minY + maxY) / 2) * transform.scale;&lt;br /&gt;
      draw();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function draw() {&lt;br /&gt;
      context.setTransform(size.ratio, 0, 0, size.ratio, 0, 0);&lt;br /&gt;
      context.clearRect(0, 0, size.width, size.height);&lt;br /&gt;
      context.save();&lt;br /&gt;
      context.translate(transform.x, transform.y);&lt;br /&gt;
      context.scale(transform.scale, transform.scale);&lt;br /&gt;
&lt;br /&gt;
      if (searchCluster) {&lt;br /&gt;
        context.beginPath();&lt;br /&gt;
        context.arc(&lt;br /&gt;
          searchCluster.x,&lt;br /&gt;
          searchCluster.y,&lt;br /&gt;
          searchCluster.radius + 48,&lt;br /&gt;
          0,&lt;br /&gt;
          Math.PI * 2&lt;br /&gt;
        );&lt;br /&gt;
        context.fillStyle = &#039;rgba(104, 126, 233, 0.12)&#039;;&lt;br /&gt;
        context.fill();&lt;br /&gt;
        context.setLineDash([7 / transform.scale, 6 / transform.scale]);&lt;br /&gt;
        context.lineWidth = 1.5 / transform.scale;&lt;br /&gt;
        context.strokeStyle = &#039;rgba(140, 168, 255, 0.75)&#039;;&lt;br /&gt;
        context.stroke();&lt;br /&gt;
        context.setLineDash([]);&lt;br /&gt;
        context.font = &#039;700 &#039; + (12 / transform.scale) + &#039;px sans-serif&#039;;&lt;br /&gt;
        context.textAlign = &#039;center&#039;;&lt;br /&gt;
        context.textBaseline = &#039;bottom&#039;;&lt;br /&gt;
        context.fillStyle = &#039;#b9c7ff&#039;;&lt;br /&gt;
        context.fillText(&lt;br /&gt;
          &#039;검색 결과 · &#039; + searchCluster.nodes.length + &#039;개 메모&#039;,&lt;br /&gt;
          searchCluster.x,&lt;br /&gt;
          searchCluster.y - searchCluster.radius - 58 / transform.scale&lt;br /&gt;
        );&lt;br /&gt;
      }&lt;br /&gt;
&lt;br /&gt;
      visibleEdges().forEach(function (edge) {&lt;br /&gt;
        var emphasized = selected &amp;amp;&amp;amp; (edge.source === selected || edge.target === selected);&lt;br /&gt;
        var searchEdge = searchCluster &amp;amp;&amp;amp;&lt;br /&gt;
          (edge.source.isSearchResult || edge.target.isSearchResult);&lt;br /&gt;
        context.globalAlpha = searchCluster &amp;amp;&amp;amp; !searchEdge ? 0.1 : 1;&lt;br /&gt;
        context.beginPath();&lt;br /&gt;
        context.moveTo(edge.source.x, edge.source.y);&lt;br /&gt;
        context.lineTo(edge.target.x, edge.target.y);&lt;br /&gt;
        context.strokeStyle = emphasized ? edge.relation.color : edge.relation.color + &#039;55&#039;;&lt;br /&gt;
        context.lineWidth = (emphasized || searchEdge ? 2.2 : 1) / transform.scale;&lt;br /&gt;
        context.stroke();&lt;br /&gt;
      });&lt;br /&gt;
      context.globalAlpha = 1;&lt;br /&gt;
&lt;br /&gt;
      nodes.forEach(function (node) {&lt;br /&gt;
        var isSelected = node === selected;&lt;br /&gt;
        var isHovered = node === hovered;&lt;br /&gt;
        var isSearchResult = Boolean(node.isSearchResult);&lt;br /&gt;
        var radius = nodeRadius(node);&lt;br /&gt;
        context.globalAlpha = searchCluster &amp;amp;&amp;amp; !isSearchResult ? 0.12 : 1;&lt;br /&gt;
        context.beginPath();&lt;br /&gt;
        context.arc(node.x, node.y, radius + (isSelected ? 4 : 0), 0, Math.PI * 2);&lt;br /&gt;
        context.fillStyle = isSelected || isSearchResult ? &#039;#ffffff&#039; : confidenceColor(node.confidence);&lt;br /&gt;
        context.fill();&lt;br /&gt;
        context.lineWidth = (isSelected || isSearchResult ? 3 : 1.5) / transform.scale;&lt;br /&gt;
        context.strokeStyle = isSelected || isSearchResult ? &#039;#8ca8ff&#039; : &#039;#11182e&#039;;&lt;br /&gt;
        context.stroke();&lt;br /&gt;
&lt;br /&gt;
        if (isSelected || isHovered || isSearchResult ||&lt;br /&gt;
          (transform.scale &amp;gt; 0.68 &amp;amp;&amp;amp; node.degree &amp;gt; 0)) {&lt;br /&gt;
          context.font = (isSelected || isSearchResult ? &#039;600 &#039; : &#039;&#039;) + (12 / transform.scale) + &#039;px sans-serif&#039;;&lt;br /&gt;
          context.textAlign = &#039;center&#039;;&lt;br /&gt;
          context.textBaseline = &#039;top&#039;;&lt;br /&gt;
          context.fillStyle = &#039;#f5f7ff&#039;;&lt;br /&gt;
          context.shadowColor = &#039;#080b16&#039;;&lt;br /&gt;
          context.shadowBlur = 5 / transform.scale;&lt;br /&gt;
          var label = node.title.length &amp;gt; 26 ? node.title.slice(0, 25) + &#039;…&#039; : node.title;&lt;br /&gt;
          context.fillText(label, node.x, node.y + radius + 7 / transform.scale);&lt;br /&gt;
          context.shadowBlur = 0;&lt;br /&gt;
        }&lt;br /&gt;
      });&lt;br /&gt;
      context.globalAlpha = 1;&lt;br /&gt;
      context.restore();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function simulate() {&lt;br /&gt;
      if (simulationTicks &amp;gt; 220) {&lt;br /&gt;
        if (searchCluster) {&lt;br /&gt;
          draw();&lt;br /&gt;
        } else {&lt;br /&gt;
          fitGraph();&lt;br /&gt;
        }&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      for (var pass = 0; pass &amp;lt; 3; pass += 1) {&lt;br /&gt;
        for (var i = 0; i &amp;lt; nodes.length; i += 1) {&lt;br /&gt;
          for (var j = i + 1; j &amp;lt; nodes.length; j += 1) {&lt;br /&gt;
            var first = nodes[i];&lt;br /&gt;
            var second = nodes[j];&lt;br /&gt;
            if (first.degree === 0 &amp;amp;&amp;amp; second.degree === 0) {&lt;br /&gt;
              continue;&lt;br /&gt;
            }&lt;br /&gt;
            var dx = second.x - first.x || 0.01;&lt;br /&gt;
            var dy = second.y - first.y || 0.01;&lt;br /&gt;
            var distanceSquared = dx * dx + dy * dy;&lt;br /&gt;
            var distance = Math.sqrt(distanceSquared) || 0.01;&lt;br /&gt;
            var minimumDistance = nodeRadius(first) + nodeRadius(second) + 7;&lt;br /&gt;
            var force = distance &amp;lt; minimumDistance&lt;br /&gt;
              ? (minimumDistance - distance) * 0.035 / distance&lt;br /&gt;
              : 1.8 / Math.max(80, distanceSquared);&lt;br /&gt;
            first.vx -= dx * force;&lt;br /&gt;
            first.vy -= dy * force;&lt;br /&gt;
            second.vx += dx * force;&lt;br /&gt;
            second.vy += dy * force;&lt;br /&gt;
          }&lt;br /&gt;
        }&lt;br /&gt;
        edges.forEach(function (edge) {&lt;br /&gt;
          var dx = edge.target.x - edge.source.x;&lt;br /&gt;
          var dy = edge.target.y - edge.source.y;&lt;br /&gt;
          var distance = Math.sqrt(dx * dx + dy * dy) || 1;&lt;br /&gt;
          var force = (distance - 64) * 0.0014;&lt;br /&gt;
          edge.source.vx += dx * force;&lt;br /&gt;
          edge.source.vy += dy * force;&lt;br /&gt;
          edge.target.vx -= dx * force;&lt;br /&gt;
          edge.target.vy -= dy * force;&lt;br /&gt;
        });&lt;br /&gt;
        nodes.forEach(function (node) {&lt;br /&gt;
          if (node.isSearchResult) {&lt;br /&gt;
            node.vx = 0;&lt;br /&gt;
            node.vy = 0;&lt;br /&gt;
            return;&lt;br /&gt;
          }&lt;br /&gt;
          if (node.degree === 0) {&lt;br /&gt;
            node.vx = 0;&lt;br /&gt;
            node.vy = 0;&lt;br /&gt;
            return;&lt;br /&gt;
          }&lt;br /&gt;
          node.vx += -node.x * 0.0018;&lt;br /&gt;
          node.vy += -node.y * 0.0018;&lt;br /&gt;
          node.vx *= 0.82;&lt;br /&gt;
          node.vy *= 0.82;&lt;br /&gt;
          node.x += node.vx;&lt;br /&gt;
          node.y += node.vy;&lt;br /&gt;
        });&lt;br /&gt;
        simulationTicks += 1;&lt;br /&gt;
      }&lt;br /&gt;
      draw();&lt;br /&gt;
      animationFrame = window.requestAnimationFrame(simulate);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function relationDefinition(key) {&lt;br /&gt;
      return RELATIONS.find(function (relation) { return relation.key === key; });&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function updateCounts() {&lt;br /&gt;
      if (searchCluster) {&lt;br /&gt;
        var relatedEdges = edges.filter(function (edge) {&lt;br /&gt;
          return edge.source.isSearchResult || edge.target.isSearchResult;&lt;br /&gt;
        }).length;&lt;br /&gt;
        counts.textContent = &#039;검색 결과 &#039; + searchCluster.nodes.length + &#039;개 · &#039; +&lt;br /&gt;
          relatedEdges + &#039;개 연결&#039;;&lt;br /&gt;
      } else {&lt;br /&gt;
        counts.textContent = nodes.length + &#039;개 메모 · &#039; + visibleEdges().length + &#039;개 연결&#039;;&lt;br /&gt;
      }&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function resetSearchCluster() {&lt;br /&gt;
      var addedKeys = Object.create(null);&lt;br /&gt;
      edges.forEach(function (edge) {&lt;br /&gt;
        if (edge.searchAdded) {&lt;br /&gt;
          edge.source.degree = Math.max(0, edge.source.degree - 1);&lt;br /&gt;
          edge.target.degree = Math.max(0, edge.target.degree - 1);&lt;br /&gt;
        }&lt;br /&gt;
      });&lt;br /&gt;
      nodes.forEach(function (node) {&lt;br /&gt;
        if (node.searchAdded) {&lt;br /&gt;
          addedKeys[canonicalId(node.id)] = true;&lt;br /&gt;
          delete byId[canonicalId(node.id)];&lt;br /&gt;
        } else if (node.preSearchPosition) {&lt;br /&gt;
          node.x = node.preSearchPosition.x;&lt;br /&gt;
          node.y = node.preSearchPosition.y;&lt;br /&gt;
        }&lt;br /&gt;
        delete node.isSearchResult;&lt;br /&gt;
        delete node.preSearchPosition;&lt;br /&gt;
      });&lt;br /&gt;
      nodes = nodes.filter(function (node) { return !node.searchAdded; });&lt;br /&gt;
      edges = edges.filter(function (edge) {&lt;br /&gt;
        return !edge.searchAdded &amp;amp;&amp;amp;&lt;br /&gt;
          !addedKeys[canonicalId(edge.source.id)] &amp;amp;&amp;amp;&lt;br /&gt;
          !addedKeys[canonicalId(edge.target.id)];&lt;br /&gt;
      });&lt;br /&gt;
      searchCluster = null;&lt;br /&gt;
      clearSearch.hidden = true;&lt;br /&gt;
      selected = null;&lt;br /&gt;
      showInspector(null);&lt;br /&gt;
      updateCounts();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function focusSearchResults(items, query) {&lt;br /&gt;
      resetSearchCluster();&lt;br /&gt;
      if (!items.length) {&lt;br /&gt;
        fitGraph();&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
&lt;br /&gt;
      var resultNodes = [];&lt;br /&gt;
      items.forEach(function (item, index) {&lt;br /&gt;
        var key = canonicalId(item.id);&lt;br /&gt;
        var node = byId[key];&lt;br /&gt;
        if (!node) {&lt;br /&gt;
          node = {&lt;br /&gt;
            id: item.id,&lt;br /&gt;
            title: item.title || String(item.id).replace(/^Lesson:/, &#039;&#039;),&lt;br /&gt;
            confidence: item.confidence || &#039;low&#039;,&lt;br /&gt;
            updatedAt: &#039;&#039;,&lt;br /&gt;
            x: 0,&lt;br /&gt;
            y: 0,&lt;br /&gt;
            vx: 0,&lt;br /&gt;
            vy: 0,&lt;br /&gt;
            degree: 0,&lt;br /&gt;
            searchAdded: true&lt;br /&gt;
          };&lt;br /&gt;
          nodes.push(node);&lt;br /&gt;
          byId[key] = node;&lt;br /&gt;
        } else {&lt;br /&gt;
          node.preSearchPosition = { x: node.x, y: node.y };&lt;br /&gt;
          node.title = item.title || node.title;&lt;br /&gt;
          node.confidence = item.confidence || node.confidence;&lt;br /&gt;
        }&lt;br /&gt;
        node.isSearchResult = true;&lt;br /&gt;
        node.searchRank = index + 1;&lt;br /&gt;
        resultNodes.push(node);&lt;br /&gt;
      });&lt;br /&gt;
&lt;br /&gt;
      items.forEach(function (item) {&lt;br /&gt;
        (Array.isArray(item.relations) ? item.relations : []).forEach(function (relationItem) {&lt;br /&gt;
          var source = byId[canonicalId(relationItem.from || item.id)];&lt;br /&gt;
          var target = byId[canonicalId(relationItem.to)];&lt;br /&gt;
          var relation = relationDefinition(relationItem.relation);&lt;br /&gt;
          if (!source || !target || !relation || source === target) {&lt;br /&gt;
            return;&lt;br /&gt;
          }&lt;br /&gt;
          var duplicate = edges.some(function (edge) {&lt;br /&gt;
            return canonicalId(edge.source.id) === canonicalId(source.id) &amp;amp;&amp;amp;&lt;br /&gt;
              canonicalId(edge.target.id) === canonicalId(target.id) &amp;amp;&amp;amp;&lt;br /&gt;
              edge.relation.key === relation.key;&lt;br /&gt;
          });&lt;br /&gt;
          if (!duplicate) {&lt;br /&gt;
            edges.push({&lt;br /&gt;
              source: source,&lt;br /&gt;
              target: target,&lt;br /&gt;
              relation: relation,&lt;br /&gt;
              searchAdded: true&lt;br /&gt;
            });&lt;br /&gt;
            source.degree += 1;&lt;br /&gt;
            target.degree += 1;&lt;br /&gt;
          }&lt;br /&gt;
        });&lt;br /&gt;
      });&lt;br /&gt;
&lt;br /&gt;
      var clusterRadius = resultNodes.length === 1 ? 0 :&lt;br /&gt;
        Math.max(82, Math.min(132, resultNodes.length * 22));&lt;br /&gt;
      resultNodes.forEach(function (node, index) {&lt;br /&gt;
        var angle = -Math.PI / 2 + (Math.PI * 2 * index / resultNodes.length);&lt;br /&gt;
        node.x = Math.cos(angle) * clusterRadius;&lt;br /&gt;
        node.y = Math.sin(angle) * clusterRadius;&lt;br /&gt;
        node.vx = 0;&lt;br /&gt;
        node.vy = 0;&lt;br /&gt;
      });&lt;br /&gt;
      searchCluster = {&lt;br /&gt;
        query: query,&lt;br /&gt;
        nodes: resultNodes,&lt;br /&gt;
        x: 0,&lt;br /&gt;
        y: 0,&lt;br /&gt;
        radius: clusterRadius&lt;br /&gt;
      };&lt;br /&gt;
      activeRelation = &#039;all&#039;;&lt;br /&gt;
      filter.value = &#039;all&#039;;&lt;br /&gt;
      clearSearch.hidden = false;&lt;br /&gt;
      transform.scale = Math.max(0.72, Math.min(&lt;br /&gt;
        1.35,&lt;br /&gt;
        Math.min(size.width, size.height) / Math.max(360, clusterRadius * 2 + 150)&lt;br /&gt;
      ));&lt;br /&gt;
      transform.x = size.width / 2;&lt;br /&gt;
      transform.y = size.height / 2;&lt;br /&gt;
      selectNode(resultNodes[0]);&lt;br /&gt;
      updateCounts();&lt;br /&gt;
      draw();&lt;br /&gt;
      viewport.scrollIntoView({ behavior: &#039;smooth&#039;, block: &#039;center&#039; });&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function connectionSummary(node) {&lt;br /&gt;
      var connected = edges.filter(function (edge) {&lt;br /&gt;
        return edge.source === node || edge.target === node;&lt;br /&gt;
      });&lt;br /&gt;
      var summary = Object.create(null);&lt;br /&gt;
      connected.forEach(function (edge) {&lt;br /&gt;
        summary[edge.relation.label] = (summary[edge.relation.label] || 0) + 1;&lt;br /&gt;
      });&lt;br /&gt;
      return { edges: connected, counts: summary };&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function showInspector(node) {&lt;br /&gt;
      inspector.replaceChildren();&lt;br /&gt;
      if (!node) {&lt;br /&gt;
        var welcome = makeElement(&#039;div&#039;, &#039;s3rm-inspector-welcome&#039;);&lt;br /&gt;
        welcome.appendChild(makeElement(&#039;span&#039;, &#039;s3rm-inspector-symbol&#039;, &#039;⌁&#039;));&lt;br /&gt;
        welcome.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;메모를 선택하세요&#039;));&lt;br /&gt;
        welcome.appendChild(makeElement(&#039;p&#039;, &#039;&#039;, &#039;점 하나를 누르면 메모 정보와 연결된 기록을 보여 드립니다.&#039;));&lt;br /&gt;
        inspector.appendChild(welcome);&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      var summary = connectionSummary(node);&lt;br /&gt;
      inspector.appendChild(makeElement(&lt;br /&gt;
        &#039;div&#039;,&lt;br /&gt;
        &#039;s3rm-inspector-kicker&#039;,&lt;br /&gt;
        node.isSearchResult ? &#039;검색 결과 &#039; + node.searchRank : &#039;선택한 메모&#039;&lt;br /&gt;
      ));&lt;br /&gt;
      inspector.appendChild(makeElement(&#039;h3&#039;, &#039;&#039;, node.title));&lt;br /&gt;
      var meta = makeElement(&#039;div&#039;, &#039;s3rm-inspector-meta&#039;);&lt;br /&gt;
      meta.appendChild(makeElement(&#039;span&#039;, &#039;s3rm-confidence s3rm-confidence-&#039; + node.confidence, confidenceLabel(node.confidence)));&lt;br /&gt;
      meta.appendChild(makeElement(&#039;span&#039;, &#039;&#039;, summary.edges.length + &#039;개 연결&#039;));&lt;br /&gt;
      inspector.appendChild(meta);&lt;br /&gt;
      if (Object.keys(summary.counts).length) {&lt;br /&gt;
        var relationList = makeElement(&#039;ul&#039;, &#039;s3rm-inspector-relations&#039;);&lt;br /&gt;
        Object.keys(summary.counts).forEach(function (label) {&lt;br /&gt;
          var item = makeElement(&#039;li&#039;, &#039;&#039;);&lt;br /&gt;
          item.append(makeElement(&#039;span&#039;, &#039;&#039;, label), makeElement(&#039;strong&#039;, &#039;&#039;, String(summary.counts[label])));&lt;br /&gt;
          relationList.appendChild(item);&lt;br /&gt;
        });&lt;br /&gt;
        inspector.appendChild(relationList);&lt;br /&gt;
      } else {&lt;br /&gt;
        inspector.appendChild(makeElement(&#039;p&#039;, &#039;s3rm-inspector-muted&#039;, &#039;아직 다른 메모와 연결되지 않았습니다.&#039;));&lt;br /&gt;
      }&lt;br /&gt;
      var open = makeElement(&#039;a&#039;, &#039;s3rm-graph-primary-link&#039;, &#039;메모 열기&#039;);&lt;br /&gt;
      open.href = mw.util.getUrl(node.id);&lt;br /&gt;
      inspector.appendChild(open);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function canvasPoint(event) {&lt;br /&gt;
      var rect = canvas.getBoundingClientRect();&lt;br /&gt;
      return { x: event.clientX - rect.left, y: event.clientY - rect.top };&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function worldPoint(event) {&lt;br /&gt;
      var point = canvasPoint(event);&lt;br /&gt;
      return {&lt;br /&gt;
        x: (point.x - transform.x) / transform.scale,&lt;br /&gt;
        y: (point.y - transform.y) / transform.scale&lt;br /&gt;
      };&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function hitNode(event) {&lt;br /&gt;
      var point = worldPoint(event);&lt;br /&gt;
      for (var i = nodes.length - 1; i &amp;gt;= 0; i -= 1) {&lt;br /&gt;
        var node = nodes[i];&lt;br /&gt;
        var dx = point.x - node.x;&lt;br /&gt;
        var dy = point.y - node.y;&lt;br /&gt;
        if (dx * dx + dy * dy &amp;lt;= Math.pow(nodeRadius(node) + 6 / transform.scale, 2)) {&lt;br /&gt;
          return node;&lt;br /&gt;
        }&lt;br /&gt;
      }&lt;br /&gt;
      return null;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function selectNode(node) {&lt;br /&gt;
      selected = node;&lt;br /&gt;
      showInspector(node);&lt;br /&gt;
      draw();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    canvas.addEventListener(&#039;pointerdown&#039;, function (event) {&lt;br /&gt;
      dragging = hitNode(event);&lt;br /&gt;
      panning = !dragging;&lt;br /&gt;
      pointerStart = canvasPoint(event);&lt;br /&gt;
      canvas.setPointerCapture(event.pointerId);&lt;br /&gt;
      canvas.classList.add(&#039;is-dragging&#039;);&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;pointermove&#039;, function (event) {&lt;br /&gt;
      if (dragging) {&lt;br /&gt;
        var world = worldPoint(event);&lt;br /&gt;
        dragging.x = world.x;&lt;br /&gt;
        dragging.y = world.y;&lt;br /&gt;
        dragging.vx = 0;&lt;br /&gt;
        dragging.vy = 0;&lt;br /&gt;
      } else if (panning &amp;amp;&amp;amp; pointerStart) {&lt;br /&gt;
        var point = canvasPoint(event);&lt;br /&gt;
        transform.x += point.x - pointerStart.x;&lt;br /&gt;
        transform.y += point.y - pointerStart.y;&lt;br /&gt;
        pointerStart = point;&lt;br /&gt;
      } else {&lt;br /&gt;
        hovered = hitNode(event);&lt;br /&gt;
        canvas.style.cursor = hovered ? &#039;pointer&#039; : &#039;grab&#039;;&lt;br /&gt;
      }&lt;br /&gt;
      draw();&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;pointerup&#039;, function (event) {&lt;br /&gt;
      var hit = hitNode(event);&lt;br /&gt;
      if (dragging &amp;amp;&amp;amp; hit === dragging) {&lt;br /&gt;
        selectNode(dragging);&lt;br /&gt;
      }&lt;br /&gt;
      dragging = null;&lt;br /&gt;
      panning = false;&lt;br /&gt;
      pointerStart = null;&lt;br /&gt;
      canvas.classList.remove(&#039;is-dragging&#039;);&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;dblclick&#039;, function (event) {&lt;br /&gt;
      var node = hitNode(event);&lt;br /&gt;
      if (node) {&lt;br /&gt;
        window.location.href = mw.util.getUrl(node.id);&lt;br /&gt;
      }&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;wheel&#039;, function (event) {&lt;br /&gt;
      event.preventDefault();&lt;br /&gt;
      var point = canvasPoint(event);&lt;br /&gt;
      var oldScale = transform.scale;&lt;br /&gt;
      var nextScale = Math.max(0.25, Math.min(3.5, oldScale * Math.exp(-event.deltaY * 0.0012)));&lt;br /&gt;
      transform.x = point.x - ((point.x - transform.x) / oldScale) * nextScale;&lt;br /&gt;
      transform.y = point.y - ((point.y - transform.y) / oldScale) * nextScale;&lt;br /&gt;
      transform.scale = nextScale;&lt;br /&gt;
      draw();&lt;br /&gt;
    }, { passive: false });&lt;br /&gt;
&lt;br /&gt;
    search.addEventListener(&#039;input&#039;, function () {&lt;br /&gt;
      var query = search.value.trim().toLocaleLowerCase(&#039;ko&#039;);&lt;br /&gt;
      searchResults.replaceChildren();&lt;br /&gt;
      if (!query) {&lt;br /&gt;
        searchResults.hidden = true;&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      var matches = nodes.filter(function (node) {&lt;br /&gt;
        return node.title.toLocaleLowerCase(&#039;ko&#039;).includes(query) || node.id.toLowerCase().includes(query);&lt;br /&gt;
      }).slice(0, 6);&lt;br /&gt;
      matches.forEach(function (node) {&lt;br /&gt;
        var button = makeElement(&#039;button&#039;, &#039;&#039;, node.title);&lt;br /&gt;
        button.type = &#039;button&#039;;&lt;br /&gt;
        button.addEventListener(&#039;click&#039;, function () {&lt;br /&gt;
          selectNode(node);&lt;br /&gt;
          transform.x = size.width / 2 - node.x * transform.scale;&lt;br /&gt;
          transform.y = size.height / 2 - node.y * transform.scale;&lt;br /&gt;
          search.value = node.title;&lt;br /&gt;
          searchResults.hidden = true;&lt;br /&gt;
          draw();&lt;br /&gt;
        });&lt;br /&gt;
        searchResults.appendChild(button);&lt;br /&gt;
      });&lt;br /&gt;
      if (!matches.length) {&lt;br /&gt;
        searchResults.appendChild(makeElement(&#039;span&#039;, &#039;&#039;, &#039;일치하는 메모가 없습니다.&#039;));&lt;br /&gt;
      }&lt;br /&gt;
      searchResults.hidden = false;&lt;br /&gt;
    });&lt;br /&gt;
    search.addEventListener(&#039;keydown&#039;, function (event) {&lt;br /&gt;
      if (event.key === &#039;Escape&#039;) {&lt;br /&gt;
        searchResults.hidden = true;&lt;br /&gt;
      } else if (event.key === &#039;Enter&#039;) {&lt;br /&gt;
        var first = searchResults.querySelector(&#039;button&#039;);&lt;br /&gt;
        if (first) { first.click(); }&lt;br /&gt;
      }&lt;br /&gt;
    });&lt;br /&gt;
    filter.addEventListener(&#039;change&#039;, function () {&lt;br /&gt;
      activeRelation = filter.value;&lt;br /&gt;
      updateCounts();&lt;br /&gt;
      draw();&lt;br /&gt;
    });&lt;br /&gt;
    reset.addEventListener(&#039;click&#039;, fitGraph);&lt;br /&gt;
    clearSearch.addEventListener(&#039;click&#039;, function () {&lt;br /&gt;
      resetSearchCluster();&lt;br /&gt;
      fitGraph();&lt;br /&gt;
    });&lt;br /&gt;
&lt;br /&gt;
    showInspector(null);&lt;br /&gt;
    resize();&lt;br /&gt;
    var resizeObserver = window.ResizeObserver ? new ResizeObserver(resize) : null;&lt;br /&gt;
    if (resizeObserver) {&lt;br /&gt;
      resizeObserver.observe(viewport);&lt;br /&gt;
    } else {&lt;br /&gt;
      window.addEventListener(&#039;resize&#039;, resize);&lt;br /&gt;
    }&lt;br /&gt;
    graphSearchController = focusSearchResults;&lt;br /&gt;
    if (pendingGraphSearch) {&lt;br /&gt;
      focusSearchResults(pendingGraphSearch.items, pendingGraphSearch.query);&lt;br /&gt;
      pendingGraphSearch = null;&lt;br /&gt;
    }&lt;br /&gt;
    animationFrame = window.requestAnimationFrame(simulate);&lt;br /&gt;
    window.addEventListener(&#039;pagehide&#039;, function () {&lt;br /&gt;
      window.cancelAnimationFrame(animationFrame);&lt;br /&gt;
      if (resizeObserver) { resizeObserver.disconnect(); }&lt;br /&gt;
    }, { once: true });&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  var askQuery = &#039;[[Category:Lessons]]&#039; +&lt;br /&gt;
    &#039;|?Lesson title|?Confidence|?Updated at&#039; +&lt;br /&gt;
    RELATIONS.map(function (relation) { return &#039;|?&#039; + relation.property; }).join(&#039;&#039;) +&lt;br /&gt;
    &#039;|sort=Updated at|order=descending|limit=&#039; + NODE_LIMIT;&lt;br /&gt;
&lt;br /&gt;
  createSemanticSearch();&lt;br /&gt;
&lt;br /&gt;
  mw.loader.using([&#039;mediawiki.api&#039;, &#039;mediawiki.util&#039;]).then(function () {&lt;br /&gt;
    return new mw.Api().get({ action: &#039;ask&#039;, query: askQuery, formatversion: 2 });&lt;br /&gt;
  }).then(function (data) {&lt;br /&gt;
    var graph = parseGraph(data);&lt;br /&gt;
    if (!graph.nodes.length) {&lt;br /&gt;
      renderEmpty();&lt;br /&gt;
      return;&lt;br /&gt;
    }&lt;br /&gt;
    createGraph(graph);&lt;br /&gt;
  }).catch(renderError);&lt;br /&gt;
}());&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%EB%AF%B8%EB%94%94%EC%96%B4%EC%9C%84%ED%82%A4:Common.js&amp;diff=2740</id>
		<title>미디어위키:Common.js</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%EB%AF%B8%EB%94%94%EC%96%B4%EC%9C%84%ED%82%A4:Common.js&amp;diff=2740"/>
		<updated>2026-07-19T02:15:13Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 원본 스키마 동기화&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;(function () {&lt;br /&gt;
  &#039;use strict&#039;;&lt;br /&gt;
&lt;br /&gt;
  var root = document.getElementById(&#039;s3rm-knowledge-graph&#039;);&lt;br /&gt;
  var semanticRoot = document.getElementById(&#039;s3rm-semantic-search&#039;);&lt;br /&gt;
  if (!root || !window.mw) {&lt;br /&gt;
    return;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  var RELATIONS = [&lt;br /&gt;
    { property: &#039;Supports&#039;, key: &#039;supports&#039;, label: &#039;뒷받침함&#039;, color: &#039;#5f7ae6&#039; },&lt;br /&gt;
    { property: &#039;Contradicts&#039;, key: &#039;contradicts&#039;, label: &#039;상충함&#039;, color: &#039;#d0605b&#039; },&lt;br /&gt;
    { property: &#039;Analogous to&#039;, key: &#039;analogous_to&#039;, label: &#039;유사함&#039;, color: &#039;#a17bd7&#039; },&lt;br /&gt;
    { property: &#039;Failed because&#039;, key: &#039;failed_because&#039;, label: &#039;실패 원인&#039;, color: &#039;#dc8b45&#039; },&lt;br /&gt;
    { property: &#039;Works when&#039;, key: &#039;works_when&#039;, label: &#039;작동 조건&#039;, color: &#039;#43a27b&#039; },&lt;br /&gt;
    { property: &#039;Suggests&#039;, key: &#039;suggests&#039;, label: &#039;후속 제안&#039;, color: &#039;#3f9db7&#039; },&lt;br /&gt;
    { property: &#039;Supersedes&#039;, key: &#039;supersedes&#039;, label: &#039;대체함&#039;, color: &#039;#7d849e&#039; }&lt;br /&gt;
  ];&lt;br /&gt;
  var NODE_LIMIT = 160;&lt;br /&gt;
  var EDGE_LIMIT = 480;&lt;br /&gt;
  var mcpReady = null;&lt;br /&gt;
  var graphSearchController = null;&lt;br /&gt;
  var pendingGraphSearch = null;&lt;br /&gt;
&lt;br /&gt;
  function mcpRequest(payload, expectBody) {&lt;br /&gt;
    return window.fetch(&#039;/mcp&#039;, {&lt;br /&gt;
      method: &#039;POST&#039;,&lt;br /&gt;
      credentials: &#039;same-origin&#039;,&lt;br /&gt;
      headers: {&lt;br /&gt;
        &#039;Content-Type&#039;: &#039;application/json&#039;,&lt;br /&gt;
        &#039;Accept&#039;: &#039;application/json, text/event-stream&#039;&lt;br /&gt;
      },&lt;br /&gt;
      body: JSON.stringify(payload)&lt;br /&gt;
    }).then(function (response) {&lt;br /&gt;
      if (!response.ok) {&lt;br /&gt;
        throw new Error(&#039;MCP HTTP &#039; + response.status);&lt;br /&gt;
      }&lt;br /&gt;
      if (!expectBody) {&lt;br /&gt;
        return null;&lt;br /&gt;
      }&lt;br /&gt;
      return response.json();&lt;br /&gt;
    });&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function initializeMcp() {&lt;br /&gt;
    if (!mcpReady) {&lt;br /&gt;
      mcpReady = mcpRequest({&lt;br /&gt;
        jsonrpc: &#039;2.0&#039;,&lt;br /&gt;
        id: 1,&lt;br /&gt;
        method: &#039;initialize&#039;,&lt;br /&gt;
        params: {&lt;br /&gt;
          protocolVersion: &#039;2025-06-18&#039;,&lt;br /&gt;
          capabilities: {},&lt;br /&gt;
          clientInfo: { name: &#039;s3rm-wiki-search&#039;, version: &#039;1&#039; }&lt;br /&gt;
        }&lt;br /&gt;
      }, true).then(function (response) {&lt;br /&gt;
        if (!response || response.error) {&lt;br /&gt;
          throw new Error(&#039;MCP initialization failed&#039;);&lt;br /&gt;
        }&lt;br /&gt;
        return mcpRequest({&lt;br /&gt;
          jsonrpc: &#039;2.0&#039;,&lt;br /&gt;
          method: &#039;notifications/initialized&#039;,&lt;br /&gt;
          params: {}&lt;br /&gt;
        }, false);&lt;br /&gt;
      });&lt;br /&gt;
    }&lt;br /&gt;
    return mcpReady;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function searchSemantically(query) {&lt;br /&gt;
    return initializeMcp().then(function () {&lt;br /&gt;
      return mcpRequest({&lt;br /&gt;
        jsonrpc: &#039;2.0&#039;,&lt;br /&gt;
        id: 2,&lt;br /&gt;
        method: &#039;tools/call&#039;,&lt;br /&gt;
        params: {&lt;br /&gt;
          name: &#039;search_lessons&#039;,&lt;br /&gt;
          arguments: {&lt;br /&gt;
            query: query,&lt;br /&gt;
            retrieval_mode: &#039;hybrid_v1&#039;,&lt;br /&gt;
            fields: [&#039;title&#039;, &#039;question&#039;, &#039;reusable_lesson&#039;, &#039;confidence&#039;, &#039;relations&#039;],&lt;br /&gt;
            limit: 6&lt;br /&gt;
          }&lt;br /&gt;
        }&lt;br /&gt;
      }, true);&lt;br /&gt;
    }).then(function (response) {&lt;br /&gt;
      if (!response || response.error || !response.result || response.result.isError) {&lt;br /&gt;
        throw new Error(&#039;Semantic search failed&#039;);&lt;br /&gt;
      }&lt;br /&gt;
      return response.result.structuredContent || {};&lt;br /&gt;
    });&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function textValue(value) {&lt;br /&gt;
    if (value === null || value === undefined) {&lt;br /&gt;
      return &#039;&#039;;&lt;br /&gt;
    }&lt;br /&gt;
    if (typeof value === &#039;string&#039; || typeof value === &#039;number&#039;) {&lt;br /&gt;
      return String(value);&lt;br /&gt;
    }&lt;br /&gt;
    return String(value.fulltext || value.displaytitle || value.raw || value.timestamp || &#039;&#039;);&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function firstValue(values, fallback) {&lt;br /&gt;
    return values &amp;amp;&amp;amp; values.length ? textValue(values[0]) : fallback;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function confidenceLabel(confidence) {&lt;br /&gt;
    if (confidence === &#039;high&#039;) { return &#039;신뢰도: 높음&#039;; }&lt;br /&gt;
    if (confidence === &#039;medium&#039;) { return &#039;신뢰도: 중간&#039;; }&lt;br /&gt;
    return &#039;신뢰도: 낮음&#039;;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function parseGraph(data) {&lt;br /&gt;
    var rawResults = data &amp;amp;&amp;amp; data.query &amp;amp;&amp;amp; data.query.results ? data.query.results : {};&lt;br /&gt;
    var entries = Array.isArray(rawResults)&lt;br /&gt;
      ? rawResults.map(function (item) { return [item.fulltext, item]; })&lt;br /&gt;
      : Object.keys(rawResults).map(function (key) { return [key, rawResults[key]]; });&lt;br /&gt;
    var nodes = [];&lt;br /&gt;
    var byId = Object.create(null);&lt;br /&gt;
&lt;br /&gt;
    entries.slice(0, NODE_LIMIT).forEach(function (entry, index) {&lt;br /&gt;
      var id = entry[0];&lt;br /&gt;
      var result = entry[1] || {};&lt;br /&gt;
      var printouts = result.printouts || {};&lt;br /&gt;
      var angle = index * 2.399963229728653;&lt;br /&gt;
      var radius = 20 * Math.sqrt(index + 1);&lt;br /&gt;
      var node = {&lt;br /&gt;
        id: id,&lt;br /&gt;
        title: firstValue(printouts[&#039;Lesson title&#039;], id.replace(/^Lesson:/, &#039;&#039;)),&lt;br /&gt;
        confidence: firstValue(printouts.Confidence, &#039;low&#039;).toLowerCase(),&lt;br /&gt;
        updatedAt: firstValue(printouts[&#039;Updated at&#039;], &#039;&#039;),&lt;br /&gt;
        x: Math.cos(angle) * radius,&lt;br /&gt;
        y: Math.sin(angle) * radius,&lt;br /&gt;
        vx: 0,&lt;br /&gt;
        vy: 0,&lt;br /&gt;
        degree: 0,&lt;br /&gt;
        printouts: printouts&lt;br /&gt;
      };&lt;br /&gt;
      nodes.push(node);&lt;br /&gt;
      byId[id] = node;&lt;br /&gt;
    });&lt;br /&gt;
&lt;br /&gt;
    var edges = [];&lt;br /&gt;
    nodes.forEach(function (node) {&lt;br /&gt;
      RELATIONS.forEach(function (relation) {&lt;br /&gt;
        var targets = node.printouts[relation.property] || [];&lt;br /&gt;
        targets.forEach(function (target) {&lt;br /&gt;
          var targetId = textValue(target);&lt;br /&gt;
          if (edges.length &amp;lt; EDGE_LIMIT &amp;amp;&amp;amp; byId[targetId] &amp;amp;&amp;amp; targetId !== node.id) {&lt;br /&gt;
            edges.push({ source: node, target: byId[targetId], relation: relation });&lt;br /&gt;
            node.degree += 1;&lt;br /&gt;
            byId[targetId].degree += 1;&lt;br /&gt;
          }&lt;br /&gt;
        });&lt;br /&gt;
      });&lt;br /&gt;
      delete node.printouts;&lt;br /&gt;
    });&lt;br /&gt;
&lt;br /&gt;
    var connectedNodes = nodes.filter(function (node) { return node.degree &amp;gt; 0; });&lt;br /&gt;
    var isolatedNodes = nodes.filter(function (node) { return node.degree === 0; });&lt;br /&gt;
    connectedNodes.forEach(function (node, index) {&lt;br /&gt;
      var angle = index * 2.399963229728653;&lt;br /&gt;
      var radius = 16 * Math.sqrt(index + 1);&lt;br /&gt;
      node.x = Math.cos(angle) * radius;&lt;br /&gt;
      node.y = Math.sin(angle) * radius;&lt;br /&gt;
    });&lt;br /&gt;
    isolatedNodes.forEach(function (node, index) {&lt;br /&gt;
      var angle = index * 2.399963229728653 + 0.7;&lt;br /&gt;
      var radius = 92 + 10 * Math.sqrt(index + 1);&lt;br /&gt;
      node.x = Math.cos(angle) * radius;&lt;br /&gt;
      node.y = Math.sin(angle) * radius;&lt;br /&gt;
    });&lt;br /&gt;
    return { nodes: nodes, edges: edges };&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function makeElement(tag, className, text) {&lt;br /&gt;
    var element = document.createElement(tag);&lt;br /&gt;
    if (className) {&lt;br /&gt;
      element.className = className;&lt;br /&gt;
    }&lt;br /&gt;
    if (text !== undefined) {&lt;br /&gt;
      element.textContent = text;&lt;br /&gt;
    }&lt;br /&gt;
    return element;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function createSemanticSearch() {&lt;br /&gt;
    if (!semanticRoot) {&lt;br /&gt;
      return;&lt;br /&gt;
    }&lt;br /&gt;
    semanticRoot.replaceChildren();&lt;br /&gt;
    var intro = makeElement(&#039;div&#039;, &#039;s3rm-semantic-intro&#039;);&lt;br /&gt;
    intro.appendChild(makeElement(&#039;div&#039;, &#039;s3rm-semantic-icon&#039;, &#039;⌕&#039;));&lt;br /&gt;
    var introCopy = makeElement(&#039;div&#039;, &#039;&#039;);&lt;br /&gt;
    introCopy.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;검색&#039;));&lt;br /&gt;
    introCopy.appendChild(makeElement(&#039;p&#039;, &#039;&#039;, &#039;현상이나 궁금한 점을 문장으로 입력하세요. 표현이 달라도 관련 메모를 찾습니다.&#039;));&lt;br /&gt;
    intro.appendChild(introCopy);&lt;br /&gt;
&lt;br /&gt;
    var form = makeElement(&#039;form&#039;, &#039;s3rm-semantic-form&#039;);&lt;br /&gt;
    var input = makeElement(&#039;input&#039;, &#039;s3rm-semantic-input&#039;);&lt;br /&gt;
    input.type = &#039;search&#039;;&lt;br /&gt;
    input.name = &#039;query&#039;;&lt;br /&gt;
    input.maxLength = 500;&lt;br /&gt;
    input.placeholder = &#039;예: GPU 메모리는 남아 있는데 할당이 실패하는 이유는?&#039;;&lt;br /&gt;
    input.setAttribute(&#039;aria-label&#039;, &#039;메모 검색&#039;);&lt;br /&gt;
    var submit = makeElement(&#039;button&#039;, &#039;s3rm-semantic-submit&#039;, &#039;검색&#039;);&lt;br /&gt;
    submit.type = &#039;submit&#039;;&lt;br /&gt;
    form.append(input, submit);&lt;br /&gt;
&lt;br /&gt;
    var examples = makeElement(&#039;div&#039;, &#039;s3rm-semantic-examples&#039;);&lt;br /&gt;
    examples.appendChild(makeElement(&#039;span&#039;, &#039;&#039;, &#039;검색 예시&#039;));&lt;br /&gt;
    [&lt;br /&gt;
      &#039;재시도가 한꺼번에 몰리는 이유&#039;,&lt;br /&gt;
      &#039;GPU와 SSD 사이 병목을 줄이는 방법&#039;,&lt;br /&gt;
      &#039;긴 작업에서 지연이 갑자기 커지는 원인&#039;&lt;br /&gt;
    ].forEach(function (example) {&lt;br /&gt;
      var button = makeElement(&#039;button&#039;, &#039;&#039;, example);&lt;br /&gt;
      button.type = &#039;button&#039;;&lt;br /&gt;
      button.addEventListener(&#039;click&#039;, function () {&lt;br /&gt;
        input.value = example;&lt;br /&gt;
        form.requestSubmit();&lt;br /&gt;
      });&lt;br /&gt;
      examples.appendChild(button);&lt;br /&gt;
    });&lt;br /&gt;
&lt;br /&gt;
    var feedback = makeElement(&#039;div&#039;, &#039;s3rm-semantic-feedback&#039;);&lt;br /&gt;
    feedback.setAttribute(&#039;aria-live&#039;, &#039;polite&#039;);&lt;br /&gt;
    feedback.hidden = true;&lt;br /&gt;
    semanticRoot.append(intro, form, examples, feedback);&lt;br /&gt;
&lt;br /&gt;
    function renderSearchResults(payload, query) {&lt;br /&gt;
      var items = Array.isArray(payload.items) ? payload.items : [];&lt;br /&gt;
      if (graphSearchController) {&lt;br /&gt;
        graphSearchController(items, query);&lt;br /&gt;
      } else {&lt;br /&gt;
        pendingGraphSearch = { items: items, query: query };&lt;br /&gt;
      }&lt;br /&gt;
      feedback.replaceChildren();&lt;br /&gt;
      feedback.hidden = false;&lt;br /&gt;
      var resultHeader = makeElement(&#039;div&#039;, &#039;s3rm-semantic-result-header&#039;);&lt;br /&gt;
      resultHeader.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;“&#039; + query + &#039;” 검색 결과&#039;));&lt;br /&gt;
      resultHeader.appendChild(makeElement(&#039;span&#039;, &#039;s3rm-semantic-mode&#039;, items.length + &#039;개 메모&#039;));&lt;br /&gt;
      feedback.appendChild(resultHeader);&lt;br /&gt;
&lt;br /&gt;
      if (!items.length) {&lt;br /&gt;
        feedback.appendChild(makeElement(&#039;p&#039;, &#039;s3rm-semantic-empty&#039;, &#039;가까운 메모를 찾지 못했습니다. 표현을 조금 바꿔 다시 검색해 보세요.&#039;));&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      feedback.appendChild(makeElement(&#039;p&#039;, &#039;s3rm-semantic-graph-note&#039;, &#039;아래 그래프에 검색 결과를 모았습니다. 점을 선택하면 메모와 연결을 볼 수 있습니다.&#039;));&lt;br /&gt;
      var list = makeElement(&#039;div&#039;, &#039;s3rm-semantic-result-links&#039;);&lt;br /&gt;
      items.forEach(function (item) {&lt;br /&gt;
        var link = makeElement(&#039;a&#039;, &#039;&#039;, item.title || String(item.id).replace(/^Lesson:/, &#039;&#039;));&lt;br /&gt;
        link.href = mw.util.getUrl(item.id);&lt;br /&gt;
        list.appendChild(link);&lt;br /&gt;
      });&lt;br /&gt;
      feedback.appendChild(list);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    form.addEventListener(&#039;submit&#039;, function (event) {&lt;br /&gt;
      event.preventDefault();&lt;br /&gt;
      var query = input.value.trim();&lt;br /&gt;
      if (query.length &amp;lt; 2) {&lt;br /&gt;
        feedback.replaceChildren(makeElement(&#039;p&#039;, &#039;s3rm-semantic-error&#039;, &#039;두 글자 이상 입력해 주세요.&#039;));&lt;br /&gt;
        feedback.hidden = false;&lt;br /&gt;
        input.focus();&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      submit.disabled = true;&lt;br /&gt;
      submit.textContent = &#039;찾는 중…&#039;;&lt;br /&gt;
      feedback.replaceChildren(makeElement(&#039;div&#039;, &#039;s3rm-semantic-searching&#039;, &#039;관련 메모를 찾고 있습니다.&#039;));&lt;br /&gt;
      feedback.hidden = false;&lt;br /&gt;
      searchSemantically(query).then(function (payload) {&lt;br /&gt;
        renderSearchResults(payload, query);&lt;br /&gt;
      }).catch(function () {&lt;br /&gt;
        feedback.replaceChildren();&lt;br /&gt;
        var error = makeElement(&#039;div&#039;, &#039;s3rm-semantic-error&#039;);&lt;br /&gt;
        error.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;지금은 검색을 사용할 수 없습니다.&#039;));&lt;br /&gt;
        var fallback = makeElement(&#039;a&#039;, &#039;&#039;, &#039;검색 열기&#039;);&lt;br /&gt;
        fallback.href = mw.util.getUrl(&#039;Special:Search&#039;, { search: query });&lt;br /&gt;
        error.appendChild(fallback);&lt;br /&gt;
        feedback.appendChild(error);&lt;br /&gt;
      }).finally(function () {&lt;br /&gt;
        submit.disabled = false;&lt;br /&gt;
        submit.textContent = &#039;검색&#039;;&lt;br /&gt;
      });&lt;br /&gt;
    });&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function renderEmpty() {&lt;br /&gt;
    root.replaceChildren();&lt;br /&gt;
    var empty = makeElement(&#039;div&#039;, &#039;s3rm-graph-empty&#039;);&lt;br /&gt;
    empty.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;아직 연결할 메모가 없습니다&#039;));&lt;br /&gt;
    empty.appendChild(makeElement(&#039;p&#039;, &#039;&#039;, &#039;첫 메모를 남기면 이곳에서 지식의 연결을 볼 수 있습니다.&#039;));&lt;br /&gt;
    var link = makeElement(&#039;a&#039;, &#039;s3rm-graph-primary-link&#039;, &#039;첫 메모 작성하기&#039;);&lt;br /&gt;
    link.href = &#039;#quick-start&#039;;&lt;br /&gt;
    empty.appendChild(link);&lt;br /&gt;
    root.appendChild(empty);&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function renderError() {&lt;br /&gt;
    root.replaceChildren();&lt;br /&gt;
    var fallback = makeElement(&#039;div&#039;, &#039;s3rm-graph-empty&#039;);&lt;br /&gt;
    fallback.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;그래프를 불러오지 못했습니다&#039;));&lt;br /&gt;
    fallback.appendChild(makeElement(&#039;p&#039;, &#039;&#039;, &#039;최근 메모와 검색은 계속 사용할 수 있습니다. 잠시 뒤 다시 시도해 주세요.&#039;));&lt;br /&gt;
    var link = makeElement(&#039;a&#039;, &#039;s3rm-graph-primary-link&#039;, &#039;전체 메모 보기&#039;);&lt;br /&gt;
    link.href = mw.util.getUrl(&#039;Special:AllPages/Lesson:&#039;);&lt;br /&gt;
    fallback.appendChild(link);&lt;br /&gt;
    root.appendChild(fallback);&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function createGraph(graph) {&lt;br /&gt;
    var nodes = graph.nodes;&lt;br /&gt;
    var edges = graph.edges;&lt;br /&gt;
    var byId = Object.create(null);&lt;br /&gt;
    function canonicalId(id) {&lt;br /&gt;
      return String(id || &#039;&#039;).replace(/_/g, &#039; &#039;).toLocaleLowerCase(&#039;en&#039;);&lt;br /&gt;
    }&lt;br /&gt;
    nodes.forEach(function (node) { byId[canonicalId(node.id)] = node; });&lt;br /&gt;
    var selected = null;&lt;br /&gt;
    var hovered = null;&lt;br /&gt;
    var searchCluster = null;&lt;br /&gt;
    var activeRelation = &#039;all&#039;;&lt;br /&gt;
    var transform = { x: 0, y: 0, scale: 1 };&lt;br /&gt;
    var dragging = null;&lt;br /&gt;
    var panning = false;&lt;br /&gt;
    var pointerStart = null;&lt;br /&gt;
    var animationFrame = 0;&lt;br /&gt;
    var simulationTicks = 0;&lt;br /&gt;
&lt;br /&gt;
    root.replaceChildren();&lt;br /&gt;
    var toolbar = makeElement(&#039;div&#039;, &#039;s3rm-graph-toolbar&#039;);&lt;br /&gt;
    var searchWrap = makeElement(&#039;div&#039;, &#039;s3rm-graph-search&#039;);&lt;br /&gt;
    var search = makeElement(&#039;input&#039;, &#039;s3rm-graph-search-input&#039;);&lt;br /&gt;
    search.type = &#039;search&#039;;&lt;br /&gt;
    search.placeholder = &#039;그래프에서 제목 필터&#039;;&lt;br /&gt;
    search.setAttribute(&#039;aria-label&#039;, &#039;그래프 제목 필터&#039;);&lt;br /&gt;
    search.autocomplete = &#039;off&#039;;&lt;br /&gt;
    var searchResults = makeElement(&#039;div&#039;, &#039;s3rm-graph-search-results&#039;);&lt;br /&gt;
    searchResults.hidden = true;&lt;br /&gt;
    searchWrap.append(search, searchResults);&lt;br /&gt;
&lt;br /&gt;
    var filter = makeElement(&#039;select&#039;, &#039;s3rm-graph-filter&#039;);&lt;br /&gt;
    filter.setAttribute(&#039;aria-label&#039;, &#039;관계 유형 필터&#039;);&lt;br /&gt;
    var allOption = makeElement(&#039;option&#039;, &#039;&#039;, &#039;모든 관계&#039;);&lt;br /&gt;
    allOption.value = &#039;all&#039;;&lt;br /&gt;
    filter.appendChild(allOption);&lt;br /&gt;
    RELATIONS.forEach(function (relation) {&lt;br /&gt;
      var option = makeElement(&#039;option&#039;, &#039;&#039;, relation.label);&lt;br /&gt;
      option.value = relation.key;&lt;br /&gt;
      filter.appendChild(option);&lt;br /&gt;
    });&lt;br /&gt;
    var reset = makeElement(&#039;button&#039;, &#039;s3rm-graph-reset&#039;, &#039;화면 맞춤&#039;);&lt;br /&gt;
    reset.type = &#039;button&#039;;&lt;br /&gt;
    var clearSearch = makeElement(&#039;button&#039;, &#039;s3rm-graph-clear-search&#039;, &#039;검색 결과 해제&#039;);&lt;br /&gt;
    clearSearch.type = &#039;button&#039;;&lt;br /&gt;
    clearSearch.hidden = true;&lt;br /&gt;
    toolbar.append(searchWrap, filter, reset, clearSearch);&lt;br /&gt;
&lt;br /&gt;
    var body = makeElement(&#039;div&#039;, &#039;s3rm-graph-body&#039;);&lt;br /&gt;
    var viewport = makeElement(&#039;div&#039;, &#039;s3rm-graph-viewport&#039;);&lt;br /&gt;
    var canvas = document.createElement(&#039;canvas&#039;);&lt;br /&gt;
    canvas.className = &#039;s3rm-graph-canvas&#039;;&lt;br /&gt;
    canvas.setAttribute(&#039;aria-hidden&#039;, &#039;true&#039;);&lt;br /&gt;
    viewport.appendChild(canvas);&lt;br /&gt;
    var hint = makeElement(&#039;div&#039;, &#039;s3rm-graph-hint&#039;, &#039;드래그하여 이동 · 스크롤하여 확대 · 두 번 눌러 메모 열기&#039;);&lt;br /&gt;
    viewport.appendChild(hint);&lt;br /&gt;
&lt;br /&gt;
    var inspector = makeElement(&#039;aside&#039;, &#039;s3rm-graph-inspector&#039;);&lt;br /&gt;
    inspector.setAttribute(&#039;aria-live&#039;, &#039;polite&#039;);&lt;br /&gt;
    body.append(viewport, inspector);&lt;br /&gt;
&lt;br /&gt;
    var footer = makeElement(&#039;div&#039;, &#039;s3rm-graph-footer&#039;);&lt;br /&gt;
    var counts = makeElement(&#039;span&#039;, &#039;s3rm-graph-counts&#039;, nodes.length + &#039;개 메모 · &#039; + edges.length + &#039;개 연결&#039;);&lt;br /&gt;
    var legend = makeElement(&#039;div&#039;, &#039;s3rm-graph-legend&#039;);&lt;br /&gt;
    RELATIONS.slice(0, 4).forEach(function (relation) {&lt;br /&gt;
      var item = makeElement(&#039;span&#039;, &#039;s3rm-graph-legend-item&#039;);&lt;br /&gt;
      var swatch = makeElement(&#039;i&#039;, &#039;&#039;);&lt;br /&gt;
      swatch.style.backgroundColor = relation.color;&lt;br /&gt;
      item.append(swatch, document.createTextNode(relation.label));&lt;br /&gt;
      legend.appendChild(item);&lt;br /&gt;
    });&lt;br /&gt;
    footer.append(counts, legend);&lt;br /&gt;
    root.append(toolbar, body, footer);&lt;br /&gt;
&lt;br /&gt;
    var context = canvas.getContext(&#039;2d&#039;);&lt;br /&gt;
    var size = { width: 0, height: 0, ratio: 1 };&lt;br /&gt;
&lt;br /&gt;
    function visibleEdges() {&lt;br /&gt;
      return activeRelation === &#039;all&#039;&lt;br /&gt;
        ? edges&lt;br /&gt;
        : edges.filter(function (edge) { return edge.relation.key === activeRelation; });&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function nodeRadius(node) {&lt;br /&gt;
      return 6 + Math.min(8, Math.sqrt(node.degree) * 2.2) + (node.isSearchResult ? 3 : 0);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function confidenceColor(confidence) {&lt;br /&gt;
      if (confidence === &#039;high&#039;) { return &#039;#53c18b&#039;; }&lt;br /&gt;
      if (confidence === &#039;medium&#039;) { return &#039;#8ca8ff&#039;; }&lt;br /&gt;
      return &#039;#c0c5d8&#039;;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function resize() {&lt;br /&gt;
      var rect = viewport.getBoundingClientRect();&lt;br /&gt;
      size.width = Math.max(320, rect.width);&lt;br /&gt;
      size.height = Math.max(360, rect.height);&lt;br /&gt;
      size.ratio = Math.min(window.devicePixelRatio || 1, 2);&lt;br /&gt;
      canvas.width = Math.round(size.width * size.ratio);&lt;br /&gt;
      canvas.height = Math.round(size.height * size.ratio);&lt;br /&gt;
      canvas.style.width = size.width + &#039;px&#039;;&lt;br /&gt;
      canvas.style.height = size.height + &#039;px&#039;;&lt;br /&gt;
      draw();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function fitGraph() {&lt;br /&gt;
      if (!nodes.length) { return; }&lt;br /&gt;
      var xs = nodes.map(function (node) { return node.x; });&lt;br /&gt;
      var ys = nodes.map(function (node) { return node.y; });&lt;br /&gt;
      var minX = Math.min.apply(null, xs) - 38;&lt;br /&gt;
      var maxX = Math.max.apply(null, xs) + 38;&lt;br /&gt;
      var minY = Math.min.apply(null, ys) - 38;&lt;br /&gt;
      var maxY = Math.max.apply(null, ys) + 38;&lt;br /&gt;
      transform.scale = Math.max(0.2, Math.min(1.25, Math.min(&lt;br /&gt;
        size.width / Math.max(1, maxX - minX),&lt;br /&gt;
        size.height / Math.max(1, maxY - minY)&lt;br /&gt;
      )));&lt;br /&gt;
      transform.x = size.width / 2 - ((minX + maxX) / 2) * transform.scale;&lt;br /&gt;
      transform.y = size.height / 2 - ((minY + maxY) / 2) * transform.scale;&lt;br /&gt;
      draw();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function draw() {&lt;br /&gt;
      context.setTransform(size.ratio, 0, 0, size.ratio, 0, 0);&lt;br /&gt;
      context.clearRect(0, 0, size.width, size.height);&lt;br /&gt;
      context.save();&lt;br /&gt;
      context.translate(transform.x, transform.y);&lt;br /&gt;
      context.scale(transform.scale, transform.scale);&lt;br /&gt;
&lt;br /&gt;
      if (searchCluster) {&lt;br /&gt;
        context.beginPath();&lt;br /&gt;
        context.arc(&lt;br /&gt;
          searchCluster.x,&lt;br /&gt;
          searchCluster.y,&lt;br /&gt;
          searchCluster.radius + 48,&lt;br /&gt;
          0,&lt;br /&gt;
          Math.PI * 2&lt;br /&gt;
        );&lt;br /&gt;
        context.fillStyle = &#039;rgba(104, 126, 233, 0.12)&#039;;&lt;br /&gt;
        context.fill();&lt;br /&gt;
        context.setLineDash([7 / transform.scale, 6 / transform.scale]);&lt;br /&gt;
        context.lineWidth = 1.5 / transform.scale;&lt;br /&gt;
        context.strokeStyle = &#039;rgba(140, 168, 255, 0.75)&#039;;&lt;br /&gt;
        context.stroke();&lt;br /&gt;
        context.setLineDash([]);&lt;br /&gt;
        context.font = &#039;700 &#039; + (12 / transform.scale) + &#039;px sans-serif&#039;;&lt;br /&gt;
        context.textAlign = &#039;center&#039;;&lt;br /&gt;
        context.textBaseline = &#039;bottom&#039;;&lt;br /&gt;
        context.fillStyle = &#039;#b9c7ff&#039;;&lt;br /&gt;
        context.fillText(&lt;br /&gt;
          &#039;검색 결과 · &#039; + searchCluster.nodes.length + &#039;개 메모&#039;,&lt;br /&gt;
          searchCluster.x,&lt;br /&gt;
          searchCluster.y - searchCluster.radius - 58 / transform.scale&lt;br /&gt;
        );&lt;br /&gt;
      }&lt;br /&gt;
&lt;br /&gt;
      visibleEdges().forEach(function (edge) {&lt;br /&gt;
        var emphasized = selected &amp;amp;&amp;amp; (edge.source === selected || edge.target === selected);&lt;br /&gt;
        var searchEdge = searchCluster &amp;amp;&amp;amp;&lt;br /&gt;
          (edge.source.isSearchResult || edge.target.isSearchResult);&lt;br /&gt;
        context.globalAlpha = searchCluster &amp;amp;&amp;amp; !searchEdge ? 0.1 : 1;&lt;br /&gt;
        context.beginPath();&lt;br /&gt;
        context.moveTo(edge.source.x, edge.source.y);&lt;br /&gt;
        context.lineTo(edge.target.x, edge.target.y);&lt;br /&gt;
        context.strokeStyle = emphasized ? edge.relation.color : edge.relation.color + &#039;55&#039;;&lt;br /&gt;
        context.lineWidth = (emphasized || searchEdge ? 2.2 : 1) / transform.scale;&lt;br /&gt;
        context.stroke();&lt;br /&gt;
      });&lt;br /&gt;
      context.globalAlpha = 1;&lt;br /&gt;
&lt;br /&gt;
      nodes.forEach(function (node) {&lt;br /&gt;
        var isSelected = node === selected;&lt;br /&gt;
        var isHovered = node === hovered;&lt;br /&gt;
        var isSearchResult = Boolean(node.isSearchResult);&lt;br /&gt;
        var radius = nodeRadius(node);&lt;br /&gt;
        context.globalAlpha = searchCluster &amp;amp;&amp;amp; !isSearchResult ? 0.12 : 1;&lt;br /&gt;
        context.beginPath();&lt;br /&gt;
        context.arc(node.x, node.y, radius + (isSelected ? 4 : 0), 0, Math.PI * 2);&lt;br /&gt;
        context.fillStyle = isSelected || isSearchResult ? &#039;#ffffff&#039; : confidenceColor(node.confidence);&lt;br /&gt;
        context.fill();&lt;br /&gt;
        context.lineWidth = (isSelected || isSearchResult ? 3 : 1.5) / transform.scale;&lt;br /&gt;
        context.strokeStyle = isSelected || isSearchResult ? &#039;#8ca8ff&#039; : &#039;#11182e&#039;;&lt;br /&gt;
        context.stroke();&lt;br /&gt;
&lt;br /&gt;
        if (isSelected || isHovered || isSearchResult ||&lt;br /&gt;
          (transform.scale &amp;gt; 0.68 &amp;amp;&amp;amp; node.degree &amp;gt; 0)) {&lt;br /&gt;
          context.font = (isSelected || isSearchResult ? &#039;600 &#039; : &#039;&#039;) + (12 / transform.scale) + &#039;px sans-serif&#039;;&lt;br /&gt;
          context.textAlign = &#039;center&#039;;&lt;br /&gt;
          context.textBaseline = &#039;top&#039;;&lt;br /&gt;
          context.fillStyle = &#039;#f5f7ff&#039;;&lt;br /&gt;
          context.shadowColor = &#039;#080b16&#039;;&lt;br /&gt;
          context.shadowBlur = 5 / transform.scale;&lt;br /&gt;
          var label = node.title.length &amp;gt; 26 ? node.title.slice(0, 25) + &#039;…&#039; : node.title;&lt;br /&gt;
          context.fillText(label, node.x, node.y + radius + 7 / transform.scale);&lt;br /&gt;
          context.shadowBlur = 0;&lt;br /&gt;
        }&lt;br /&gt;
      });&lt;br /&gt;
      context.globalAlpha = 1;&lt;br /&gt;
      context.restore();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function simulate() {&lt;br /&gt;
      if (simulationTicks &amp;gt; 220) {&lt;br /&gt;
        if (searchCluster) {&lt;br /&gt;
          draw();&lt;br /&gt;
        } else {&lt;br /&gt;
          fitGraph();&lt;br /&gt;
        }&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      for (var pass = 0; pass &amp;lt; 3; pass += 1) {&lt;br /&gt;
        for (var i = 0; i &amp;lt; nodes.length; i += 1) {&lt;br /&gt;
          for (var j = i + 1; j &amp;lt; nodes.length; j += 1) {&lt;br /&gt;
            var first = nodes[i];&lt;br /&gt;
            var second = nodes[j];&lt;br /&gt;
            var dx = second.x - first.x || 0.01;&lt;br /&gt;
            var dy = second.y - first.y || 0.01;&lt;br /&gt;
            var distanceSquared = dx * dx + dy * dy;&lt;br /&gt;
            var distance = Math.sqrt(distanceSquared) || 0.01;&lt;br /&gt;
            var minimumDistance = nodeRadius(first) + nodeRadius(second) + 7;&lt;br /&gt;
            var force = distance &amp;lt; minimumDistance&lt;br /&gt;
              ? (minimumDistance - distance) * 0.035 / distance&lt;br /&gt;
              : 1.8 / Math.max(80, distanceSquared);&lt;br /&gt;
            first.vx -= dx * force;&lt;br /&gt;
            first.vy -= dy * force;&lt;br /&gt;
            second.vx += dx * force;&lt;br /&gt;
            second.vy += dy * force;&lt;br /&gt;
          }&lt;br /&gt;
        }&lt;br /&gt;
        edges.forEach(function (edge) {&lt;br /&gt;
          var dx = edge.target.x - edge.source.x;&lt;br /&gt;
          var dy = edge.target.y - edge.source.y;&lt;br /&gt;
          var distance = Math.sqrt(dx * dx + dy * dy) || 1;&lt;br /&gt;
          var force = (distance - 64) * 0.0014;&lt;br /&gt;
          edge.source.vx += dx * force;&lt;br /&gt;
          edge.source.vy += dy * force;&lt;br /&gt;
          edge.target.vx -= dx * force;&lt;br /&gt;
          edge.target.vy -= dy * force;&lt;br /&gt;
        });&lt;br /&gt;
        nodes.forEach(function (node) {&lt;br /&gt;
          if (node.isSearchResult) {&lt;br /&gt;
            node.vx = 0;&lt;br /&gt;
            node.vy = 0;&lt;br /&gt;
            return;&lt;br /&gt;
          }&lt;br /&gt;
          node.vx += -node.x * 0.0018;&lt;br /&gt;
          node.vy += -node.y * 0.0018;&lt;br /&gt;
          node.vx *= 0.82;&lt;br /&gt;
          node.vy *= 0.82;&lt;br /&gt;
          node.x += node.vx;&lt;br /&gt;
          node.y += node.vy;&lt;br /&gt;
        });&lt;br /&gt;
        simulationTicks += 1;&lt;br /&gt;
      }&lt;br /&gt;
      draw();&lt;br /&gt;
      animationFrame = window.requestAnimationFrame(simulate);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function relationDefinition(key) {&lt;br /&gt;
      return RELATIONS.find(function (relation) { return relation.key === key; });&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function updateCounts() {&lt;br /&gt;
      if (searchCluster) {&lt;br /&gt;
        var relatedEdges = edges.filter(function (edge) {&lt;br /&gt;
          return edge.source.isSearchResult || edge.target.isSearchResult;&lt;br /&gt;
        }).length;&lt;br /&gt;
        counts.textContent = &#039;검색 결과 &#039; + searchCluster.nodes.length + &#039;개 · &#039; +&lt;br /&gt;
          relatedEdges + &#039;개 연결&#039;;&lt;br /&gt;
      } else {&lt;br /&gt;
        counts.textContent = nodes.length + &#039;개 메모 · &#039; + visibleEdges().length + &#039;개 연결&#039;;&lt;br /&gt;
      }&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function resetSearchCluster() {&lt;br /&gt;
      var addedKeys = Object.create(null);&lt;br /&gt;
      edges.forEach(function (edge) {&lt;br /&gt;
        if (edge.searchAdded) {&lt;br /&gt;
          edge.source.degree = Math.max(0, edge.source.degree - 1);&lt;br /&gt;
          edge.target.degree = Math.max(0, edge.target.degree - 1);&lt;br /&gt;
        }&lt;br /&gt;
      });&lt;br /&gt;
      nodes.forEach(function (node) {&lt;br /&gt;
        if (node.searchAdded) {&lt;br /&gt;
          addedKeys[canonicalId(node.id)] = true;&lt;br /&gt;
          delete byId[canonicalId(node.id)];&lt;br /&gt;
        } else if (node.preSearchPosition) {&lt;br /&gt;
          node.x = node.preSearchPosition.x;&lt;br /&gt;
          node.y = node.preSearchPosition.y;&lt;br /&gt;
        }&lt;br /&gt;
        delete node.isSearchResult;&lt;br /&gt;
        delete node.preSearchPosition;&lt;br /&gt;
      });&lt;br /&gt;
      nodes = nodes.filter(function (node) { return !node.searchAdded; });&lt;br /&gt;
      edges = edges.filter(function (edge) {&lt;br /&gt;
        return !edge.searchAdded &amp;amp;&amp;amp;&lt;br /&gt;
          !addedKeys[canonicalId(edge.source.id)] &amp;amp;&amp;amp;&lt;br /&gt;
          !addedKeys[canonicalId(edge.target.id)];&lt;br /&gt;
      });&lt;br /&gt;
      searchCluster = null;&lt;br /&gt;
      clearSearch.hidden = true;&lt;br /&gt;
      selected = null;&lt;br /&gt;
      showInspector(null);&lt;br /&gt;
      updateCounts();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function focusSearchResults(items, query) {&lt;br /&gt;
      resetSearchCluster();&lt;br /&gt;
      if (!items.length) {&lt;br /&gt;
        fitGraph();&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
&lt;br /&gt;
      var resultNodes = [];&lt;br /&gt;
      items.forEach(function (item, index) {&lt;br /&gt;
        var key = canonicalId(item.id);&lt;br /&gt;
        var node = byId[key];&lt;br /&gt;
        if (!node) {&lt;br /&gt;
          node = {&lt;br /&gt;
            id: item.id,&lt;br /&gt;
            title: item.title || String(item.id).replace(/^Lesson:/, &#039;&#039;),&lt;br /&gt;
            confidence: item.confidence || &#039;low&#039;,&lt;br /&gt;
            updatedAt: &#039;&#039;,&lt;br /&gt;
            x: 0,&lt;br /&gt;
            y: 0,&lt;br /&gt;
            vx: 0,&lt;br /&gt;
            vy: 0,&lt;br /&gt;
            degree: 0,&lt;br /&gt;
            searchAdded: true&lt;br /&gt;
          };&lt;br /&gt;
          nodes.push(node);&lt;br /&gt;
          byId[key] = node;&lt;br /&gt;
        } else {&lt;br /&gt;
          node.preSearchPosition = { x: node.x, y: node.y };&lt;br /&gt;
          node.title = item.title || node.title;&lt;br /&gt;
          node.confidence = item.confidence || node.confidence;&lt;br /&gt;
        }&lt;br /&gt;
        node.isSearchResult = true;&lt;br /&gt;
        node.searchRank = index + 1;&lt;br /&gt;
        resultNodes.push(node);&lt;br /&gt;
      });&lt;br /&gt;
&lt;br /&gt;
      items.forEach(function (item) {&lt;br /&gt;
        (Array.isArray(item.relations) ? item.relations : []).forEach(function (relationItem) {&lt;br /&gt;
          var source = byId[canonicalId(relationItem.from || item.id)];&lt;br /&gt;
          var target = byId[canonicalId(relationItem.to)];&lt;br /&gt;
          var relation = relationDefinition(relationItem.relation);&lt;br /&gt;
          if (!source || !target || !relation || source === target) {&lt;br /&gt;
            return;&lt;br /&gt;
          }&lt;br /&gt;
          var duplicate = edges.some(function (edge) {&lt;br /&gt;
            return canonicalId(edge.source.id) === canonicalId(source.id) &amp;amp;&amp;amp;&lt;br /&gt;
              canonicalId(edge.target.id) === canonicalId(target.id) &amp;amp;&amp;amp;&lt;br /&gt;
              edge.relation.key === relation.key;&lt;br /&gt;
          });&lt;br /&gt;
          if (!duplicate) {&lt;br /&gt;
            edges.push({&lt;br /&gt;
              source: source,&lt;br /&gt;
              target: target,&lt;br /&gt;
              relation: relation,&lt;br /&gt;
              searchAdded: true&lt;br /&gt;
            });&lt;br /&gt;
            source.degree += 1;&lt;br /&gt;
            target.degree += 1;&lt;br /&gt;
          }&lt;br /&gt;
        });&lt;br /&gt;
      });&lt;br /&gt;
&lt;br /&gt;
      var clusterRadius = resultNodes.length === 1 ? 0 :&lt;br /&gt;
        Math.max(82, Math.min(132, resultNodes.length * 22));&lt;br /&gt;
      resultNodes.forEach(function (node, index) {&lt;br /&gt;
        var angle = -Math.PI / 2 + (Math.PI * 2 * index / resultNodes.length);&lt;br /&gt;
        node.x = Math.cos(angle) * clusterRadius;&lt;br /&gt;
        node.y = Math.sin(angle) * clusterRadius;&lt;br /&gt;
        node.vx = 0;&lt;br /&gt;
        node.vy = 0;&lt;br /&gt;
      });&lt;br /&gt;
      searchCluster = {&lt;br /&gt;
        query: query,&lt;br /&gt;
        nodes: resultNodes,&lt;br /&gt;
        x: 0,&lt;br /&gt;
        y: 0,&lt;br /&gt;
        radius: clusterRadius&lt;br /&gt;
      };&lt;br /&gt;
      activeRelation = &#039;all&#039;;&lt;br /&gt;
      filter.value = &#039;all&#039;;&lt;br /&gt;
      clearSearch.hidden = false;&lt;br /&gt;
      transform.scale = Math.max(0.72, Math.min(&lt;br /&gt;
        1.35,&lt;br /&gt;
        Math.min(size.width, size.height) / Math.max(360, clusterRadius * 2 + 150)&lt;br /&gt;
      ));&lt;br /&gt;
      transform.x = size.width / 2;&lt;br /&gt;
      transform.y = size.height / 2;&lt;br /&gt;
      selectNode(resultNodes[0]);&lt;br /&gt;
      updateCounts();&lt;br /&gt;
      draw();&lt;br /&gt;
      viewport.scrollIntoView({ behavior: &#039;smooth&#039;, block: &#039;center&#039; });&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function connectionSummary(node) {&lt;br /&gt;
      var connected = edges.filter(function (edge) {&lt;br /&gt;
        return edge.source === node || edge.target === node;&lt;br /&gt;
      });&lt;br /&gt;
      var summary = Object.create(null);&lt;br /&gt;
      connected.forEach(function (edge) {&lt;br /&gt;
        summary[edge.relation.label] = (summary[edge.relation.label] || 0) + 1;&lt;br /&gt;
      });&lt;br /&gt;
      return { edges: connected, counts: summary };&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function showInspector(node) {&lt;br /&gt;
      inspector.replaceChildren();&lt;br /&gt;
      if (!node) {&lt;br /&gt;
        var welcome = makeElement(&#039;div&#039;, &#039;s3rm-inspector-welcome&#039;);&lt;br /&gt;
        welcome.appendChild(makeElement(&#039;span&#039;, &#039;s3rm-inspector-symbol&#039;, &#039;⌁&#039;));&lt;br /&gt;
        welcome.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;메모를 선택하세요&#039;));&lt;br /&gt;
        welcome.appendChild(makeElement(&#039;p&#039;, &#039;&#039;, &#039;점 하나를 누르면 메모 정보와 연결된 기록을 보여 드립니다.&#039;));&lt;br /&gt;
        inspector.appendChild(welcome);&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      var summary = connectionSummary(node);&lt;br /&gt;
      inspector.appendChild(makeElement(&lt;br /&gt;
        &#039;div&#039;,&lt;br /&gt;
        &#039;s3rm-inspector-kicker&#039;,&lt;br /&gt;
        node.isSearchResult ? &#039;검색 결과 &#039; + node.searchRank : &#039;선택한 메모&#039;&lt;br /&gt;
      ));&lt;br /&gt;
      inspector.appendChild(makeElement(&#039;h3&#039;, &#039;&#039;, node.title));&lt;br /&gt;
      var meta = makeElement(&#039;div&#039;, &#039;s3rm-inspector-meta&#039;);&lt;br /&gt;
      meta.appendChild(makeElement(&#039;span&#039;, &#039;s3rm-confidence s3rm-confidence-&#039; + node.confidence, confidenceLabel(node.confidence)));&lt;br /&gt;
      meta.appendChild(makeElement(&#039;span&#039;, &#039;&#039;, summary.edges.length + &#039;개 연결&#039;));&lt;br /&gt;
      inspector.appendChild(meta);&lt;br /&gt;
      if (Object.keys(summary.counts).length) {&lt;br /&gt;
        var relationList = makeElement(&#039;ul&#039;, &#039;s3rm-inspector-relations&#039;);&lt;br /&gt;
        Object.keys(summary.counts).forEach(function (label) {&lt;br /&gt;
          var item = makeElement(&#039;li&#039;, &#039;&#039;);&lt;br /&gt;
          item.append(makeElement(&#039;span&#039;, &#039;&#039;, label), makeElement(&#039;strong&#039;, &#039;&#039;, String(summary.counts[label])));&lt;br /&gt;
          relationList.appendChild(item);&lt;br /&gt;
        });&lt;br /&gt;
        inspector.appendChild(relationList);&lt;br /&gt;
      } else {&lt;br /&gt;
        inspector.appendChild(makeElement(&#039;p&#039;, &#039;s3rm-inspector-muted&#039;, &#039;아직 다른 메모와 연결되지 않았습니다.&#039;));&lt;br /&gt;
      }&lt;br /&gt;
      var open = makeElement(&#039;a&#039;, &#039;s3rm-graph-primary-link&#039;, &#039;메모 열기&#039;);&lt;br /&gt;
      open.href = mw.util.getUrl(node.id);&lt;br /&gt;
      inspector.appendChild(open);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function canvasPoint(event) {&lt;br /&gt;
      var rect = canvas.getBoundingClientRect();&lt;br /&gt;
      return { x: event.clientX - rect.left, y: event.clientY - rect.top };&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function worldPoint(event) {&lt;br /&gt;
      var point = canvasPoint(event);&lt;br /&gt;
      return {&lt;br /&gt;
        x: (point.x - transform.x) / transform.scale,&lt;br /&gt;
        y: (point.y - transform.y) / transform.scale&lt;br /&gt;
      };&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function hitNode(event) {&lt;br /&gt;
      var point = worldPoint(event);&lt;br /&gt;
      for (var i = nodes.length - 1; i &amp;gt;= 0; i -= 1) {&lt;br /&gt;
        var node = nodes[i];&lt;br /&gt;
        var dx = point.x - node.x;&lt;br /&gt;
        var dy = point.y - node.y;&lt;br /&gt;
        if (dx * dx + dy * dy &amp;lt;= Math.pow(nodeRadius(node) + 6 / transform.scale, 2)) {&lt;br /&gt;
          return node;&lt;br /&gt;
        }&lt;br /&gt;
      }&lt;br /&gt;
      return null;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function selectNode(node) {&lt;br /&gt;
      selected = node;&lt;br /&gt;
      showInspector(node);&lt;br /&gt;
      draw();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    canvas.addEventListener(&#039;pointerdown&#039;, function (event) {&lt;br /&gt;
      dragging = hitNode(event);&lt;br /&gt;
      panning = !dragging;&lt;br /&gt;
      pointerStart = canvasPoint(event);&lt;br /&gt;
      canvas.setPointerCapture(event.pointerId);&lt;br /&gt;
      canvas.classList.add(&#039;is-dragging&#039;);&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;pointermove&#039;, function (event) {&lt;br /&gt;
      if (dragging) {&lt;br /&gt;
        var world = worldPoint(event);&lt;br /&gt;
        dragging.x = world.x;&lt;br /&gt;
        dragging.y = world.y;&lt;br /&gt;
        dragging.vx = 0;&lt;br /&gt;
        dragging.vy = 0;&lt;br /&gt;
      } else if (panning &amp;amp;&amp;amp; pointerStart) {&lt;br /&gt;
        var point = canvasPoint(event);&lt;br /&gt;
        transform.x += point.x - pointerStart.x;&lt;br /&gt;
        transform.y += point.y - pointerStart.y;&lt;br /&gt;
        pointerStart = point;&lt;br /&gt;
      } else {&lt;br /&gt;
        hovered = hitNode(event);&lt;br /&gt;
        canvas.style.cursor = hovered ? &#039;pointer&#039; : &#039;grab&#039;;&lt;br /&gt;
      }&lt;br /&gt;
      draw();&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;pointerup&#039;, function (event) {&lt;br /&gt;
      var hit = hitNode(event);&lt;br /&gt;
      if (dragging &amp;amp;&amp;amp; hit === dragging) {&lt;br /&gt;
        selectNode(dragging);&lt;br /&gt;
      }&lt;br /&gt;
      dragging = null;&lt;br /&gt;
      panning = false;&lt;br /&gt;
      pointerStart = null;&lt;br /&gt;
      canvas.classList.remove(&#039;is-dragging&#039;);&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;dblclick&#039;, function (event) {&lt;br /&gt;
      var node = hitNode(event);&lt;br /&gt;
      if (node) {&lt;br /&gt;
        window.location.href = mw.util.getUrl(node.id);&lt;br /&gt;
      }&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;wheel&#039;, function (event) {&lt;br /&gt;
      event.preventDefault();&lt;br /&gt;
      var point = canvasPoint(event);&lt;br /&gt;
      var oldScale = transform.scale;&lt;br /&gt;
      var nextScale = Math.max(0.25, Math.min(3.5, oldScale * Math.exp(-event.deltaY * 0.0012)));&lt;br /&gt;
      transform.x = point.x - ((point.x - transform.x) / oldScale) * nextScale;&lt;br /&gt;
      transform.y = point.y - ((point.y - transform.y) / oldScale) * nextScale;&lt;br /&gt;
      transform.scale = nextScale;&lt;br /&gt;
      draw();&lt;br /&gt;
    }, { passive: false });&lt;br /&gt;
&lt;br /&gt;
    search.addEventListener(&#039;input&#039;, function () {&lt;br /&gt;
      var query = search.value.trim().toLocaleLowerCase(&#039;ko&#039;);&lt;br /&gt;
      searchResults.replaceChildren();&lt;br /&gt;
      if (!query) {&lt;br /&gt;
        searchResults.hidden = true;&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      var matches = nodes.filter(function (node) {&lt;br /&gt;
        return node.title.toLocaleLowerCase(&#039;ko&#039;).includes(query) || node.id.toLowerCase().includes(query);&lt;br /&gt;
      }).slice(0, 6);&lt;br /&gt;
      matches.forEach(function (node) {&lt;br /&gt;
        var button = makeElement(&#039;button&#039;, &#039;&#039;, node.title);&lt;br /&gt;
        button.type = &#039;button&#039;;&lt;br /&gt;
        button.addEventListener(&#039;click&#039;, function () {&lt;br /&gt;
          selectNode(node);&lt;br /&gt;
          transform.x = size.width / 2 - node.x * transform.scale;&lt;br /&gt;
          transform.y = size.height / 2 - node.y * transform.scale;&lt;br /&gt;
          search.value = node.title;&lt;br /&gt;
          searchResults.hidden = true;&lt;br /&gt;
          draw();&lt;br /&gt;
        });&lt;br /&gt;
        searchResults.appendChild(button);&lt;br /&gt;
      });&lt;br /&gt;
      if (!matches.length) {&lt;br /&gt;
        searchResults.appendChild(makeElement(&#039;span&#039;, &#039;&#039;, &#039;일치하는 메모가 없습니다.&#039;));&lt;br /&gt;
      }&lt;br /&gt;
      searchResults.hidden = false;&lt;br /&gt;
    });&lt;br /&gt;
    search.addEventListener(&#039;keydown&#039;, function (event) {&lt;br /&gt;
      if (event.key === &#039;Escape&#039;) {&lt;br /&gt;
        searchResults.hidden = true;&lt;br /&gt;
      } else if (event.key === &#039;Enter&#039;) {&lt;br /&gt;
        var first = searchResults.querySelector(&#039;button&#039;);&lt;br /&gt;
        if (first) { first.click(); }&lt;br /&gt;
      }&lt;br /&gt;
    });&lt;br /&gt;
    filter.addEventListener(&#039;change&#039;, function () {&lt;br /&gt;
      activeRelation = filter.value;&lt;br /&gt;
      updateCounts();&lt;br /&gt;
      draw();&lt;br /&gt;
    });&lt;br /&gt;
    reset.addEventListener(&#039;click&#039;, fitGraph);&lt;br /&gt;
    clearSearch.addEventListener(&#039;click&#039;, function () {&lt;br /&gt;
      resetSearchCluster();&lt;br /&gt;
      fitGraph();&lt;br /&gt;
    });&lt;br /&gt;
&lt;br /&gt;
    showInspector(null);&lt;br /&gt;
    resize();&lt;br /&gt;
    var resizeObserver = window.ResizeObserver ? new ResizeObserver(resize) : null;&lt;br /&gt;
    if (resizeObserver) {&lt;br /&gt;
      resizeObserver.observe(viewport);&lt;br /&gt;
    } else {&lt;br /&gt;
      window.addEventListener(&#039;resize&#039;, resize);&lt;br /&gt;
    }&lt;br /&gt;
    graphSearchController = focusSearchResults;&lt;br /&gt;
    if (pendingGraphSearch) {&lt;br /&gt;
      focusSearchResults(pendingGraphSearch.items, pendingGraphSearch.query);&lt;br /&gt;
      pendingGraphSearch = null;&lt;br /&gt;
    }&lt;br /&gt;
    animationFrame = window.requestAnimationFrame(simulate);&lt;br /&gt;
    window.addEventListener(&#039;pagehide&#039;, function () {&lt;br /&gt;
      window.cancelAnimationFrame(animationFrame);&lt;br /&gt;
      if (resizeObserver) { resizeObserver.disconnect(); }&lt;br /&gt;
    }, { once: true });&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  var askQuery = &#039;[[Category:Lessons]]&#039; +&lt;br /&gt;
    &#039;|?Lesson title|?Confidence|?Updated at&#039; +&lt;br /&gt;
    RELATIONS.map(function (relation) { return &#039;|?&#039; + relation.property; }).join(&#039;&#039;) +&lt;br /&gt;
    &#039;|sort=Updated at|order=descending|limit=&#039; + NODE_LIMIT;&lt;br /&gt;
&lt;br /&gt;
  createSemanticSearch();&lt;br /&gt;
&lt;br /&gt;
  mw.loader.using([&#039;mediawiki.api&#039;, &#039;mediawiki.util&#039;]).then(function () {&lt;br /&gt;
    return new mw.Api().get({ action: &#039;ask&#039;, query: askQuery, formatversion: 2 });&lt;br /&gt;
  }).then(function (data) {&lt;br /&gt;
    var graph = parseGraph(data);&lt;br /&gt;
    if (!graph.nodes.length) {&lt;br /&gt;
      renderEmpty();&lt;br /&gt;
      return;&lt;br /&gt;
    }&lt;br /&gt;
    createGraph(graph);&lt;br /&gt;
  }).catch(renderError);&lt;br /&gt;
}());&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC/%EB%A9%94%EB%AA%A8_%EC%82%AC%EC%9A%A9%EB%B2%95&amp;diff=2727</id>
		<title>도움말:S3 연구 메모리/메모 사용법</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC/%EB%A9%94%EB%AA%A8_%EC%82%AC%EC%9A%A9%EB%B2%95&amp;diff=2727"/>
		<updated>2026-07-19T02:12:35Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 저장소 문서 동기화&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;!-- S3RM managed repository document; edit the Markdown source, not this page. --&amp;gt;&lt;br /&gt;
&#039;&#039;이 페이지는 저장소 문서에서 자동으로 동기화됩니다. 위키에서 직접 편집하지 마세요.&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
S3 연구 메모리는 프로젝트 일지나 실험 원본을 모으는 곳이 아닙니다. 다른 작업에서도 다시 참고할 만한 시행착오를 짧은 메모로 남깁니다.&lt;br /&gt;
&lt;br /&gt;
=== 먼저 검색하기 ===&lt;br /&gt;
&lt;br /&gt;
브라우저 메인 화면의 검색창에는 궁금한 점을 문장으로 입력할 수 있습니다. 표현이 달라도 관련된 메모를 함께 찾습니다. 검색 결과는 그 아래 지식 그래프 중앙에 한 군집으로 모이고, 결과가 아닌 메모는 흐리게 표시됩니다. 점을 누르면 연결 수와 관계 유형을 볼 수 있고, 두 번 누르면 메모가 열립니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;검색 결과 해제&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 누르면 전체 그래프로 돌아갑니다. 전체 그래프는 연결된 메모를 중앙에 모으고 연결이 없는 메모를 바깥쪽에 조밀하게 배치합니다. 연결된 메모의 제목은 처음부터 표시됩니다. 제목 필터, 관계 필터, 확대와 이동도 사용할 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
본문까지 찾으려면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Special:Search&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 에이전트에서는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;search_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 사용합니다. 프로젝트 이름보다 현상과 원인을 나타내는 말로 찾는 편이 잘 맞습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
메모리 단편화, retry storm, lock contention, cold start&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
같은 내용이 이미 있으면 새 메모를 만들지 않습니다. 기존 메모에 자료를 더하거나, 내용이 다른 이유를 관계로 연결하면 됩니다.&lt;br /&gt;
&lt;br /&gt;
=== 브라우저에서 메모 쓰기 ===&lt;br /&gt;
&lt;br /&gt;
로그인한 뒤 다음 화면을 엽니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
/index.php/연구_메모리#create-a-lesson&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
문서 ID에는 영문 소문자, 숫자, 밑줄, 하이픈만 씁니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Lesson:&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 입력하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
gpu_allocator_fragmentation&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
문서 ID는 에이전트가 메모를 인용할 때도 쓰므로 날짜, 사람 이름, 프로젝트에서만 통하는 줄임말은 피하세요. 화면에 보이는 제목은 나중에 바꿀 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
입력란에는 다음 내용을 적습니다.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 화면 !! 필드 !! 적을 내용&lt;br /&gt;
|-&lt;br /&gt;
| 제목 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;title&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 메모의 결론이 드러나는 짧은 제목&lt;br /&gt;
|-&lt;br /&gt;
| 궁금했던 점 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;question&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 시도를 시작한 이유&lt;br /&gt;
|-&lt;br /&gt;
| 해본 것 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;attempt&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 실제로 바꿨거나 비교한 것&lt;br /&gt;
|-&lt;br /&gt;
| 당시 조건 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;context&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 장비, 버전, 설정, 워크로드 등 결과에 영향을 준 조건&lt;br /&gt;
|-&lt;br /&gt;
| 실제 결과 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;observation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 직접 확인한 수치나 동작&lt;br /&gt;
|-&lt;br /&gt;
| 왜 그랬는지 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;interpretation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 가능한 원인과 아직 확실하지 않은 부분&lt;br /&gt;
|-&lt;br /&gt;
| 다음에 기억할 것 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;reusable_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 다른 작업에서도 써먹을 한 문장&lt;br /&gt;
|-&lt;br /&gt;
| 언제 맞는지 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;applicability&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 맞는 조건과 예외&lt;br /&gt;
|-&lt;br /&gt;
| 신뢰도 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;confidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 자료의 질에 따라 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;low&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;medium&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;high&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 중 선택&lt;br /&gt;
|-&lt;br /&gt;
| 관련 자료 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;evidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아래에 연결할 자료를 짧게 설명&lt;br /&gt;
|-&lt;br /&gt;
| 자료 출처 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;record_origin&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 직접 얻은 내용은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;lab&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 외부 자료를 정리했으면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;imported&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
관찰과 해석은 나눠 쓰세요. 예를 들어 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;동시 compaction 16개에서 p99가 18 ms에서 71 ms로 늘었다&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 관찰입니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;공유 writeback 제한 때문에 queue가 묶였다&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 해석이며, 새 자료가 나오면 달라질 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
저장한 메모는 바로 검색되고 메인 화면의 지식 그래프와 최근 메모에도 나타납니다.&lt;br /&gt;
&lt;br /&gt;
=== 자료 연결하기 ===&lt;br /&gt;
&lt;br /&gt;
메모마다 자료를 하나 이상 연결합니다. 원본 전체를 위키에 복사하지 말고 나중에 다시 찾을 수 있는 위치를 적으세요.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 입력란 !! 내용&lt;br /&gt;
|-&lt;br /&gt;
| 자료 ID || 메모 안에서 겹치지 않는 짧은 ID&lt;br /&gt;
|-&lt;br /&gt;
| 출처 || 저장소, 커밋, 실행 ID, 문서 절, 시간 구간 등&lt;br /&gt;
|-&lt;br /&gt;
| URL || 바로 열 수 있는 주소가 있을 때만 입력&lt;br /&gt;
|-&lt;br /&gt;
| 자료 종류 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;paper&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;code&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;dataset&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;log&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;benchmark&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;other&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 중 선택&lt;br /&gt;
|-&lt;br /&gt;
| 확인 범위 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;full_text&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;official_abstract&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;partial_source&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;metadata_only&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 중 선택&lt;br /&gt;
|-&lt;br /&gt;
| 메모 || 이 자료에서 확인할 부분과 한계&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
git yonsei-s3/kernel-probes@4f19a2e, results/lockstat/run-031/summary.json&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
확인 범위는 자료에서 실제로 읽은 범위이며 Lesson의 종합 신뢰도인 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;confidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 다릅니다. 서지정보만 확인했다면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;metadata_only&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 표시하고 기술 내용을 추측하지 않습니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;not_recorded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 이전 근거에 값이 없을 때 표시되는 호환값이므로 새 근거에는 선택하지 않습니다. 브라우저 양식에도 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;not_recorded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 기본값이 없고 새 근거에 선택할 수 없습니다. MCP로 새 근거를 붙일 때는 나머지 네 값 중 하나를 명시해야 합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;latest&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;대시보드&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 작성자만 아는 로컬 경로처럼 시간이 지나면 찾기 어려운 표현은 피합니다. 비밀번호, 토큰, 개인 정보, 공개할 수 없는 대화, 대용량 원시 데이터도 본문에 넣지 않습니다.&lt;br /&gt;
&lt;br /&gt;
=== 근거를 검증한 기록 남기기 ===&lt;br /&gt;
&lt;br /&gt;
자료를 실제로 읽어 Lesson 주장을 확인했다면 기존 evidence를 고치지 말고 새 검증 기록을 붙입니다. 같은 evidence를 나중에 다시 확인했다면 이전 기록을 수정·삭제하지 않고 새 기록을 append-only로 붙입니다. evidence별 마지막 기록이 현재 판정입니다.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 입력란 !! 내용&lt;br /&gt;
|-&lt;br /&gt;
| evidence ID || 검사한 기존 근거 ID&lt;br /&gt;
|-&lt;br /&gt;
| evidence digest || 읽기 시점의 evidence 내용 digest&lt;br /&gt;
|-&lt;br /&gt;
| 확인 범위 || 이번에 실제로 검사한 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;verification_basis&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| 자료 식별자 || 파일, 커밋, 데이터 버전, 문서 버전 등 고정 식별자&lt;br /&gt;
|-&lt;br /&gt;
| 자료 SHA-256 || 실제로 검사한 정확한 바이트의 SHA-256&lt;br /&gt;
|-&lt;br /&gt;
| 위치 || 페이지, 절, 라인, 실험 구간 등 주장을 확인한 위치&lt;br /&gt;
|-&lt;br /&gt;
| 확인 범위와 한계 || 읽은 범위와 제외한 부분&lt;br /&gt;
|-&lt;br /&gt;
| 판정 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supports&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;contradicts&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;inconclusive&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 중 하나&lt;br /&gt;
|-&lt;br /&gt;
| 연결 주장 || 자료가 실제로 확인한 Lesson 필드&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
원본 파일을 검사했다면 그 파일 바이트를 해시합니다. 원본 대신 저장·정규화한 텍스트를 검사했다면 그 정확한 텍스트의 UTF-8 바이트를 해시합니다. 이 경우 변환 방법과 보존한 산출물을 자료 식별자와 확인 범위에 명시합니다. MediaWiki revision의 저장 계정과 시각이 권위 출처입니다. 검증 기록의 출처는 현재 저장 계정과 정확히 같아야 하고, 기록 시각은 revision 저장 시각 전후 5분 안이며 이전 기록보다 늦어야 하는 보조 필드입니다. 기존 출처와 시각은 수정할 수 없습니다.&lt;br /&gt;
&lt;br /&gt;
high confidence는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;full_text&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 또는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;official_abstract&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 기반으로 한 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supports&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 검증이 observation, interpretation, reusable_lesson 세 필드를 모두 뒷받침할 때만 사용합니다. observation, interpretation, reusable_lesson을 수정한 후 결과 confidence가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;medium&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 또는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;high&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이면 실제로 바꾸는 주장과 연결한 같은 강도의 검증이 필요합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;contradicts&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;나 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;inconclusive&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 지지 근거로 세지 않습니다. 결과 confidence가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;low&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;인 교정은 기존 confidence를 낮추는 경우를 포함해 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;not_recorded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 아닌 basis evidence로 할 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
=== 다른 메모와 연결하기 ===&lt;br /&gt;
&lt;br /&gt;
관계는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;출발 메모 관계값 대상 메모&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 순서로 읽습니다.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 관계 !! 뜻&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supports&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 대상 메모와 맞는 자료나 원인을 보탬&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;contradicts&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 대상 메모의 주요 주장과 관찰이 충돌함&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;analogous_to&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 분야는 달라도 같은 원인이나 실패 형태를 보임&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;failed_because&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 대상 메모에 적힌 이유로 시도가 실패함&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;works_when&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 대상 메모의 조건에서 방법이 잘 작동함&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;suggests&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 다음 가설이나 시도를 제안함&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supersedes&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 새 메모가 이전 안내를 대신함. 상태는 자동으로 바뀌지 않음&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
연결 이유에는 두 메모가 닮거나 충돌하는 지점을 한두 문장으로 씁니다. 관련성이 애매하면 억지로 연결하지 않아도 됩니다.&lt;br /&gt;
&lt;br /&gt;
=== 메모 고치기 ===&lt;br /&gt;
&lt;br /&gt;
메모 화면에서 &#039;&#039;&#039;메모 편집&#039;&#039;&#039;을 누릅니다. 잘못 적은 수치나 해석을 고치고, 수정 요약에 이유를 짧게 남기세요. 이전 내용은 MediaWiki 이력에서 확인할 수 있습니다. 에이전트가 고칠 때는 현재 revision과 근거를 다시 읽은 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;revise_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 사용합니다. 같은 논문의 보강 내용을 evidence note나 새 Technical Review 메모에 숨기지 않습니다. 새 근거를 먼저 붙였다면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;attach_evidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 응답의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;current_revision_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 다음 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;revise_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;expected_revision_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 사용합니다. 그 사이 다른 변경이 있었다면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;get_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 다시 읽고 변경안을 재계산합니다.&lt;br /&gt;
&lt;br /&gt;
=== MCP로 찾기 ===&lt;br /&gt;
&lt;br /&gt;
처음에는 결과를 3~5개만 받고 필요한 필드만 요청하는 편이 빠릅니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
S3 Research Memory에서 tail latency와 관련된 메모를 최대 5개 찾아 줘.&lt;br /&gt;
id, title, reusable_lesson, applicability, confidence, citations만 반환해.&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
후보를 고른 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;get_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 자세히 읽고, 관계는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;find_related_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 다른 분야의 비슷한 사례는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;find_analogies&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 찾습니다. 근거 note와 추가 정보를 확인하려면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;citation_detail=full&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 지정합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;get_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 revision ID는 이어지는 조건부 수정과 감사에 사용합니다. 답변에 메모를 사용했다면 반환된 자료 인용도 같이 보관하세요.&lt;br /&gt;
&lt;br /&gt;
서버에서 의미 검색을 켠 경우에는 표현이 다른 문제도 찾도록 다음처럼 요청할 수 있습니다. 기본 검색은 계속 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;legacy&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이므로 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;hybrid_v1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 직접 지정합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
search_lessons를 retrieval_mode=hybrid_v1로 실행해,&lt;br /&gt;
문제의 제약과 해결 원리가 비슷한 메모를 최대 5개 찾아 줘.&lt;br /&gt;
effective_mode와 semantic_status도 알려 줘.&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
의미 유사도는 저장된 관계나 사실이 아닙니다. 후보의 적용 조건과 근거를 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;get_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 확인한 뒤 실제 관계를 남길지 판단하세요.&lt;br /&gt;
&lt;br /&gt;
=== 에이전트에게 작성을 맡길 때 ===&lt;br /&gt;
&lt;br /&gt;
쓰기 토큰이 있는 에이전트는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;create_lesson_draft&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;link_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;attach_evidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;record_evidence_verification&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;revise_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 쓸 수 있습니다. 에이전트가 만든 메모와 revision은 저장 즉시 검색됩니다. 상태 변경, 삭제, 이동, 관리자 작업은 MCP에서 할 수 없습니다. 다섯 쓰기 도구는 모두 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;operation_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 필요합니다. 같은 Lesson 안에서 일반 쓰기, revision, 검증 기록은 ID 충돌 범위를 공유합니다. 같은 종류·요청의 정확한 재시도일 때만 같은 ID와 같은 요청을 보내고, 다른 작업에는 새 ID를 사용합니다. Lesson 생성에는 구조화된 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;initial_evidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 필수이며 Lesson 본문과 같은 revision에 저장됩니다.&lt;br /&gt;
&lt;br /&gt;
자동화 작업·워크플로·CI·도구 호출의 성공·실패 상태, 재시도, 인증·연결 오류와 실행 로그 자체는 Lesson으로 저장하지 않습니다. 실패 자체가 연구 대상이고 단일 실행을 넘어 재사용할 원인과 적용 조건을 확인 가능한 근거로 일반화한 독립 주장일 때만 저장합니다.&lt;br /&gt;
&lt;br /&gt;
자료 위치와 확인한 사실을 먼저 제공하고 다음을 요청하세요.&lt;br /&gt;
&lt;br /&gt;
* 같은 메모가 있는지 먼저 검색&lt;br /&gt;
* 관찰과 해석을 분리&lt;br /&gt;
* 맞는 조건과 예외를 명시&lt;br /&gt;
* 자료를 추측하지 않기&lt;br /&gt;
* 검증 기록이 있다면 자료 digest·위치·범위·판정·연결 주장을 함께 확인&lt;br /&gt;
* 작업이 끝나면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Lesson:&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; ID 반환&lt;br /&gt;
* 여러 건을 바꿨다면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;audit_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 revision·내용·근거·관계를 확인&lt;br /&gt;
반환된 ID를 브라우저에서 열어 내용과 자료 위치를 확인합니다. 틀린 부분은 같은 ID의 새 revision으로 고칩니다. 출처만 추가할 때는 evidence, 독립된 새 주장일 때만 새 Lesson과 관계를 사용합니다. 감사는 저장된 구조, digest, 검증 수령 기록의 일관성을 확인하며 외부 자료의 사실 진위를 자동으로 판정하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
도구 입력과 응답 형식은 [[Help:S3 연구 메모리/MCP 클라이언트|MCP 클라이언트 안내]], 필드와 관계의 기계 계약은 [[Help:S3 연구 메모리/아키텍처|아키텍처]]에 있습니다.&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%EB%AF%B8%EB%94%94%EC%96%B4%EC%9C%84%ED%82%A4:Common.js&amp;diff=2726</id>
		<title>미디어위키:Common.js</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%EB%AF%B8%EB%94%94%EC%96%B4%EC%9C%84%ED%82%A4:Common.js&amp;diff=2726"/>
		<updated>2026-07-19T02:12:35Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 원본 스키마 동기화&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;(function () {&lt;br /&gt;
  &#039;use strict&#039;;&lt;br /&gt;
&lt;br /&gt;
  var root = document.getElementById(&#039;s3rm-knowledge-graph&#039;);&lt;br /&gt;
  var semanticRoot = document.getElementById(&#039;s3rm-semantic-search&#039;);&lt;br /&gt;
  if (!root || !window.mw) {&lt;br /&gt;
    return;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  var RELATIONS = [&lt;br /&gt;
    { property: &#039;Supports&#039;, key: &#039;supports&#039;, label: &#039;뒷받침함&#039;, color: &#039;#5f7ae6&#039; },&lt;br /&gt;
    { property: &#039;Contradicts&#039;, key: &#039;contradicts&#039;, label: &#039;상충함&#039;, color: &#039;#d0605b&#039; },&lt;br /&gt;
    { property: &#039;Analogous to&#039;, key: &#039;analogous_to&#039;, label: &#039;유사함&#039;, color: &#039;#a17bd7&#039; },&lt;br /&gt;
    { property: &#039;Failed because&#039;, key: &#039;failed_because&#039;, label: &#039;실패 원인&#039;, color: &#039;#dc8b45&#039; },&lt;br /&gt;
    { property: &#039;Works when&#039;, key: &#039;works_when&#039;, label: &#039;작동 조건&#039;, color: &#039;#43a27b&#039; },&lt;br /&gt;
    { property: &#039;Suggests&#039;, key: &#039;suggests&#039;, label: &#039;후속 제안&#039;, color: &#039;#3f9db7&#039; },&lt;br /&gt;
    { property: &#039;Supersedes&#039;, key: &#039;supersedes&#039;, label: &#039;대체함&#039;, color: &#039;#7d849e&#039; }&lt;br /&gt;
  ];&lt;br /&gt;
  var NODE_LIMIT = 160;&lt;br /&gt;
  var EDGE_LIMIT = 480;&lt;br /&gt;
  var mcpReady = null;&lt;br /&gt;
  var graphSearchController = null;&lt;br /&gt;
  var pendingGraphSearch = null;&lt;br /&gt;
&lt;br /&gt;
  function mcpRequest(payload, expectBody) {&lt;br /&gt;
    return window.fetch(&#039;/mcp&#039;, {&lt;br /&gt;
      method: &#039;POST&#039;,&lt;br /&gt;
      credentials: &#039;same-origin&#039;,&lt;br /&gt;
      headers: {&lt;br /&gt;
        &#039;Content-Type&#039;: &#039;application/json&#039;,&lt;br /&gt;
        &#039;Accept&#039;: &#039;application/json, text/event-stream&#039;&lt;br /&gt;
      },&lt;br /&gt;
      body: JSON.stringify(payload)&lt;br /&gt;
    }).then(function (response) {&lt;br /&gt;
      if (!response.ok) {&lt;br /&gt;
        throw new Error(&#039;MCP HTTP &#039; + response.status);&lt;br /&gt;
      }&lt;br /&gt;
      if (!expectBody) {&lt;br /&gt;
        return null;&lt;br /&gt;
      }&lt;br /&gt;
      return response.json();&lt;br /&gt;
    });&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function initializeMcp() {&lt;br /&gt;
    if (!mcpReady) {&lt;br /&gt;
      mcpReady = mcpRequest({&lt;br /&gt;
        jsonrpc: &#039;2.0&#039;,&lt;br /&gt;
        id: 1,&lt;br /&gt;
        method: &#039;initialize&#039;,&lt;br /&gt;
        params: {&lt;br /&gt;
          protocolVersion: &#039;2025-06-18&#039;,&lt;br /&gt;
          capabilities: {},&lt;br /&gt;
          clientInfo: { name: &#039;s3rm-wiki-search&#039;, version: &#039;1&#039; }&lt;br /&gt;
        }&lt;br /&gt;
      }, true).then(function (response) {&lt;br /&gt;
        if (!response || response.error) {&lt;br /&gt;
          throw new Error(&#039;MCP initialization failed&#039;);&lt;br /&gt;
        }&lt;br /&gt;
        return mcpRequest({&lt;br /&gt;
          jsonrpc: &#039;2.0&#039;,&lt;br /&gt;
          method: &#039;notifications/initialized&#039;,&lt;br /&gt;
          params: {}&lt;br /&gt;
        }, false);&lt;br /&gt;
      });&lt;br /&gt;
    }&lt;br /&gt;
    return mcpReady;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function searchSemantically(query) {&lt;br /&gt;
    return initializeMcp().then(function () {&lt;br /&gt;
      return mcpRequest({&lt;br /&gt;
        jsonrpc: &#039;2.0&#039;,&lt;br /&gt;
        id: 2,&lt;br /&gt;
        method: &#039;tools/call&#039;,&lt;br /&gt;
        params: {&lt;br /&gt;
          name: &#039;search_lessons&#039;,&lt;br /&gt;
          arguments: {&lt;br /&gt;
            query: query,&lt;br /&gt;
            retrieval_mode: &#039;hybrid_v1&#039;,&lt;br /&gt;
            fields: [&#039;title&#039;, &#039;question&#039;, &#039;reusable_lesson&#039;, &#039;confidence&#039;, &#039;relations&#039;],&lt;br /&gt;
            limit: 6&lt;br /&gt;
          }&lt;br /&gt;
        }&lt;br /&gt;
      }, true);&lt;br /&gt;
    }).then(function (response) {&lt;br /&gt;
      if (!response || response.error || !response.result || response.result.isError) {&lt;br /&gt;
        throw new Error(&#039;Semantic search failed&#039;);&lt;br /&gt;
      }&lt;br /&gt;
      return response.result.structuredContent || {};&lt;br /&gt;
    });&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function textValue(value) {&lt;br /&gt;
    if (value === null || value === undefined) {&lt;br /&gt;
      return &#039;&#039;;&lt;br /&gt;
    }&lt;br /&gt;
    if (typeof value === &#039;string&#039; || typeof value === &#039;number&#039;) {&lt;br /&gt;
      return String(value);&lt;br /&gt;
    }&lt;br /&gt;
    return String(value.fulltext || value.displaytitle || value.raw || value.timestamp || &#039;&#039;);&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function firstValue(values, fallback) {&lt;br /&gt;
    return values &amp;amp;&amp;amp; values.length ? textValue(values[0]) : fallback;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function confidenceLabel(confidence) {&lt;br /&gt;
    if (confidence === &#039;high&#039;) { return &#039;신뢰도: 높음&#039;; }&lt;br /&gt;
    if (confidence === &#039;medium&#039;) { return &#039;신뢰도: 중간&#039;; }&lt;br /&gt;
    return &#039;신뢰도: 낮음&#039;;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function parseGraph(data) {&lt;br /&gt;
    var rawResults = data &amp;amp;&amp;amp; data.query &amp;amp;&amp;amp; data.query.results ? data.query.results : {};&lt;br /&gt;
    var entries = Array.isArray(rawResults)&lt;br /&gt;
      ? rawResults.map(function (item) { return [item.fulltext, item]; })&lt;br /&gt;
      : Object.keys(rawResults).map(function (key) { return [key, rawResults[key]]; });&lt;br /&gt;
    var nodes = [];&lt;br /&gt;
    var byId = Object.create(null);&lt;br /&gt;
&lt;br /&gt;
    entries.slice(0, NODE_LIMIT).forEach(function (entry, index) {&lt;br /&gt;
      var id = entry[0];&lt;br /&gt;
      var result = entry[1] || {};&lt;br /&gt;
      var printouts = result.printouts || {};&lt;br /&gt;
      var angle = index * 2.399963229728653;&lt;br /&gt;
      var radius = 20 * Math.sqrt(index + 1);&lt;br /&gt;
      var node = {&lt;br /&gt;
        id: id,&lt;br /&gt;
        title: firstValue(printouts[&#039;Lesson title&#039;], id.replace(/^Lesson:/, &#039;&#039;)),&lt;br /&gt;
        confidence: firstValue(printouts.Confidence, &#039;low&#039;).toLowerCase(),&lt;br /&gt;
        updatedAt: firstValue(printouts[&#039;Updated at&#039;], &#039;&#039;),&lt;br /&gt;
        x: Math.cos(angle) * radius,&lt;br /&gt;
        y: Math.sin(angle) * radius,&lt;br /&gt;
        vx: 0,&lt;br /&gt;
        vy: 0,&lt;br /&gt;
        degree: 0,&lt;br /&gt;
        printouts: printouts&lt;br /&gt;
      };&lt;br /&gt;
      nodes.push(node);&lt;br /&gt;
      byId[id] = node;&lt;br /&gt;
    });&lt;br /&gt;
&lt;br /&gt;
    var edges = [];&lt;br /&gt;
    nodes.forEach(function (node) {&lt;br /&gt;
      RELATIONS.forEach(function (relation) {&lt;br /&gt;
        var targets = node.printouts[relation.property] || [];&lt;br /&gt;
        targets.forEach(function (target) {&lt;br /&gt;
          var targetId = textValue(target);&lt;br /&gt;
          if (edges.length &amp;lt; EDGE_LIMIT &amp;amp;&amp;amp; byId[targetId] &amp;amp;&amp;amp; targetId !== node.id) {&lt;br /&gt;
            edges.push({ source: node, target: byId[targetId], relation: relation });&lt;br /&gt;
            node.degree += 1;&lt;br /&gt;
            byId[targetId].degree += 1;&lt;br /&gt;
          }&lt;br /&gt;
        });&lt;br /&gt;
      });&lt;br /&gt;
      delete node.printouts;&lt;br /&gt;
    });&lt;br /&gt;
&lt;br /&gt;
    var connectedNodes = nodes.filter(function (node) { return node.degree &amp;gt; 0; });&lt;br /&gt;
    var isolatedNodes = nodes.filter(function (node) { return node.degree === 0; });&lt;br /&gt;
    connectedNodes.forEach(function (node, index) {&lt;br /&gt;
      var angle = index * 2.399963229728653;&lt;br /&gt;
      var radius = 16 * Math.sqrt(index + 1);&lt;br /&gt;
      node.x = Math.cos(angle) * radius;&lt;br /&gt;
      node.y = Math.sin(angle) * radius;&lt;br /&gt;
    });&lt;br /&gt;
    isolatedNodes.forEach(function (node, index) {&lt;br /&gt;
      var angle = index * 2.399963229728653 + 0.7;&lt;br /&gt;
      var radius = 92 + 10 * Math.sqrt(index + 1);&lt;br /&gt;
      node.x = Math.cos(angle) * radius;&lt;br /&gt;
      node.y = Math.sin(angle) * radius;&lt;br /&gt;
    });&lt;br /&gt;
    return { nodes: nodes, edges: edges };&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function makeElement(tag, className, text) {&lt;br /&gt;
    var element = document.createElement(tag);&lt;br /&gt;
    if (className) {&lt;br /&gt;
      element.className = className;&lt;br /&gt;
    }&lt;br /&gt;
    if (text !== undefined) {&lt;br /&gt;
      element.textContent = text;&lt;br /&gt;
    }&lt;br /&gt;
    return element;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function createSemanticSearch() {&lt;br /&gt;
    if (!semanticRoot) {&lt;br /&gt;
      return;&lt;br /&gt;
    }&lt;br /&gt;
    semanticRoot.replaceChildren();&lt;br /&gt;
    var intro = makeElement(&#039;div&#039;, &#039;s3rm-semantic-intro&#039;);&lt;br /&gt;
    intro.appendChild(makeElement(&#039;div&#039;, &#039;s3rm-semantic-icon&#039;, &#039;⌕&#039;));&lt;br /&gt;
    var introCopy = makeElement(&#039;div&#039;, &#039;&#039;);&lt;br /&gt;
    introCopy.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;검색&#039;));&lt;br /&gt;
    introCopy.appendChild(makeElement(&#039;p&#039;, &#039;&#039;, &#039;현상이나 궁금한 점을 문장으로 입력하세요. 표현이 달라도 관련 메모를 찾습니다.&#039;));&lt;br /&gt;
    intro.appendChild(introCopy);&lt;br /&gt;
&lt;br /&gt;
    var form = makeElement(&#039;form&#039;, &#039;s3rm-semantic-form&#039;);&lt;br /&gt;
    var input = makeElement(&#039;input&#039;, &#039;s3rm-semantic-input&#039;);&lt;br /&gt;
    input.type = &#039;search&#039;;&lt;br /&gt;
    input.name = &#039;query&#039;;&lt;br /&gt;
    input.maxLength = 500;&lt;br /&gt;
    input.placeholder = &#039;예: GPU 메모리는 남아 있는데 할당이 실패하는 이유는?&#039;;&lt;br /&gt;
    input.setAttribute(&#039;aria-label&#039;, &#039;메모 검색&#039;);&lt;br /&gt;
    var submit = makeElement(&#039;button&#039;, &#039;s3rm-semantic-submit&#039;, &#039;검색&#039;);&lt;br /&gt;
    submit.type = &#039;submit&#039;;&lt;br /&gt;
    form.append(input, submit);&lt;br /&gt;
&lt;br /&gt;
    var examples = makeElement(&#039;div&#039;, &#039;s3rm-semantic-examples&#039;);&lt;br /&gt;
    examples.appendChild(makeElement(&#039;span&#039;, &#039;&#039;, &#039;검색 예시&#039;));&lt;br /&gt;
    [&lt;br /&gt;
      &#039;재시도가 한꺼번에 몰리는 이유&#039;,&lt;br /&gt;
      &#039;GPU와 SSD 사이 병목을 줄이는 방법&#039;,&lt;br /&gt;
      &#039;긴 작업에서 지연이 갑자기 커지는 원인&#039;&lt;br /&gt;
    ].forEach(function (example) {&lt;br /&gt;
      var button = makeElement(&#039;button&#039;, &#039;&#039;, example);&lt;br /&gt;
      button.type = &#039;button&#039;;&lt;br /&gt;
      button.addEventListener(&#039;click&#039;, function () {&lt;br /&gt;
        input.value = example;&lt;br /&gt;
        form.requestSubmit();&lt;br /&gt;
      });&lt;br /&gt;
      examples.appendChild(button);&lt;br /&gt;
    });&lt;br /&gt;
&lt;br /&gt;
    var feedback = makeElement(&#039;div&#039;, &#039;s3rm-semantic-feedback&#039;);&lt;br /&gt;
    feedback.setAttribute(&#039;aria-live&#039;, &#039;polite&#039;);&lt;br /&gt;
    feedback.hidden = true;&lt;br /&gt;
    semanticRoot.append(intro, form, examples, feedback);&lt;br /&gt;
&lt;br /&gt;
    function renderSearchResults(payload, query) {&lt;br /&gt;
      var items = Array.isArray(payload.items) ? payload.items : [];&lt;br /&gt;
      if (graphSearchController) {&lt;br /&gt;
        graphSearchController(items, query);&lt;br /&gt;
      } else {&lt;br /&gt;
        pendingGraphSearch = { items: items, query: query };&lt;br /&gt;
      }&lt;br /&gt;
      feedback.replaceChildren();&lt;br /&gt;
      feedback.hidden = false;&lt;br /&gt;
      var resultHeader = makeElement(&#039;div&#039;, &#039;s3rm-semantic-result-header&#039;);&lt;br /&gt;
      resultHeader.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;“&#039; + query + &#039;” 검색 결과&#039;));&lt;br /&gt;
      resultHeader.appendChild(makeElement(&#039;span&#039;, &#039;s3rm-semantic-mode&#039;, items.length + &#039;개 메모&#039;));&lt;br /&gt;
      feedback.appendChild(resultHeader);&lt;br /&gt;
&lt;br /&gt;
      if (!items.length) {&lt;br /&gt;
        feedback.appendChild(makeElement(&#039;p&#039;, &#039;s3rm-semantic-empty&#039;, &#039;가까운 메모를 찾지 못했습니다. 표현을 조금 바꿔 다시 검색해 보세요.&#039;));&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      feedback.appendChild(makeElement(&#039;p&#039;, &#039;s3rm-semantic-graph-note&#039;, &#039;아래 그래프에 검색 결과를 모았습니다. 점을 선택하면 메모와 연결을 볼 수 있습니다.&#039;));&lt;br /&gt;
      var list = makeElement(&#039;div&#039;, &#039;s3rm-semantic-result-links&#039;);&lt;br /&gt;
      items.forEach(function (item) {&lt;br /&gt;
        var link = makeElement(&#039;a&#039;, &#039;&#039;, item.title || String(item.id).replace(/^Lesson:/, &#039;&#039;));&lt;br /&gt;
        link.href = mw.util.getUrl(item.id);&lt;br /&gt;
        list.appendChild(link);&lt;br /&gt;
      });&lt;br /&gt;
      feedback.appendChild(list);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    form.addEventListener(&#039;submit&#039;, function (event) {&lt;br /&gt;
      event.preventDefault();&lt;br /&gt;
      var query = input.value.trim();&lt;br /&gt;
      if (query.length &amp;lt; 2) {&lt;br /&gt;
        feedback.replaceChildren(makeElement(&#039;p&#039;, &#039;s3rm-semantic-error&#039;, &#039;두 글자 이상 입력해 주세요.&#039;));&lt;br /&gt;
        feedback.hidden = false;&lt;br /&gt;
        input.focus();&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      submit.disabled = true;&lt;br /&gt;
      submit.textContent = &#039;찾는 중…&#039;;&lt;br /&gt;
      feedback.replaceChildren(makeElement(&#039;div&#039;, &#039;s3rm-semantic-searching&#039;, &#039;관련 메모를 찾고 있습니다.&#039;));&lt;br /&gt;
      feedback.hidden = false;&lt;br /&gt;
      searchSemantically(query).then(function (payload) {&lt;br /&gt;
        renderSearchResults(payload, query);&lt;br /&gt;
      }).catch(function () {&lt;br /&gt;
        feedback.replaceChildren();&lt;br /&gt;
        var error = makeElement(&#039;div&#039;, &#039;s3rm-semantic-error&#039;);&lt;br /&gt;
        error.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;지금은 검색을 사용할 수 없습니다.&#039;));&lt;br /&gt;
        var fallback = makeElement(&#039;a&#039;, &#039;&#039;, &#039;검색 열기&#039;);&lt;br /&gt;
        fallback.href = mw.util.getUrl(&#039;Special:Search&#039;, { search: query });&lt;br /&gt;
        error.appendChild(fallback);&lt;br /&gt;
        feedback.appendChild(error);&lt;br /&gt;
      }).finally(function () {&lt;br /&gt;
        submit.disabled = false;&lt;br /&gt;
        submit.textContent = &#039;검색&#039;;&lt;br /&gt;
      });&lt;br /&gt;
    });&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function renderEmpty() {&lt;br /&gt;
    root.replaceChildren();&lt;br /&gt;
    var empty = makeElement(&#039;div&#039;, &#039;s3rm-graph-empty&#039;);&lt;br /&gt;
    empty.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;아직 연결할 메모가 없습니다&#039;));&lt;br /&gt;
    empty.appendChild(makeElement(&#039;p&#039;, &#039;&#039;, &#039;첫 메모를 남기면 이곳에서 지식의 연결을 볼 수 있습니다.&#039;));&lt;br /&gt;
    var link = makeElement(&#039;a&#039;, &#039;s3rm-graph-primary-link&#039;, &#039;첫 메모 작성하기&#039;);&lt;br /&gt;
    link.href = &#039;#quick-start&#039;;&lt;br /&gt;
    empty.appendChild(link);&lt;br /&gt;
    root.appendChild(empty);&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function renderError() {&lt;br /&gt;
    root.replaceChildren();&lt;br /&gt;
    var fallback = makeElement(&#039;div&#039;, &#039;s3rm-graph-empty&#039;);&lt;br /&gt;
    fallback.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;그래프를 불러오지 못했습니다&#039;));&lt;br /&gt;
    fallback.appendChild(makeElement(&#039;p&#039;, &#039;&#039;, &#039;최근 메모와 검색은 계속 사용할 수 있습니다. 잠시 뒤 다시 시도해 주세요.&#039;));&lt;br /&gt;
    var link = makeElement(&#039;a&#039;, &#039;s3rm-graph-primary-link&#039;, &#039;전체 메모 보기&#039;);&lt;br /&gt;
    link.href = mw.util.getUrl(&#039;Special:AllPages/Lesson:&#039;);&lt;br /&gt;
    fallback.appendChild(link);&lt;br /&gt;
    root.appendChild(fallback);&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function createGraph(graph) {&lt;br /&gt;
    var nodes = graph.nodes;&lt;br /&gt;
    var edges = graph.edges;&lt;br /&gt;
    var byId = Object.create(null);&lt;br /&gt;
    function canonicalId(id) {&lt;br /&gt;
      return String(id || &#039;&#039;).replace(/_/g, &#039; &#039;).toLocaleLowerCase(&#039;en&#039;);&lt;br /&gt;
    }&lt;br /&gt;
    nodes.forEach(function (node) { byId[canonicalId(node.id)] = node; });&lt;br /&gt;
    var selected = null;&lt;br /&gt;
    var hovered = null;&lt;br /&gt;
    var searchCluster = null;&lt;br /&gt;
    var activeRelation = &#039;all&#039;;&lt;br /&gt;
    var transform = { x: 0, y: 0, scale: 1 };&lt;br /&gt;
    var dragging = null;&lt;br /&gt;
    var panning = false;&lt;br /&gt;
    var pointerStart = null;&lt;br /&gt;
    var animationFrame = 0;&lt;br /&gt;
    var simulationTicks = 0;&lt;br /&gt;
&lt;br /&gt;
    root.replaceChildren();&lt;br /&gt;
    var toolbar = makeElement(&#039;div&#039;, &#039;s3rm-graph-toolbar&#039;);&lt;br /&gt;
    var searchWrap = makeElement(&#039;div&#039;, &#039;s3rm-graph-search&#039;);&lt;br /&gt;
    var search = makeElement(&#039;input&#039;, &#039;s3rm-graph-search-input&#039;);&lt;br /&gt;
    search.type = &#039;search&#039;;&lt;br /&gt;
    search.placeholder = &#039;그래프에서 제목 필터&#039;;&lt;br /&gt;
    search.setAttribute(&#039;aria-label&#039;, &#039;그래프 제목 필터&#039;);&lt;br /&gt;
    search.autocomplete = &#039;off&#039;;&lt;br /&gt;
    var searchResults = makeElement(&#039;div&#039;, &#039;s3rm-graph-search-results&#039;);&lt;br /&gt;
    searchResults.hidden = true;&lt;br /&gt;
    searchWrap.append(search, searchResults);&lt;br /&gt;
&lt;br /&gt;
    var filter = makeElement(&#039;select&#039;, &#039;s3rm-graph-filter&#039;);&lt;br /&gt;
    filter.setAttribute(&#039;aria-label&#039;, &#039;관계 유형 필터&#039;);&lt;br /&gt;
    var allOption = makeElement(&#039;option&#039;, &#039;&#039;, &#039;모든 관계&#039;);&lt;br /&gt;
    allOption.value = &#039;all&#039;;&lt;br /&gt;
    filter.appendChild(allOption);&lt;br /&gt;
    RELATIONS.forEach(function (relation) {&lt;br /&gt;
      var option = makeElement(&#039;option&#039;, &#039;&#039;, relation.label);&lt;br /&gt;
      option.value = relation.key;&lt;br /&gt;
      filter.appendChild(option);&lt;br /&gt;
    });&lt;br /&gt;
    var reset = makeElement(&#039;button&#039;, &#039;s3rm-graph-reset&#039;, &#039;화면 맞춤&#039;);&lt;br /&gt;
    reset.type = &#039;button&#039;;&lt;br /&gt;
    var clearSearch = makeElement(&#039;button&#039;, &#039;s3rm-graph-clear-search&#039;, &#039;검색 결과 해제&#039;);&lt;br /&gt;
    clearSearch.type = &#039;button&#039;;&lt;br /&gt;
    clearSearch.hidden = true;&lt;br /&gt;
    toolbar.append(searchWrap, filter, reset, clearSearch);&lt;br /&gt;
&lt;br /&gt;
    var body = makeElement(&#039;div&#039;, &#039;s3rm-graph-body&#039;);&lt;br /&gt;
    var viewport = makeElement(&#039;div&#039;, &#039;s3rm-graph-viewport&#039;);&lt;br /&gt;
    var canvas = document.createElement(&#039;canvas&#039;);&lt;br /&gt;
    canvas.className = &#039;s3rm-graph-canvas&#039;;&lt;br /&gt;
    canvas.setAttribute(&#039;aria-hidden&#039;, &#039;true&#039;);&lt;br /&gt;
    viewport.appendChild(canvas);&lt;br /&gt;
    var hint = makeElement(&#039;div&#039;, &#039;s3rm-graph-hint&#039;, &#039;드래그하여 이동 · 스크롤하여 확대 · 두 번 눌러 메모 열기&#039;);&lt;br /&gt;
    viewport.appendChild(hint);&lt;br /&gt;
&lt;br /&gt;
    var inspector = makeElement(&#039;aside&#039;, &#039;s3rm-graph-inspector&#039;);&lt;br /&gt;
    inspector.setAttribute(&#039;aria-live&#039;, &#039;polite&#039;);&lt;br /&gt;
    body.append(viewport, inspector);&lt;br /&gt;
&lt;br /&gt;
    var footer = makeElement(&#039;div&#039;, &#039;s3rm-graph-footer&#039;);&lt;br /&gt;
    var counts = makeElement(&#039;span&#039;, &#039;s3rm-graph-counts&#039;, nodes.length + &#039;개 메모 · &#039; + edges.length + &#039;개 연결&#039;);&lt;br /&gt;
    var legend = makeElement(&#039;div&#039;, &#039;s3rm-graph-legend&#039;);&lt;br /&gt;
    RELATIONS.slice(0, 4).forEach(function (relation) {&lt;br /&gt;
      var item = makeElement(&#039;span&#039;, &#039;s3rm-graph-legend-item&#039;);&lt;br /&gt;
      var swatch = makeElement(&#039;i&#039;, &#039;&#039;);&lt;br /&gt;
      swatch.style.backgroundColor = relation.color;&lt;br /&gt;
      item.append(swatch, document.createTextNode(relation.label));&lt;br /&gt;
      legend.appendChild(item);&lt;br /&gt;
    });&lt;br /&gt;
    footer.append(counts, legend);&lt;br /&gt;
    root.append(toolbar, body, footer);&lt;br /&gt;
&lt;br /&gt;
    var context = canvas.getContext(&#039;2d&#039;);&lt;br /&gt;
    var size = { width: 0, height: 0, ratio: 1 };&lt;br /&gt;
&lt;br /&gt;
    function visibleEdges() {&lt;br /&gt;
      return activeRelation === &#039;all&#039;&lt;br /&gt;
        ? edges&lt;br /&gt;
        : edges.filter(function (edge) { return edge.relation.key === activeRelation; });&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function nodeRadius(node) {&lt;br /&gt;
      return 6 + Math.min(8, Math.sqrt(node.degree) * 2.2) + (node.isSearchResult ? 3 : 0);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function confidenceColor(confidence) {&lt;br /&gt;
      if (confidence === &#039;high&#039;) { return &#039;#53c18b&#039;; }&lt;br /&gt;
      if (confidence === &#039;medium&#039;) { return &#039;#8ca8ff&#039;; }&lt;br /&gt;
      return &#039;#c0c5d8&#039;;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function resize() {&lt;br /&gt;
      var rect = viewport.getBoundingClientRect();&lt;br /&gt;
      size.width = Math.max(320, rect.width);&lt;br /&gt;
      size.height = Math.max(360, rect.height);&lt;br /&gt;
      size.ratio = Math.min(window.devicePixelRatio || 1, 2);&lt;br /&gt;
      canvas.width = Math.round(size.width * size.ratio);&lt;br /&gt;
      canvas.height = Math.round(size.height * size.ratio);&lt;br /&gt;
      canvas.style.width = size.width + &#039;px&#039;;&lt;br /&gt;
      canvas.style.height = size.height + &#039;px&#039;;&lt;br /&gt;
      draw();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function fitGraph() {&lt;br /&gt;
      if (!nodes.length) { return; }&lt;br /&gt;
      var xs = nodes.map(function (node) { return node.x; });&lt;br /&gt;
      var ys = nodes.map(function (node) { return node.y; });&lt;br /&gt;
      var minX = Math.min.apply(null, xs) - 38;&lt;br /&gt;
      var maxX = Math.max.apply(null, xs) + 38;&lt;br /&gt;
      var minY = Math.min.apply(null, ys) - 38;&lt;br /&gt;
      var maxY = Math.max.apply(null, ys) + 38;&lt;br /&gt;
      transform.scale = Math.max(0.35, Math.min(1.25, Math.min(&lt;br /&gt;
        size.width / Math.max(1, maxX - minX),&lt;br /&gt;
        size.height / Math.max(1, maxY - minY)&lt;br /&gt;
      )));&lt;br /&gt;
      transform.x = size.width / 2 - ((minX + maxX) / 2) * transform.scale;&lt;br /&gt;
      transform.y = size.height / 2 - ((minY + maxY) / 2) * transform.scale;&lt;br /&gt;
      draw();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function draw() {&lt;br /&gt;
      context.setTransform(size.ratio, 0, 0, size.ratio, 0, 0);&lt;br /&gt;
      context.clearRect(0, 0, size.width, size.height);&lt;br /&gt;
      context.save();&lt;br /&gt;
      context.translate(transform.x, transform.y);&lt;br /&gt;
      context.scale(transform.scale, transform.scale);&lt;br /&gt;
&lt;br /&gt;
      if (searchCluster) {&lt;br /&gt;
        context.beginPath();&lt;br /&gt;
        context.arc(&lt;br /&gt;
          searchCluster.x,&lt;br /&gt;
          searchCluster.y,&lt;br /&gt;
          searchCluster.radius + 48,&lt;br /&gt;
          0,&lt;br /&gt;
          Math.PI * 2&lt;br /&gt;
        );&lt;br /&gt;
        context.fillStyle = &#039;rgba(104, 126, 233, 0.12)&#039;;&lt;br /&gt;
        context.fill();&lt;br /&gt;
        context.setLineDash([7 / transform.scale, 6 / transform.scale]);&lt;br /&gt;
        context.lineWidth = 1.5 / transform.scale;&lt;br /&gt;
        context.strokeStyle = &#039;rgba(140, 168, 255, 0.75)&#039;;&lt;br /&gt;
        context.stroke();&lt;br /&gt;
        context.setLineDash([]);&lt;br /&gt;
        context.font = &#039;700 &#039; + (12 / transform.scale) + &#039;px sans-serif&#039;;&lt;br /&gt;
        context.textAlign = &#039;center&#039;;&lt;br /&gt;
        context.textBaseline = &#039;bottom&#039;;&lt;br /&gt;
        context.fillStyle = &#039;#b9c7ff&#039;;&lt;br /&gt;
        context.fillText(&lt;br /&gt;
          &#039;검색 결과 · &#039; + searchCluster.nodes.length + &#039;개 메모&#039;,&lt;br /&gt;
          searchCluster.x,&lt;br /&gt;
          searchCluster.y - searchCluster.radius - 58 / transform.scale&lt;br /&gt;
        );&lt;br /&gt;
      }&lt;br /&gt;
&lt;br /&gt;
      visibleEdges().forEach(function (edge) {&lt;br /&gt;
        var emphasized = selected &amp;amp;&amp;amp; (edge.source === selected || edge.target === selected);&lt;br /&gt;
        var searchEdge = searchCluster &amp;amp;&amp;amp;&lt;br /&gt;
          (edge.source.isSearchResult || edge.target.isSearchResult);&lt;br /&gt;
        context.globalAlpha = searchCluster &amp;amp;&amp;amp; !searchEdge ? 0.1 : 1;&lt;br /&gt;
        context.beginPath();&lt;br /&gt;
        context.moveTo(edge.source.x, edge.source.y);&lt;br /&gt;
        context.lineTo(edge.target.x, edge.target.y);&lt;br /&gt;
        context.strokeStyle = emphasized ? edge.relation.color : edge.relation.color + &#039;55&#039;;&lt;br /&gt;
        context.lineWidth = (emphasized || searchEdge ? 2.2 : 1) / transform.scale;&lt;br /&gt;
        context.stroke();&lt;br /&gt;
      });&lt;br /&gt;
      context.globalAlpha = 1;&lt;br /&gt;
&lt;br /&gt;
      nodes.forEach(function (node) {&lt;br /&gt;
        var isSelected = node === selected;&lt;br /&gt;
        var isHovered = node === hovered;&lt;br /&gt;
        var isSearchResult = Boolean(node.isSearchResult);&lt;br /&gt;
        var radius = nodeRadius(node);&lt;br /&gt;
        context.globalAlpha = searchCluster &amp;amp;&amp;amp; !isSearchResult ? 0.12 : 1;&lt;br /&gt;
        context.beginPath();&lt;br /&gt;
        context.arc(node.x, node.y, radius + (isSelected ? 4 : 0), 0, Math.PI * 2);&lt;br /&gt;
        context.fillStyle = isSelected || isSearchResult ? &#039;#ffffff&#039; : confidenceColor(node.confidence);&lt;br /&gt;
        context.fill();&lt;br /&gt;
        context.lineWidth = (isSelected || isSearchResult ? 3 : 1.5) / transform.scale;&lt;br /&gt;
        context.strokeStyle = isSelected || isSearchResult ? &#039;#8ca8ff&#039; : &#039;#11182e&#039;;&lt;br /&gt;
        context.stroke();&lt;br /&gt;
&lt;br /&gt;
        if (isSelected || isHovered || isSearchResult ||&lt;br /&gt;
          (transform.scale &amp;gt; 0.68 &amp;amp;&amp;amp; node.degree &amp;gt; 0)) {&lt;br /&gt;
          context.font = (isSelected || isSearchResult ? &#039;600 &#039; : &#039;&#039;) + (12 / transform.scale) + &#039;px sans-serif&#039;;&lt;br /&gt;
          context.textAlign = &#039;center&#039;;&lt;br /&gt;
          context.textBaseline = &#039;top&#039;;&lt;br /&gt;
          context.fillStyle = &#039;#f5f7ff&#039;;&lt;br /&gt;
          context.shadowColor = &#039;#080b16&#039;;&lt;br /&gt;
          context.shadowBlur = 5 / transform.scale;&lt;br /&gt;
          var label = node.title.length &amp;gt; 26 ? node.title.slice(0, 25) + &#039;…&#039; : node.title;&lt;br /&gt;
          context.fillText(label, node.x, node.y + radius + 7 / transform.scale);&lt;br /&gt;
          context.shadowBlur = 0;&lt;br /&gt;
        }&lt;br /&gt;
      });&lt;br /&gt;
      context.globalAlpha = 1;&lt;br /&gt;
      context.restore();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function simulate() {&lt;br /&gt;
      if (simulationTicks &amp;gt; 220) {&lt;br /&gt;
        if (searchCluster) {&lt;br /&gt;
          draw();&lt;br /&gt;
        } else {&lt;br /&gt;
          fitGraph();&lt;br /&gt;
        }&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      for (var pass = 0; pass &amp;lt; 3; pass += 1) {&lt;br /&gt;
        for (var i = 0; i &amp;lt; nodes.length; i += 1) {&lt;br /&gt;
          for (var j = i + 1; j &amp;lt; nodes.length; j += 1) {&lt;br /&gt;
            var first = nodes[i];&lt;br /&gt;
            var second = nodes[j];&lt;br /&gt;
            var dx = second.x - first.x || 0.01;&lt;br /&gt;
            var dy = second.y - first.y || 0.01;&lt;br /&gt;
            var distanceSquared = dx * dx + dy * dy + 60;&lt;br /&gt;
            var force = 145 / distanceSquared;&lt;br /&gt;
            first.vx -= dx * force;&lt;br /&gt;
            first.vy -= dy * force;&lt;br /&gt;
            second.vx += dx * force;&lt;br /&gt;
            second.vy += dy * force;&lt;br /&gt;
          }&lt;br /&gt;
        }&lt;br /&gt;
        edges.forEach(function (edge) {&lt;br /&gt;
          var dx = edge.target.x - edge.source.x;&lt;br /&gt;
          var dy = edge.target.y - edge.source.y;&lt;br /&gt;
          var distance = Math.sqrt(dx * dx + dy * dy) || 1;&lt;br /&gt;
          var force = (distance - 64) * 0.0014;&lt;br /&gt;
          edge.source.vx += dx * force;&lt;br /&gt;
          edge.source.vy += dy * force;&lt;br /&gt;
          edge.target.vx -= dx * force;&lt;br /&gt;
          edge.target.vy -= dy * force;&lt;br /&gt;
        });&lt;br /&gt;
        nodes.forEach(function (node) {&lt;br /&gt;
          if (node.isSearchResult) {&lt;br /&gt;
            node.vx = 0;&lt;br /&gt;
            node.vy = 0;&lt;br /&gt;
            return;&lt;br /&gt;
          }&lt;br /&gt;
          node.vx += -node.x * 0.0018;&lt;br /&gt;
          node.vy += -node.y * 0.0018;&lt;br /&gt;
          node.vx *= 0.82;&lt;br /&gt;
          node.vy *= 0.82;&lt;br /&gt;
          node.x += node.vx;&lt;br /&gt;
          node.y += node.vy;&lt;br /&gt;
        });&lt;br /&gt;
        simulationTicks += 1;&lt;br /&gt;
      }&lt;br /&gt;
      draw();&lt;br /&gt;
      animationFrame = window.requestAnimationFrame(simulate);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function relationDefinition(key) {&lt;br /&gt;
      return RELATIONS.find(function (relation) { return relation.key === key; });&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function updateCounts() {&lt;br /&gt;
      if (searchCluster) {&lt;br /&gt;
        var relatedEdges = edges.filter(function (edge) {&lt;br /&gt;
          return edge.source.isSearchResult || edge.target.isSearchResult;&lt;br /&gt;
        }).length;&lt;br /&gt;
        counts.textContent = &#039;검색 결과 &#039; + searchCluster.nodes.length + &#039;개 · &#039; +&lt;br /&gt;
          relatedEdges + &#039;개 연결&#039;;&lt;br /&gt;
      } else {&lt;br /&gt;
        counts.textContent = nodes.length + &#039;개 메모 · &#039; + visibleEdges().length + &#039;개 연결&#039;;&lt;br /&gt;
      }&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function resetSearchCluster() {&lt;br /&gt;
      var addedKeys = Object.create(null);&lt;br /&gt;
      edges.forEach(function (edge) {&lt;br /&gt;
        if (edge.searchAdded) {&lt;br /&gt;
          edge.source.degree = Math.max(0, edge.source.degree - 1);&lt;br /&gt;
          edge.target.degree = Math.max(0, edge.target.degree - 1);&lt;br /&gt;
        }&lt;br /&gt;
      });&lt;br /&gt;
      nodes.forEach(function (node) {&lt;br /&gt;
        if (node.searchAdded) {&lt;br /&gt;
          addedKeys[canonicalId(node.id)] = true;&lt;br /&gt;
          delete byId[canonicalId(node.id)];&lt;br /&gt;
        } else if (node.preSearchPosition) {&lt;br /&gt;
          node.x = node.preSearchPosition.x;&lt;br /&gt;
          node.y = node.preSearchPosition.y;&lt;br /&gt;
        }&lt;br /&gt;
        delete node.isSearchResult;&lt;br /&gt;
        delete node.preSearchPosition;&lt;br /&gt;
      });&lt;br /&gt;
      nodes = nodes.filter(function (node) { return !node.searchAdded; });&lt;br /&gt;
      edges = edges.filter(function (edge) {&lt;br /&gt;
        return !edge.searchAdded &amp;amp;&amp;amp;&lt;br /&gt;
          !addedKeys[canonicalId(edge.source.id)] &amp;amp;&amp;amp;&lt;br /&gt;
          !addedKeys[canonicalId(edge.target.id)];&lt;br /&gt;
      });&lt;br /&gt;
      searchCluster = null;&lt;br /&gt;
      clearSearch.hidden = true;&lt;br /&gt;
      selected = null;&lt;br /&gt;
      showInspector(null);&lt;br /&gt;
      updateCounts();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function focusSearchResults(items, query) {&lt;br /&gt;
      resetSearchCluster();&lt;br /&gt;
      if (!items.length) {&lt;br /&gt;
        fitGraph();&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
&lt;br /&gt;
      var resultNodes = [];&lt;br /&gt;
      items.forEach(function (item, index) {&lt;br /&gt;
        var key = canonicalId(item.id);&lt;br /&gt;
        var node = byId[key];&lt;br /&gt;
        if (!node) {&lt;br /&gt;
          node = {&lt;br /&gt;
            id: item.id,&lt;br /&gt;
            title: item.title || String(item.id).replace(/^Lesson:/, &#039;&#039;),&lt;br /&gt;
            confidence: item.confidence || &#039;low&#039;,&lt;br /&gt;
            updatedAt: &#039;&#039;,&lt;br /&gt;
            x: 0,&lt;br /&gt;
            y: 0,&lt;br /&gt;
            vx: 0,&lt;br /&gt;
            vy: 0,&lt;br /&gt;
            degree: 0,&lt;br /&gt;
            searchAdded: true&lt;br /&gt;
          };&lt;br /&gt;
          nodes.push(node);&lt;br /&gt;
          byId[key] = node;&lt;br /&gt;
        } else {&lt;br /&gt;
          node.preSearchPosition = { x: node.x, y: node.y };&lt;br /&gt;
          node.title = item.title || node.title;&lt;br /&gt;
          node.confidence = item.confidence || node.confidence;&lt;br /&gt;
        }&lt;br /&gt;
        node.isSearchResult = true;&lt;br /&gt;
        node.searchRank = index + 1;&lt;br /&gt;
        resultNodes.push(node);&lt;br /&gt;
      });&lt;br /&gt;
&lt;br /&gt;
      items.forEach(function (item) {&lt;br /&gt;
        (Array.isArray(item.relations) ? item.relations : []).forEach(function (relationItem) {&lt;br /&gt;
          var source = byId[canonicalId(relationItem.from || item.id)];&lt;br /&gt;
          var target = byId[canonicalId(relationItem.to)];&lt;br /&gt;
          var relation = relationDefinition(relationItem.relation);&lt;br /&gt;
          if (!source || !target || !relation || source === target) {&lt;br /&gt;
            return;&lt;br /&gt;
          }&lt;br /&gt;
          var duplicate = edges.some(function (edge) {&lt;br /&gt;
            return canonicalId(edge.source.id) === canonicalId(source.id) &amp;amp;&amp;amp;&lt;br /&gt;
              canonicalId(edge.target.id) === canonicalId(target.id) &amp;amp;&amp;amp;&lt;br /&gt;
              edge.relation.key === relation.key;&lt;br /&gt;
          });&lt;br /&gt;
          if (!duplicate) {&lt;br /&gt;
            edges.push({&lt;br /&gt;
              source: source,&lt;br /&gt;
              target: target,&lt;br /&gt;
              relation: relation,&lt;br /&gt;
              searchAdded: true&lt;br /&gt;
            });&lt;br /&gt;
            source.degree += 1;&lt;br /&gt;
            target.degree += 1;&lt;br /&gt;
          }&lt;br /&gt;
        });&lt;br /&gt;
      });&lt;br /&gt;
&lt;br /&gt;
      var clusterRadius = resultNodes.length === 1 ? 0 :&lt;br /&gt;
        Math.max(82, Math.min(132, resultNodes.length * 22));&lt;br /&gt;
      resultNodes.forEach(function (node, index) {&lt;br /&gt;
        var angle = -Math.PI / 2 + (Math.PI * 2 * index / resultNodes.length);&lt;br /&gt;
        node.x = Math.cos(angle) * clusterRadius;&lt;br /&gt;
        node.y = Math.sin(angle) * clusterRadius;&lt;br /&gt;
        node.vx = 0;&lt;br /&gt;
        node.vy = 0;&lt;br /&gt;
      });&lt;br /&gt;
      searchCluster = {&lt;br /&gt;
        query: query,&lt;br /&gt;
        nodes: resultNodes,&lt;br /&gt;
        x: 0,&lt;br /&gt;
        y: 0,&lt;br /&gt;
        radius: clusterRadius&lt;br /&gt;
      };&lt;br /&gt;
      activeRelation = &#039;all&#039;;&lt;br /&gt;
      filter.value = &#039;all&#039;;&lt;br /&gt;
      clearSearch.hidden = false;&lt;br /&gt;
      transform.scale = Math.max(0.72, Math.min(&lt;br /&gt;
        1.35,&lt;br /&gt;
        Math.min(size.width, size.height) / Math.max(360, clusterRadius * 2 + 150)&lt;br /&gt;
      ));&lt;br /&gt;
      transform.x = size.width / 2;&lt;br /&gt;
      transform.y = size.height / 2;&lt;br /&gt;
      selectNode(resultNodes[0]);&lt;br /&gt;
      updateCounts();&lt;br /&gt;
      draw();&lt;br /&gt;
      viewport.scrollIntoView({ behavior: &#039;smooth&#039;, block: &#039;center&#039; });&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function connectionSummary(node) {&lt;br /&gt;
      var connected = edges.filter(function (edge) {&lt;br /&gt;
        return edge.source === node || edge.target === node;&lt;br /&gt;
      });&lt;br /&gt;
      var summary = Object.create(null);&lt;br /&gt;
      connected.forEach(function (edge) {&lt;br /&gt;
        summary[edge.relation.label] = (summary[edge.relation.label] || 0) + 1;&lt;br /&gt;
      });&lt;br /&gt;
      return { edges: connected, counts: summary };&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function showInspector(node) {&lt;br /&gt;
      inspector.replaceChildren();&lt;br /&gt;
      if (!node) {&lt;br /&gt;
        var welcome = makeElement(&#039;div&#039;, &#039;s3rm-inspector-welcome&#039;);&lt;br /&gt;
        welcome.appendChild(makeElement(&#039;span&#039;, &#039;s3rm-inspector-symbol&#039;, &#039;⌁&#039;));&lt;br /&gt;
        welcome.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;메모를 선택하세요&#039;));&lt;br /&gt;
        welcome.appendChild(makeElement(&#039;p&#039;, &#039;&#039;, &#039;점 하나를 누르면 메모 정보와 연결된 기록을 보여 드립니다.&#039;));&lt;br /&gt;
        inspector.appendChild(welcome);&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      var summary = connectionSummary(node);&lt;br /&gt;
      inspector.appendChild(makeElement(&lt;br /&gt;
        &#039;div&#039;,&lt;br /&gt;
        &#039;s3rm-inspector-kicker&#039;,&lt;br /&gt;
        node.isSearchResult ? &#039;검색 결과 &#039; + node.searchRank : &#039;선택한 메모&#039;&lt;br /&gt;
      ));&lt;br /&gt;
      inspector.appendChild(makeElement(&#039;h3&#039;, &#039;&#039;, node.title));&lt;br /&gt;
      var meta = makeElement(&#039;div&#039;, &#039;s3rm-inspector-meta&#039;);&lt;br /&gt;
      meta.appendChild(makeElement(&#039;span&#039;, &#039;s3rm-confidence s3rm-confidence-&#039; + node.confidence, confidenceLabel(node.confidence)));&lt;br /&gt;
      meta.appendChild(makeElement(&#039;span&#039;, &#039;&#039;, summary.edges.length + &#039;개 연결&#039;));&lt;br /&gt;
      inspector.appendChild(meta);&lt;br /&gt;
      if (Object.keys(summary.counts).length) {&lt;br /&gt;
        var relationList = makeElement(&#039;ul&#039;, &#039;s3rm-inspector-relations&#039;);&lt;br /&gt;
        Object.keys(summary.counts).forEach(function (label) {&lt;br /&gt;
          var item = makeElement(&#039;li&#039;, &#039;&#039;);&lt;br /&gt;
          item.append(makeElement(&#039;span&#039;, &#039;&#039;, label), makeElement(&#039;strong&#039;, &#039;&#039;, String(summary.counts[label])));&lt;br /&gt;
          relationList.appendChild(item);&lt;br /&gt;
        });&lt;br /&gt;
        inspector.appendChild(relationList);&lt;br /&gt;
      } else {&lt;br /&gt;
        inspector.appendChild(makeElement(&#039;p&#039;, &#039;s3rm-inspector-muted&#039;, &#039;아직 다른 메모와 연결되지 않았습니다.&#039;));&lt;br /&gt;
      }&lt;br /&gt;
      var open = makeElement(&#039;a&#039;, &#039;s3rm-graph-primary-link&#039;, &#039;메모 열기&#039;);&lt;br /&gt;
      open.href = mw.util.getUrl(node.id);&lt;br /&gt;
      inspector.appendChild(open);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function canvasPoint(event) {&lt;br /&gt;
      var rect = canvas.getBoundingClientRect();&lt;br /&gt;
      return { x: event.clientX - rect.left, y: event.clientY - rect.top };&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function worldPoint(event) {&lt;br /&gt;
      var point = canvasPoint(event);&lt;br /&gt;
      return {&lt;br /&gt;
        x: (point.x - transform.x) / transform.scale,&lt;br /&gt;
        y: (point.y - transform.y) / transform.scale&lt;br /&gt;
      };&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function hitNode(event) {&lt;br /&gt;
      var point = worldPoint(event);&lt;br /&gt;
      for (var i = nodes.length - 1; i &amp;gt;= 0; i -= 1) {&lt;br /&gt;
        var node = nodes[i];&lt;br /&gt;
        var dx = point.x - node.x;&lt;br /&gt;
        var dy = point.y - node.y;&lt;br /&gt;
        if (dx * dx + dy * dy &amp;lt;= Math.pow(nodeRadius(node) + 6 / transform.scale, 2)) {&lt;br /&gt;
          return node;&lt;br /&gt;
        }&lt;br /&gt;
      }&lt;br /&gt;
      return null;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function selectNode(node) {&lt;br /&gt;
      selected = node;&lt;br /&gt;
      showInspector(node);&lt;br /&gt;
      draw();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    canvas.addEventListener(&#039;pointerdown&#039;, function (event) {&lt;br /&gt;
      dragging = hitNode(event);&lt;br /&gt;
      panning = !dragging;&lt;br /&gt;
      pointerStart = canvasPoint(event);&lt;br /&gt;
      canvas.setPointerCapture(event.pointerId);&lt;br /&gt;
      canvas.classList.add(&#039;is-dragging&#039;);&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;pointermove&#039;, function (event) {&lt;br /&gt;
      if (dragging) {&lt;br /&gt;
        var world = worldPoint(event);&lt;br /&gt;
        dragging.x = world.x;&lt;br /&gt;
        dragging.y = world.y;&lt;br /&gt;
        dragging.vx = 0;&lt;br /&gt;
        dragging.vy = 0;&lt;br /&gt;
      } else if (panning &amp;amp;&amp;amp; pointerStart) {&lt;br /&gt;
        var point = canvasPoint(event);&lt;br /&gt;
        transform.x += point.x - pointerStart.x;&lt;br /&gt;
        transform.y += point.y - pointerStart.y;&lt;br /&gt;
        pointerStart = point;&lt;br /&gt;
      } else {&lt;br /&gt;
        hovered = hitNode(event);&lt;br /&gt;
        canvas.style.cursor = hovered ? &#039;pointer&#039; : &#039;grab&#039;;&lt;br /&gt;
      }&lt;br /&gt;
      draw();&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;pointerup&#039;, function (event) {&lt;br /&gt;
      var hit = hitNode(event);&lt;br /&gt;
      if (dragging &amp;amp;&amp;amp; hit === dragging) {&lt;br /&gt;
        selectNode(dragging);&lt;br /&gt;
      }&lt;br /&gt;
      dragging = null;&lt;br /&gt;
      panning = false;&lt;br /&gt;
      pointerStart = null;&lt;br /&gt;
      canvas.classList.remove(&#039;is-dragging&#039;);&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;dblclick&#039;, function (event) {&lt;br /&gt;
      var node = hitNode(event);&lt;br /&gt;
      if (node) {&lt;br /&gt;
        window.location.href = mw.util.getUrl(node.id);&lt;br /&gt;
      }&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;wheel&#039;, function (event) {&lt;br /&gt;
      event.preventDefault();&lt;br /&gt;
      var point = canvasPoint(event);&lt;br /&gt;
      var oldScale = transform.scale;&lt;br /&gt;
      var nextScale = Math.max(0.25, Math.min(3.5, oldScale * Math.exp(-event.deltaY * 0.0012)));&lt;br /&gt;
      transform.x = point.x - ((point.x - transform.x) / oldScale) * nextScale;&lt;br /&gt;
      transform.y = point.y - ((point.y - transform.y) / oldScale) * nextScale;&lt;br /&gt;
      transform.scale = nextScale;&lt;br /&gt;
      draw();&lt;br /&gt;
    }, { passive: false });&lt;br /&gt;
&lt;br /&gt;
    search.addEventListener(&#039;input&#039;, function () {&lt;br /&gt;
      var query = search.value.trim().toLocaleLowerCase(&#039;ko&#039;);&lt;br /&gt;
      searchResults.replaceChildren();&lt;br /&gt;
      if (!query) {&lt;br /&gt;
        searchResults.hidden = true;&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      var matches = nodes.filter(function (node) {&lt;br /&gt;
        return node.title.toLocaleLowerCase(&#039;ko&#039;).includes(query) || node.id.toLowerCase().includes(query);&lt;br /&gt;
      }).slice(0, 6);&lt;br /&gt;
      matches.forEach(function (node) {&lt;br /&gt;
        var button = makeElement(&#039;button&#039;, &#039;&#039;, node.title);&lt;br /&gt;
        button.type = &#039;button&#039;;&lt;br /&gt;
        button.addEventListener(&#039;click&#039;, function () {&lt;br /&gt;
          selectNode(node);&lt;br /&gt;
          transform.x = size.width / 2 - node.x * transform.scale;&lt;br /&gt;
          transform.y = size.height / 2 - node.y * transform.scale;&lt;br /&gt;
          search.value = node.title;&lt;br /&gt;
          searchResults.hidden = true;&lt;br /&gt;
          draw();&lt;br /&gt;
        });&lt;br /&gt;
        searchResults.appendChild(button);&lt;br /&gt;
      });&lt;br /&gt;
      if (!matches.length) {&lt;br /&gt;
        searchResults.appendChild(makeElement(&#039;span&#039;, &#039;&#039;, &#039;일치하는 메모가 없습니다.&#039;));&lt;br /&gt;
      }&lt;br /&gt;
      searchResults.hidden = false;&lt;br /&gt;
    });&lt;br /&gt;
    search.addEventListener(&#039;keydown&#039;, function (event) {&lt;br /&gt;
      if (event.key === &#039;Escape&#039;) {&lt;br /&gt;
        searchResults.hidden = true;&lt;br /&gt;
      } else if (event.key === &#039;Enter&#039;) {&lt;br /&gt;
        var first = searchResults.querySelector(&#039;button&#039;);&lt;br /&gt;
        if (first) { first.click(); }&lt;br /&gt;
      }&lt;br /&gt;
    });&lt;br /&gt;
    filter.addEventListener(&#039;change&#039;, function () {&lt;br /&gt;
      activeRelation = filter.value;&lt;br /&gt;
      updateCounts();&lt;br /&gt;
      draw();&lt;br /&gt;
    });&lt;br /&gt;
    reset.addEventListener(&#039;click&#039;, fitGraph);&lt;br /&gt;
    clearSearch.addEventListener(&#039;click&#039;, function () {&lt;br /&gt;
      resetSearchCluster();&lt;br /&gt;
      fitGraph();&lt;br /&gt;
    });&lt;br /&gt;
&lt;br /&gt;
    showInspector(null);&lt;br /&gt;
    resize();&lt;br /&gt;
    var resizeObserver = window.ResizeObserver ? new ResizeObserver(resize) : null;&lt;br /&gt;
    if (resizeObserver) {&lt;br /&gt;
      resizeObserver.observe(viewport);&lt;br /&gt;
    } else {&lt;br /&gt;
      window.addEventListener(&#039;resize&#039;, resize);&lt;br /&gt;
    }&lt;br /&gt;
    graphSearchController = focusSearchResults;&lt;br /&gt;
    if (pendingGraphSearch) {&lt;br /&gt;
      focusSearchResults(pendingGraphSearch.items, pendingGraphSearch.query);&lt;br /&gt;
      pendingGraphSearch = null;&lt;br /&gt;
    }&lt;br /&gt;
    animationFrame = window.requestAnimationFrame(simulate);&lt;br /&gt;
    window.addEventListener(&#039;pagehide&#039;, function () {&lt;br /&gt;
      window.cancelAnimationFrame(animationFrame);&lt;br /&gt;
      if (resizeObserver) { resizeObserver.disconnect(); }&lt;br /&gt;
    }, { once: true });&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  var askQuery = &#039;[[Category:Lessons]]&#039; +&lt;br /&gt;
    &#039;|?Lesson title|?Confidence|?Updated at&#039; +&lt;br /&gt;
    RELATIONS.map(function (relation) { return &#039;|?&#039; + relation.property; }).join(&#039;&#039;) +&lt;br /&gt;
    &#039;|sort=Updated at|order=descending|limit=&#039; + NODE_LIMIT;&lt;br /&gt;
&lt;br /&gt;
  createSemanticSearch();&lt;br /&gt;
&lt;br /&gt;
  mw.loader.using([&#039;mediawiki.api&#039;, &#039;mediawiki.util&#039;]).then(function () {&lt;br /&gt;
    return new mw.Api().get({ action: &#039;ask&#039;, query: askQuery, formatversion: 2 });&lt;br /&gt;
  }).then(function (data) {&lt;br /&gt;
    var graph = parseGraph(data);&lt;br /&gt;
    if (!graph.nodes.length) {&lt;br /&gt;
      renderEmpty();&lt;br /&gt;
      return;&lt;br /&gt;
    }&lt;br /&gt;
    createGraph(graph);&lt;br /&gt;
  }).catch(renderError);&lt;br /&gt;
}());&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC/%EB%A9%94%EB%AA%A8_%EC%82%AC%EC%9A%A9%EB%B2%95&amp;diff=2725</id>
		<title>도움말:S3 연구 메모리/메모 사용법</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC/%EB%A9%94%EB%AA%A8_%EC%82%AC%EC%9A%A9%EB%B2%95&amp;diff=2725"/>
		<updated>2026-07-19T02:07:46Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 저장소 문서 동기화&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;!-- S3RM managed repository document; edit the Markdown source, not this page. --&amp;gt;&lt;br /&gt;
&#039;&#039;이 페이지는 저장소 문서에서 자동으로 동기화됩니다. 위키에서 직접 편집하지 마세요.&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
S3 연구 메모리는 프로젝트 일지나 실험 원본을 모으는 곳이 아닙니다. 다른 작업에서도 다시 참고할 만한 시행착오를 짧은 메모로 남깁니다.&lt;br /&gt;
&lt;br /&gt;
=== 먼저 검색하기 ===&lt;br /&gt;
&lt;br /&gt;
브라우저 메인 화면의 검색창에는 궁금한 점을 문장으로 입력할 수 있습니다. 표현이 달라도 관련된 메모를 함께 찾습니다. 그 아래 지식 그래프에서 점을 누르면 연결 수와 관계 유형을 볼 수 있고, 두 번 누르면 메모가 열립니다. 제목 검색, 관계 필터, 확대와 이동도 사용할 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
본문까지 찾으려면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Special:Search&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 에이전트에서는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;search_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 사용합니다. 프로젝트 이름보다 현상과 원인을 나타내는 말로 찾는 편이 잘 맞습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
메모리 단편화, retry storm, lock contention, cold start&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
같은 내용이 이미 있으면 새 메모를 만들지 않습니다. 기존 메모에 자료를 더하거나, 내용이 다른 이유를 관계로 연결하면 됩니다.&lt;br /&gt;
&lt;br /&gt;
=== 브라우저에서 메모 쓰기 ===&lt;br /&gt;
&lt;br /&gt;
로그인한 뒤 다음 화면을 엽니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
/index.php/연구_메모리#create-a-lesson&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
문서 ID에는 영문 소문자, 숫자, 밑줄, 하이픈만 씁니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Lesson:&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 입력하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
gpu_allocator_fragmentation&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
문서 ID는 에이전트가 메모를 인용할 때도 쓰므로 날짜, 사람 이름, 프로젝트에서만 통하는 줄임말은 피하세요. 화면에 보이는 제목은 나중에 바꿀 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
입력란에는 다음 내용을 적습니다.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 화면 !! 필드 !! 적을 내용&lt;br /&gt;
|-&lt;br /&gt;
| 제목 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;title&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 메모의 결론이 드러나는 짧은 제목&lt;br /&gt;
|-&lt;br /&gt;
| 궁금했던 점 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;question&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 시도를 시작한 이유&lt;br /&gt;
|-&lt;br /&gt;
| 해본 것 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;attempt&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 실제로 바꿨거나 비교한 것&lt;br /&gt;
|-&lt;br /&gt;
| 당시 조건 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;context&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 장비, 버전, 설정, 워크로드 등 결과에 영향을 준 조건&lt;br /&gt;
|-&lt;br /&gt;
| 실제 결과 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;observation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 직접 확인한 수치나 동작&lt;br /&gt;
|-&lt;br /&gt;
| 왜 그랬는지 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;interpretation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 가능한 원인과 아직 확실하지 않은 부분&lt;br /&gt;
|-&lt;br /&gt;
| 다음에 기억할 것 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;reusable_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 다른 작업에서도 써먹을 한 문장&lt;br /&gt;
|-&lt;br /&gt;
| 언제 맞는지 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;applicability&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 맞는 조건과 예외&lt;br /&gt;
|-&lt;br /&gt;
| 신뢰도 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;confidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 자료의 질에 따라 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;low&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;medium&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;high&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 중 선택&lt;br /&gt;
|-&lt;br /&gt;
| 관련 자료 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;evidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아래에 연결할 자료를 짧게 설명&lt;br /&gt;
|-&lt;br /&gt;
| 자료 출처 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;record_origin&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 직접 얻은 내용은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;lab&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 외부 자료를 정리했으면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;imported&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
관찰과 해석은 나눠 쓰세요. 예를 들어 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;동시 compaction 16개에서 p99가 18 ms에서 71 ms로 늘었다&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 관찰입니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;공유 writeback 제한 때문에 queue가 묶였다&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 해석이며, 새 자료가 나오면 달라질 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
저장한 메모는 바로 검색되고 메인 화면의 지식 그래프와 최근 메모에도 나타납니다.&lt;br /&gt;
&lt;br /&gt;
=== 자료 연결하기 ===&lt;br /&gt;
&lt;br /&gt;
메모마다 자료를 하나 이상 연결합니다. 원본 전체를 위키에 복사하지 말고 나중에 다시 찾을 수 있는 위치를 적으세요.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 입력란 !! 내용&lt;br /&gt;
|-&lt;br /&gt;
| 자료 ID || 메모 안에서 겹치지 않는 짧은 ID&lt;br /&gt;
|-&lt;br /&gt;
| 출처 || 저장소, 커밋, 실행 ID, 문서 절, 시간 구간 등&lt;br /&gt;
|-&lt;br /&gt;
| URL || 바로 열 수 있는 주소가 있을 때만 입력&lt;br /&gt;
|-&lt;br /&gt;
| 자료 종류 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;paper&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;code&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;dataset&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;log&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;benchmark&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;other&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 중 선택&lt;br /&gt;
|-&lt;br /&gt;
| 확인 범위 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;full_text&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;official_abstract&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;partial_source&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;metadata_only&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 중 선택&lt;br /&gt;
|-&lt;br /&gt;
| 메모 || 이 자료에서 확인할 부분과 한계&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
git yonsei-s3/kernel-probes@4f19a2e, results/lockstat/run-031/summary.json&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
확인 범위는 자료에서 실제로 읽은 범위이며 Lesson의 종합 신뢰도인 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;confidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 다릅니다. 서지정보만 확인했다면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;metadata_only&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 표시하고 기술 내용을 추측하지 않습니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;not_recorded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 이전 근거에 값이 없을 때 표시되는 호환값이므로 새 근거에는 선택하지 않습니다. 브라우저 양식에도 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;not_recorded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 기본값이 없고 새 근거에 선택할 수 없습니다. MCP로 새 근거를 붙일 때는 나머지 네 값 중 하나를 명시해야 합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;latest&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;대시보드&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 작성자만 아는 로컬 경로처럼 시간이 지나면 찾기 어려운 표현은 피합니다. 비밀번호, 토큰, 개인 정보, 공개할 수 없는 대화, 대용량 원시 데이터도 본문에 넣지 않습니다.&lt;br /&gt;
&lt;br /&gt;
=== 근거를 검증한 기록 남기기 ===&lt;br /&gt;
&lt;br /&gt;
자료를 실제로 읽어 Lesson 주장을 확인했다면 기존 evidence를 고치지 말고 새 검증 기록을 붙입니다. 같은 evidence를 나중에 다시 확인했다면 이전 기록을 수정·삭제하지 않고 새 기록을 append-only로 붙입니다. evidence별 마지막 기록이 현재 판정입니다.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 입력란 !! 내용&lt;br /&gt;
|-&lt;br /&gt;
| evidence ID || 검사한 기존 근거 ID&lt;br /&gt;
|-&lt;br /&gt;
| evidence digest || 읽기 시점의 evidence 내용 digest&lt;br /&gt;
|-&lt;br /&gt;
| 확인 범위 || 이번에 실제로 검사한 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;verification_basis&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| 자료 식별자 || 파일, 커밋, 데이터 버전, 문서 버전 등 고정 식별자&lt;br /&gt;
|-&lt;br /&gt;
| 자료 SHA-256 || 실제로 검사한 정확한 바이트의 SHA-256&lt;br /&gt;
|-&lt;br /&gt;
| 위치 || 페이지, 절, 라인, 실험 구간 등 주장을 확인한 위치&lt;br /&gt;
|-&lt;br /&gt;
| 확인 범위와 한계 || 읽은 범위와 제외한 부분&lt;br /&gt;
|-&lt;br /&gt;
| 판정 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supports&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;contradicts&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;inconclusive&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 중 하나&lt;br /&gt;
|-&lt;br /&gt;
| 연결 주장 || 자료가 실제로 확인한 Lesson 필드&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
원본 파일을 검사했다면 그 파일 바이트를 해시합니다. 원본 대신 저장·정규화한 텍스트를 검사했다면 그 정확한 텍스트의 UTF-8 바이트를 해시합니다. 이 경우 변환 방법과 보존한 산출물을 자료 식별자와 확인 범위에 명시합니다. MediaWiki revision의 저장 계정과 시각이 권위 출처입니다. 검증 기록의 출처는 현재 저장 계정과 정확히 같아야 하고, 기록 시각은 revision 저장 시각 전후 5분 안이며 이전 기록보다 늦어야 하는 보조 필드입니다. 기존 출처와 시각은 수정할 수 없습니다.&lt;br /&gt;
&lt;br /&gt;
high confidence는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;full_text&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 또는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;official_abstract&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 기반으로 한 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supports&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 검증이 observation, interpretation, reusable_lesson 세 필드를 모두 뒷받침할 때만 사용합니다. observation, interpretation, reusable_lesson을 수정한 후 결과 confidence가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;medium&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 또는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;high&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이면 실제로 바꾸는 주장과 연결한 같은 강도의 검증이 필요합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;contradicts&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;나 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;inconclusive&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 지지 근거로 세지 않습니다. 결과 confidence가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;low&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;인 교정은 기존 confidence를 낮추는 경우를 포함해 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;not_recorded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 아닌 basis evidence로 할 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
=== 다른 메모와 연결하기 ===&lt;br /&gt;
&lt;br /&gt;
관계는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;출발 메모 관계값 대상 메모&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 순서로 읽습니다.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 관계 !! 뜻&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supports&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 대상 메모와 맞는 자료나 원인을 보탬&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;contradicts&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 대상 메모의 주요 주장과 관찰이 충돌함&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;analogous_to&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 분야는 달라도 같은 원인이나 실패 형태를 보임&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;failed_because&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 대상 메모에 적힌 이유로 시도가 실패함&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;works_when&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 대상 메모의 조건에서 방법이 잘 작동함&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;suggests&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 다음 가설이나 시도를 제안함&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supersedes&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 새 메모가 이전 안내를 대신함. 상태는 자동으로 바뀌지 않음&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
연결 이유에는 두 메모가 닮거나 충돌하는 지점을 한두 문장으로 씁니다. 관련성이 애매하면 억지로 연결하지 않아도 됩니다.&lt;br /&gt;
&lt;br /&gt;
=== 메모 고치기 ===&lt;br /&gt;
&lt;br /&gt;
메모 화면에서 &#039;&#039;&#039;메모 편집&#039;&#039;&#039;을 누릅니다. 잘못 적은 수치나 해석을 고치고, 수정 요약에 이유를 짧게 남기세요. 이전 내용은 MediaWiki 이력에서 확인할 수 있습니다. 에이전트가 고칠 때는 현재 revision과 근거를 다시 읽은 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;revise_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 사용합니다. 같은 논문의 보강 내용을 evidence note나 새 Technical Review 메모에 숨기지 않습니다. 새 근거를 먼저 붙였다면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;attach_evidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 응답의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;current_revision_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 다음 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;revise_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;expected_revision_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 사용합니다. 그 사이 다른 변경이 있었다면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;get_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 다시 읽고 변경안을 재계산합니다.&lt;br /&gt;
&lt;br /&gt;
=== MCP로 찾기 ===&lt;br /&gt;
&lt;br /&gt;
처음에는 결과를 3~5개만 받고 필요한 필드만 요청하는 편이 빠릅니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
S3 Research Memory에서 tail latency와 관련된 메모를 최대 5개 찾아 줘.&lt;br /&gt;
id, title, reusable_lesson, applicability, confidence, citations만 반환해.&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
후보를 고른 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;get_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 자세히 읽고, 관계는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;find_related_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 다른 분야의 비슷한 사례는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;find_analogies&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 찾습니다. 근거 note와 추가 정보를 확인하려면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;citation_detail=full&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 지정합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;get_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 revision ID는 이어지는 조건부 수정과 감사에 사용합니다. 답변에 메모를 사용했다면 반환된 자료 인용도 같이 보관하세요.&lt;br /&gt;
&lt;br /&gt;
서버에서 의미 검색을 켠 경우에는 표현이 다른 문제도 찾도록 다음처럼 요청할 수 있습니다. 기본 검색은 계속 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;legacy&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이므로 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;hybrid_v1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 직접 지정합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
search_lessons를 retrieval_mode=hybrid_v1로 실행해,&lt;br /&gt;
문제의 제약과 해결 원리가 비슷한 메모를 최대 5개 찾아 줘.&lt;br /&gt;
effective_mode와 semantic_status도 알려 줘.&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
의미 유사도는 저장된 관계나 사실이 아닙니다. 후보의 적용 조건과 근거를 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;get_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 확인한 뒤 실제 관계를 남길지 판단하세요.&lt;br /&gt;
&lt;br /&gt;
=== 에이전트에게 작성을 맡길 때 ===&lt;br /&gt;
&lt;br /&gt;
쓰기 토큰이 있는 에이전트는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;create_lesson_draft&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;link_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;attach_evidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;record_evidence_verification&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;revise_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 쓸 수 있습니다. 에이전트가 만든 메모와 revision은 저장 즉시 검색됩니다. 상태 변경, 삭제, 이동, 관리자 작업은 MCP에서 할 수 없습니다. 다섯 쓰기 도구는 모두 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;operation_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 필요합니다. 같은 Lesson 안에서 일반 쓰기, revision, 검증 기록은 ID 충돌 범위를 공유합니다. 같은 종류·요청의 정확한 재시도일 때만 같은 ID와 같은 요청을 보내고, 다른 작업에는 새 ID를 사용합니다. Lesson 생성에는 구조화된 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;initial_evidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 필수이며 Lesson 본문과 같은 revision에 저장됩니다.&lt;br /&gt;
&lt;br /&gt;
자동화 작업·워크플로·CI·도구 호출의 성공·실패 상태, 재시도, 인증·연결 오류와 실행 로그 자체는 Lesson으로 저장하지 않습니다. 실패 자체가 연구 대상이고 단일 실행을 넘어 재사용할 원인과 적용 조건을 확인 가능한 근거로 일반화한 독립 주장일 때만 저장합니다.&lt;br /&gt;
&lt;br /&gt;
자료 위치와 확인한 사실을 먼저 제공하고 다음을 요청하세요.&lt;br /&gt;
&lt;br /&gt;
* 같은 메모가 있는지 먼저 검색&lt;br /&gt;
* 관찰과 해석을 분리&lt;br /&gt;
* 맞는 조건과 예외를 명시&lt;br /&gt;
* 자료를 추측하지 않기&lt;br /&gt;
* 검증 기록이 있다면 자료 digest·위치·범위·판정·연결 주장을 함께 확인&lt;br /&gt;
* 작업이 끝나면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Lesson:&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; ID 반환&lt;br /&gt;
* 여러 건을 바꿨다면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;audit_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 revision·내용·근거·관계를 확인&lt;br /&gt;
반환된 ID를 브라우저에서 열어 내용과 자료 위치를 확인합니다. 틀린 부분은 같은 ID의 새 revision으로 고칩니다. 출처만 추가할 때는 evidence, 독립된 새 주장일 때만 새 Lesson과 관계를 사용합니다. 감사는 저장된 구조, digest, 검증 수령 기록의 일관성을 확인하며 외부 자료의 사실 진위를 자동으로 판정하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
도구 입력과 응답 형식은 [[Help:S3 연구 메모리/MCP 클라이언트|MCP 클라이언트 안내]], 필드와 관계의 기계 계약은 [[Help:S3 연구 메모리/아키텍처|아키텍처]]에 있습니다.&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC&amp;diff=2724</id>
		<title>도움말:S3 연구 메모리</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC&amp;diff=2724"/>
		<updated>2026-07-19T02:07:46Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 저장소 문서 동기화&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;!-- S3RM managed repository document; edit the Markdown source, not this page. --&amp;gt;&lt;br /&gt;
&#039;&#039;이 페이지는 저장소 문서에서 자동으로 동기화됩니다. 위키에서 직접 편집하지 마세요.&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;onlyinclude&amp;gt;&lt;br /&gt;
S3 연구 메모리는 연구실이 함께 쓰는 지식 저장소입니다. 프로젝트가 끝난 뒤에도 시행착오를 다시 찾을 수 있게, 시도한 방법과 실제 결과를 짧게 기록합니다. 실험 원본이나 일정표는 여기 쌓지 않습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;/onlyinclude&amp;gt;&lt;br /&gt;
&lt;br /&gt;
위키 본문과 MCP 검색은 공개될 수 있습니다. 에이전트는 연구실 공용 토큰으로 메모 작성, 관계 연결, 근거 첨부, 근거 검증 기록 추가와 Lesson의 조건부 수정을 할 수 있습니다. 수정에는 기준 리비전과 기존 근거가 필요합니다. 삭제, 이동과 위키 관리 기능은 제공하지 않습니다. 저장한 메모는 바로 검색되고 모든 수정은 MediaWiki 이력에 남습니다.&lt;br /&gt;
&lt;br /&gt;
서버가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_WRITE_AUTH_MODE=open&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 운영되는 동안에는 다섯 가지 변경 도구를 토큰 없이 사용할 수 있습니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;github&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드는 GitHub 로그인으로 MCP 전체를 보호합니다. 공개 시험이 끝나면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;token&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 되돌립니다.&lt;br /&gt;
&lt;br /&gt;
=== 처음 사용하는 순서 ===&lt;br /&gt;
&lt;br /&gt;
# 서버 담당자에게 공용 쓰기 토큰을 받습니다.&lt;br /&gt;
# 아래에서 운영체제에 맞는 연결 문서를 열고 토큰을 먼저 저장합니다.&lt;br /&gt;
# 같은 문서의 에이전트 프롬프트를 사용하거나 클라이언트를 직접 연결합니다.&lt;br /&gt;
# 도구 열 개와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;search_lessons(limit=1)&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 응답을 확인합니다.&lt;br /&gt;
# [[#진행 중 프로젝트에서 메모 옮기기|진행 중 프로젝트에서 메모 옮기기]]를 사용합니다.&lt;br /&gt;
읽기 전용 연결을 기본 경로로 안내하지 않습니다. 메모를 남길 사람은 토큰까지 설정해 쓰기 가능한 상태로 연결하세요. 토큰은 채팅창, 프로젝트 파일과 Git에 붙여넣지 않습니다.&lt;br /&gt;
&lt;br /&gt;
=== 플랫폼별 연결 ===&lt;br /&gt;
&lt;br /&gt;
중앙 MCP 주소는 모든 플랫폼에서 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;https://s3wiki.yonsei.ac.kr/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;입니다. 각 PC에 저장소, Docker, 별도 위키나 로컬 MCP 서버를 만들지 않습니다.&lt;br /&gt;
&lt;br /&gt;
==== Linux ====&lt;br /&gt;
&lt;br /&gt;
[[Help:S3 연구 메모리/Linux 연결|Linux에서 연결]]&lt;br /&gt;
&lt;br /&gt;
==== macOS ====&lt;br /&gt;
&lt;br /&gt;
[[Help:S3 연구 메모리/macOS 연결|macOS에서 연결]]&lt;br /&gt;
&lt;br /&gt;
==== Windows ====&lt;br /&gt;
&lt;br /&gt;
[[Help:S3 연구 메모리/Windows 연결|Windows에서 연결]]&lt;br /&gt;
&lt;br /&gt;
Claude Code를 사용한다면 토큰을 저장한 뒤 다음 명령으로 사용자 범위에 등록합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
claude mcp add --transport http --scope user \&lt;br /&gt;
  s3-research-memory https://s3wiki.yonsei.ac.kr/mcp \&lt;br /&gt;
  --header &#039;Authorization: Bearer ${S3RM_MCP_TOKEN}&#039;&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
작은따옴표를 유지해야 토큰 값 대신 환경변수 참조가 저장됩니다. 등록 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;claude mcp get s3-research-memory&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 또는 Claude Code의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 확인하세요.&lt;br /&gt;
&lt;br /&gt;
각 문서는 다음 순서로 구성되어 있습니다.&lt;br /&gt;
&lt;br /&gt;
# 전달받은 토큰을 사용자 환경에 안전하게 저장&lt;br /&gt;
# 에이전트에게 현재 클라이언트 연결 맡기기&lt;br /&gt;
# Codex, Claude Code 또는 Cursor를 직접 설정&lt;br /&gt;
# 연결 확인&lt;br /&gt;
토큰 저장 블록을 실행하면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_TOKEN:&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 표시됩니다. 그 뒤에 서버 담당자에게 전달받은 토큰을 붙여넣고 Enter를 누르세요. 입력 중에는 토큰이 화면에 보이지 않습니다. Linux와 macOS 문서는 로그인 셸을 확인해 zsh 또는 Bash에 맞는 시작 파일을 자동으로 선택하므로 파일명을 직접 바꿀 필요가 없습니다.&lt;br /&gt;
&lt;br /&gt;
ChatGPT 웹, Claude 웹·Desktop과 조직 커넥터처럼 별도 인증 제약이 있는 제품은 [[Help:S3 연구 메모리/MCP 클라이언트|MCP 클라이언트 참고]]를 확인하세요.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
연구실에서 쓰는 여러 AI 클라이언트&lt;br /&gt;
              │&lt;br /&gt;
              ▼&lt;br /&gt;
   중앙 S3 Research Memory MCP&lt;br /&gt;
              │&lt;br /&gt;
              ▼&lt;br /&gt;
   하나의 Semantic MediaWiki&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 연결 확인 ===&lt;br /&gt;
&lt;br /&gt;
새 대화에서 다음처럼 요청합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
S3 Research Memory의 search_lessons를 빈 검색어와 limit=1로 실행해 줘.&lt;br /&gt;
id, title, confidence만 반환하고, 메모가 아직 없어도 새로 만들지는 마.&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
정상이라면 다음 열 개 도구가 보입니다.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 읽기 !! 쓰기&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;search_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;create_lesson_draft&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;get_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;link_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;audit_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;attach_evidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;find_related_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;record_evidence_verification&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;find_analogies&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;revise_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
검색 기본값은 기존 전체 텍스트·단어 겹침 방식인 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;legacy&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;입니다. 서버 담당자가 의미 검색을 켠 환경에서는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;search_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;나 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;find_analogies&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;retrieval_mode=hybrid_v1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 지정해 표현이 다른 후보도 함께 찾을 수 있습니다. 결과의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;effective_mode&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;semantic_status&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 실제 적용 여부를 확인하세요. 이 서버의 GPU에서 선택적으로 실행하는 번들 모델과 설정 방법은 [[Help:S3 연구 메모리/설치와 검증#선택적 의미 검색|설치·설정의 의미 검색]]에 있습니다.&lt;br /&gt;
&lt;br /&gt;
=== 진행 중 프로젝트에서 메모 옮기기 ===&lt;br /&gt;
&lt;br /&gt;
쓰기 연결을 마친 클라이언트에서 아래 프롬프트를 붙여넣으세요. 현재 확인할 수 있는 문제와 해결 방법만 골라 최대 다섯 건씩 옮깁니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
현재 대화, 이 클라이언트가 제공한 저장 메모리, 현재 작업공간과 명시적으로 첨부된&lt;br /&gt;
자료 중 실제로 접근할 수 있는 내용만 사용해, 다른 프로젝트에서도 도움이 될 문제와&lt;br /&gt;
해결 방법을 중앙 S3 Research Memory에 옮겨 줘.&lt;br /&gt;
&lt;br /&gt;
반드시 지킬 조건:&lt;br /&gt;
- S3 Research Memory의 MCP 도구만 사용한다. generic MediaWiki API, 별도 위키·DB,&lt;br /&gt;
  로컬 MCP 서버는 사용하거나 만들지 않는다.&lt;br /&gt;
- 프로젝트 소개, 진행 현황, 할 일, 일정, 회의록을 메모로 만들지 않는다.&lt;br /&gt;
  자동화 작업·워크플로·CI·도구 호출의 성공·실패 상태, 재시도, 인증·연결 오류와 실행 로그&lt;br /&gt;
  자체도 메모로 만들지 않는다. 실패 자체가 연구 대상이고 단일 실행을 넘어 재사용할&lt;br /&gt;
  원인과 적용 조건을 확인 가능한 근거로 일반화한 독립 주장일 때만 후보로 삼는다.&lt;br /&gt;
  한 가지 문제와 결론을 메모 한 건으로 만든다.&lt;br /&gt;
- 과거 대화나 저장 메모리를 실제로 읽을 수 없으면 읽었다고 가정하지 않는다. 접근한&lt;br /&gt;
  자료의 범위와 접근하지 못한 범위를 마지막에 구분해 적는다.&lt;br /&gt;
- 위키는 공개로 읽힐 수 있다. 비밀번호, 토큰, 개인 정보, 사적인 대화 원문, 개인의&lt;br /&gt;
  신상·관계·취향·업무 평가, 미공개 제3자 자료는 쓰지 않는다. 사람 이름 대신 필요한&lt;br /&gt;
  실제 역할만 쓴다. 비밀 파일은 읽지 않고, 원시 데이터와 큰 산출물은 원래 저장소에&lt;br /&gt;
  둔다. 공개 논문의 저자처럼 인용에 필요한 정보는 정확히 유지한다.&lt;br /&gt;
- 수치, 버전, 짧은 기술 표현은 출처가 뒷받침할 때만 정확히 보존한다. 경로, URL,&lt;br /&gt;
  체크섬, 날짜, 실험 결과나 인과관계를 추측해서 채우지 않는다.&lt;br /&gt;
&lt;br /&gt;
다음 순서로 처리한다:&lt;br /&gt;
1. 후보마다 핵심 메커니즘과 적용 조건으로 search_lessons를 먼저 실행한다.&lt;br /&gt;
   한 번에 최대 5건만 받고, 필요할 때만 find_related_lessons나&lt;br /&gt;
   find_analogies로 확인한다.&lt;br /&gt;
2. 같은 주장과 적용 범위의 메모가 있으면 새 메모를 만들지 않는다. 출처만 늘어나면&lt;br /&gt;
   attach_evidence를 사용한다. 기존 핵심 내용을 보강해야 하면 get_lesson으로 현재&lt;br /&gt;
   revision을 읽고 근거를 먼저 붙인다. 이어지는 revise_lesson에는 attach_evidence&lt;br /&gt;
   응답의 current_revision_id를 expected_revision_id로 사용한다. 그 사이 다른 변경이&lt;br /&gt;
   있었다면 get_lesson을 다시 읽고 변경안을 재계산한다.&lt;br /&gt;
3. 주장이 다르지만 관련 메모가 있으면 새 메모를 만든 뒤 supports, contradicts,&lt;br /&gt;
   analogous_to, failed_because, works_when, suggests, supersedes 중 정확한 관계만&lt;br /&gt;
   link_lessons로 연결한다. 관련성이 불명확하면 관계를 만들지 않는다.&lt;br /&gt;
4. 새 메모에는 고유한 operation_id, title, question, attempt, context, observation,&lt;br /&gt;
   interpretation, reusable_lesson, applicability, confidence, evidence, 구조화된&lt;br /&gt;
   initial_evidence와 필요하면 안정적인 lesson_id를 넣는다. observation에는 직접 확인한&lt;br /&gt;
   사실만 쓰고 interpretation에는 가능한 원인과 불확실성을 분리해 쓴다.&lt;br /&gt;
5. 다시 찾을 수 있는 인용이 최소 한 개 있을 때만 create_lesson_draft를 실행한다.&lt;br /&gt;
   initial_evidence는 Lesson과 같은 revision에 원자적으로 저장된다. 추가 인용만&lt;br /&gt;
   attach_evidence로 붙인다. 논문 근거에는 실제 확인 범위를&lt;br /&gt;
   full_text, official_abstract, partial_source, metadata_only 중 정확히 표시한다.&lt;br /&gt;
   확인 범위를 기록하지 못한 자료는 새 evidence로 붙이지 않으며, 기술 내용을&lt;br /&gt;
   추측하지 않는다. 근거가 기억뿐이거나 위치를 확인할 수 없으면 쓰지 말고&lt;br /&gt;
   &#039;근거 필요&#039; 후보로 남긴다. 실제 자료를 읽은 경우에만 자료 식별자,&lt;br /&gt;
   검사한 자료의 SHA-256, 위치, 확인 범위, 판정과 연결한 주장 필드를&lt;br /&gt;
   검증 기록으로 남긴다. high confidence는 full_text 또는 official_abstract의&lt;br /&gt;
   `supports` 검증이 observation, interpretation, reusable_lesson 세 필드를 모두&lt;br /&gt;
   뒷받침할 때만 쓴다.&lt;br /&gt;
6. 이번 실행에서는 생성 또는 변경한 메모를 합쳐 최대 5건까지만 처리한다. 그 이상은&lt;br /&gt;
   제목과 중단 이유만 남기고 위키를 변경하지 않는다.&lt;br /&gt;
- 다섯 쓰기 도구 모두 새 작업마다 고유한 operation_id를 쓰고, 결과가 불분명한 재시도에는&lt;br /&gt;
  원래 ID와 요청을 그대로 쓴다. 내부 호환 필드는 서버에 맡기며 삭제와 이동은 하지 않는다.&lt;br /&gt;
  같은 Lesson의 정정·보강은 revision으로 남기고, supersedes는 실제로 다른 주장이&lt;br /&gt;
  이전 주장을 대신할 때만 사용한다. evidence note나 Technical Review 중복 메모를&lt;br /&gt;
  본문 수정 대신 만들지 않는다.&lt;br /&gt;
&lt;br /&gt;
별도 확인 질문 없이 위 조건을 만족하는 항목만 처리해. 마지막에는 다음 순서로 짧게&lt;br /&gt;
보고해:&lt;br /&gt;
1. 실제로 확인한 자료 범위와 확인하지 못한 범위&lt;br /&gt;
2. 생성·근거 추가·검증 추가·관계 추가·중복으로 건너뜀을 구분한 Lesson ID와 제목&lt;br /&gt;
3. 위키에 쓰지 않은 &#039;근거 필요&#039; 후보와 빠진 정보&lt;br /&gt;
4. 접근 가능한 범위를 모두 확인했는지, 아니면 5건 제한 때문에 남은 후보가 있는지&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
생성·수정한 메모도 바로 검색됩니다. 반환된 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Lesson:&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; ID와 revision을 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;audit_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 확인하고, 위키에서 관찰과 해석이 섞이지 않았는지, 인용으로 원본을 다시 찾을 수 있는지, 공개하면 안 되는 내용이 없는지 확인하세요. 문제가 있으면 새 revision으로 고칩니다. 감사는 저장된 구조, digest, 검증 수령 기록의 일관성만 확인하며 외부 자료의 사실 진위를 자동으로 판정하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
=== 무엇을 남기나 ===&lt;br /&gt;
&lt;br /&gt;
각 메모에는 궁금했던 점, 해본 것, 당시 조건, 실제 결과, 가능한 원인, 다음에 기억할 것, 언제 맞는지, 신뢰도와 관련 자료를 적습니다. 관계는 다음 일곱 가지만 사용합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
supports, contradicts, analogous_to, failed_because,&lt;br /&gt;
works_when, suggests, supersedes&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
원시 로그, 대형 데이터셋과 실험 산출물 전체는 원래 저장소에 두고, 위키에는 다시 찾을 수 있는 URL·경로·체크섬과 해석을 기록합니다.&lt;br /&gt;
&lt;br /&gt;
=== 문서 ===&lt;br /&gt;
&lt;br /&gt;
사용자:&lt;br /&gt;
&lt;br /&gt;
* [[Help:S3 연구 메모리/Linux 연결|Linux 연결]]&lt;br /&gt;
* [[Help:S3 연구 메모리/macOS 연결|macOS 연결]]&lt;br /&gt;
* [[Help:S3 연구 메모리/Windows 연결|Windows 연결]]&lt;br /&gt;
* [[Help:S3 연구 메모리/MCP 클라이언트|제품별 MCP 참고]]&lt;br /&gt;
* [[Help:S3 연구 메모리/메모 사용법|메모 작성·검색·MCP 사용]]&lt;br /&gt;
* [[Help:S3 연구 메모리/문제 해결|문제 해결]]&lt;br /&gt;
서버 담당자:&lt;br /&gt;
&lt;br /&gt;
* [[Help:S3 연구 메모리/중앙 서버 관리|중앙 서버 관리]]&lt;br /&gt;
* [[Help:S3 연구 메모리/설치와 검증|설치와 검증]]&lt;br /&gt;
* [[Help:S3 연구 메모리/백업과 복원|백업·복원·업그레이드]]&lt;br /&gt;
* [[Help:S3 연구 메모리/보안|계정·비밀·네트워크 보안]]&lt;br /&gt;
* [[Help:S3 연구 메모리/아키텍처|데이터 계약과 아키텍처]]&lt;br /&gt;
* [[Help:S3 연구 메모리/벡터·하이브리드 검색 설계|벡터·하이브리드 검색 설계]]&lt;br /&gt;
기여:&lt;br /&gt;
&lt;br /&gt;
* [[Help:S3 연구 메모리/문체|사용자에게 보이는 한국어]]&lt;br /&gt;
* [[Help:S3 연구 메모리/파일럿 가정|파일럿 가정]]&lt;br /&gt;
* [[Help:S3 연구 메모리/참고문서|상위 프로젝트와 공식 참고문서]]&lt;br /&gt;
* 에이전트 기여 규칙 (&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;AGENTS.md&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;)&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%EB%AF%B8%EB%94%94%EC%96%B4%EC%9C%84%ED%82%A4:Common.js&amp;diff=2723</id>
		<title>미디어위키:Common.js</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%EB%AF%B8%EB%94%94%EC%96%B4%EC%9C%84%ED%82%A4:Common.js&amp;diff=2723"/>
		<updated>2026-07-19T02:07:46Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 원본 스키마 동기화&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;(function () {&lt;br /&gt;
  &#039;use strict&#039;;&lt;br /&gt;
&lt;br /&gt;
  var root = document.getElementById(&#039;s3rm-knowledge-graph&#039;);&lt;br /&gt;
  var semanticRoot = document.getElementById(&#039;s3rm-semantic-search&#039;);&lt;br /&gt;
  if (!root || !window.mw) {&lt;br /&gt;
    return;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  var RELATIONS = [&lt;br /&gt;
    { property: &#039;Supports&#039;, key: &#039;supports&#039;, label: &#039;뒷받침함&#039;, color: &#039;#5f7ae6&#039; },&lt;br /&gt;
    { property: &#039;Contradicts&#039;, key: &#039;contradicts&#039;, label: &#039;상충함&#039;, color: &#039;#d0605b&#039; },&lt;br /&gt;
    { property: &#039;Analogous to&#039;, key: &#039;analogous_to&#039;, label: &#039;유사함&#039;, color: &#039;#a17bd7&#039; },&lt;br /&gt;
    { property: &#039;Failed because&#039;, key: &#039;failed_because&#039;, label: &#039;실패 원인&#039;, color: &#039;#dc8b45&#039; },&lt;br /&gt;
    { property: &#039;Works when&#039;, key: &#039;works_when&#039;, label: &#039;작동 조건&#039;, color: &#039;#43a27b&#039; },&lt;br /&gt;
    { property: &#039;Suggests&#039;, key: &#039;suggests&#039;, label: &#039;후속 제안&#039;, color: &#039;#3f9db7&#039; },&lt;br /&gt;
    { property: &#039;Supersedes&#039;, key: &#039;supersedes&#039;, label: &#039;대체함&#039;, color: &#039;#7d849e&#039; }&lt;br /&gt;
  ];&lt;br /&gt;
  var NODE_LIMIT = 160;&lt;br /&gt;
  var EDGE_LIMIT = 480;&lt;br /&gt;
  var mcpReady = null;&lt;br /&gt;
  var graphSearchController = null;&lt;br /&gt;
  var pendingGraphSearch = null;&lt;br /&gt;
&lt;br /&gt;
  function mcpRequest(payload, expectBody) {&lt;br /&gt;
    return window.fetch(&#039;/mcp&#039;, {&lt;br /&gt;
      method: &#039;POST&#039;,&lt;br /&gt;
      credentials: &#039;same-origin&#039;,&lt;br /&gt;
      headers: {&lt;br /&gt;
        &#039;Content-Type&#039;: &#039;application/json&#039;,&lt;br /&gt;
        &#039;Accept&#039;: &#039;application/json, text/event-stream&#039;&lt;br /&gt;
      },&lt;br /&gt;
      body: JSON.stringify(payload)&lt;br /&gt;
    }).then(function (response) {&lt;br /&gt;
      if (!response.ok) {&lt;br /&gt;
        throw new Error(&#039;MCP HTTP &#039; + response.status);&lt;br /&gt;
      }&lt;br /&gt;
      if (!expectBody) {&lt;br /&gt;
        return null;&lt;br /&gt;
      }&lt;br /&gt;
      return response.json();&lt;br /&gt;
    });&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function initializeMcp() {&lt;br /&gt;
    if (!mcpReady) {&lt;br /&gt;
      mcpReady = mcpRequest({&lt;br /&gt;
        jsonrpc: &#039;2.0&#039;,&lt;br /&gt;
        id: 1,&lt;br /&gt;
        method: &#039;initialize&#039;,&lt;br /&gt;
        params: {&lt;br /&gt;
          protocolVersion: &#039;2025-06-18&#039;,&lt;br /&gt;
          capabilities: {},&lt;br /&gt;
          clientInfo: { name: &#039;s3rm-wiki-search&#039;, version: &#039;1&#039; }&lt;br /&gt;
        }&lt;br /&gt;
      }, true).then(function (response) {&lt;br /&gt;
        if (!response || response.error) {&lt;br /&gt;
          throw new Error(&#039;MCP initialization failed&#039;);&lt;br /&gt;
        }&lt;br /&gt;
        return mcpRequest({&lt;br /&gt;
          jsonrpc: &#039;2.0&#039;,&lt;br /&gt;
          method: &#039;notifications/initialized&#039;,&lt;br /&gt;
          params: {}&lt;br /&gt;
        }, false);&lt;br /&gt;
      });&lt;br /&gt;
    }&lt;br /&gt;
    return mcpReady;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function searchSemantically(query) {&lt;br /&gt;
    return initializeMcp().then(function () {&lt;br /&gt;
      return mcpRequest({&lt;br /&gt;
        jsonrpc: &#039;2.0&#039;,&lt;br /&gt;
        id: 2,&lt;br /&gt;
        method: &#039;tools/call&#039;,&lt;br /&gt;
        params: {&lt;br /&gt;
          name: &#039;search_lessons&#039;,&lt;br /&gt;
          arguments: {&lt;br /&gt;
            query: query,&lt;br /&gt;
            retrieval_mode: &#039;hybrid_v1&#039;,&lt;br /&gt;
            fields: [&#039;title&#039;, &#039;question&#039;, &#039;reusable_lesson&#039;, &#039;confidence&#039;, &#039;relations&#039;],&lt;br /&gt;
            limit: 6&lt;br /&gt;
          }&lt;br /&gt;
        }&lt;br /&gt;
      }, true);&lt;br /&gt;
    }).then(function (response) {&lt;br /&gt;
      if (!response || response.error || !response.result || response.result.isError) {&lt;br /&gt;
        throw new Error(&#039;Semantic search failed&#039;);&lt;br /&gt;
      }&lt;br /&gt;
      return response.result.structuredContent || {};&lt;br /&gt;
    });&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function textValue(value) {&lt;br /&gt;
    if (value === null || value === undefined) {&lt;br /&gt;
      return &#039;&#039;;&lt;br /&gt;
    }&lt;br /&gt;
    if (typeof value === &#039;string&#039; || typeof value === &#039;number&#039;) {&lt;br /&gt;
      return String(value);&lt;br /&gt;
    }&lt;br /&gt;
    return String(value.fulltext || value.displaytitle || value.raw || value.timestamp || &#039;&#039;);&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function firstValue(values, fallback) {&lt;br /&gt;
    return values &amp;amp;&amp;amp; values.length ? textValue(values[0]) : fallback;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function confidenceLabel(confidence) {&lt;br /&gt;
    if (confidence === &#039;high&#039;) { return &#039;신뢰도: 높음&#039;; }&lt;br /&gt;
    if (confidence === &#039;medium&#039;) { return &#039;신뢰도: 중간&#039;; }&lt;br /&gt;
    return &#039;신뢰도: 낮음&#039;;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function parseGraph(data) {&lt;br /&gt;
    var rawResults = data &amp;amp;&amp;amp; data.query &amp;amp;&amp;amp; data.query.results ? data.query.results : {};&lt;br /&gt;
    var entries = Array.isArray(rawResults)&lt;br /&gt;
      ? rawResults.map(function (item) { return [item.fulltext, item]; })&lt;br /&gt;
      : Object.keys(rawResults).map(function (key) { return [key, rawResults[key]]; });&lt;br /&gt;
    var nodes = [];&lt;br /&gt;
    var byId = Object.create(null);&lt;br /&gt;
&lt;br /&gt;
    entries.slice(0, NODE_LIMIT).forEach(function (entry, index) {&lt;br /&gt;
      var id = entry[0];&lt;br /&gt;
      var result = entry[1] || {};&lt;br /&gt;
      var printouts = result.printouts || {};&lt;br /&gt;
      var angle = index * 2.399963229728653;&lt;br /&gt;
      var radius = 38 * Math.sqrt(index + 1);&lt;br /&gt;
      var node = {&lt;br /&gt;
        id: id,&lt;br /&gt;
        title: firstValue(printouts[&#039;Lesson title&#039;], id.replace(/^Lesson:/, &#039;&#039;)),&lt;br /&gt;
        confidence: firstValue(printouts.Confidence, &#039;low&#039;).toLowerCase(),&lt;br /&gt;
        updatedAt: firstValue(printouts[&#039;Updated at&#039;], &#039;&#039;),&lt;br /&gt;
        x: Math.cos(angle) * radius,&lt;br /&gt;
        y: Math.sin(angle) * radius,&lt;br /&gt;
        vx: 0,&lt;br /&gt;
        vy: 0,&lt;br /&gt;
        degree: 0,&lt;br /&gt;
        printouts: printouts&lt;br /&gt;
      };&lt;br /&gt;
      nodes.push(node);&lt;br /&gt;
      byId[id] = node;&lt;br /&gt;
    });&lt;br /&gt;
&lt;br /&gt;
    var edges = [];&lt;br /&gt;
    nodes.forEach(function (node) {&lt;br /&gt;
      RELATIONS.forEach(function (relation) {&lt;br /&gt;
        var targets = node.printouts[relation.property] || [];&lt;br /&gt;
        targets.forEach(function (target) {&lt;br /&gt;
          var targetId = textValue(target);&lt;br /&gt;
          if (edges.length &amp;lt; EDGE_LIMIT &amp;amp;&amp;amp; byId[targetId] &amp;amp;&amp;amp; targetId !== node.id) {&lt;br /&gt;
            edges.push({ source: node, target: byId[targetId], relation: relation });&lt;br /&gt;
            node.degree += 1;&lt;br /&gt;
            byId[targetId].degree += 1;&lt;br /&gt;
          }&lt;br /&gt;
        });&lt;br /&gt;
      });&lt;br /&gt;
      delete node.printouts;&lt;br /&gt;
    });&lt;br /&gt;
    return { nodes: nodes, edges: edges };&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function makeElement(tag, className, text) {&lt;br /&gt;
    var element = document.createElement(tag);&lt;br /&gt;
    if (className) {&lt;br /&gt;
      element.className = className;&lt;br /&gt;
    }&lt;br /&gt;
    if (text !== undefined) {&lt;br /&gt;
      element.textContent = text;&lt;br /&gt;
    }&lt;br /&gt;
    return element;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function createSemanticSearch() {&lt;br /&gt;
    if (!semanticRoot) {&lt;br /&gt;
      return;&lt;br /&gt;
    }&lt;br /&gt;
    semanticRoot.replaceChildren();&lt;br /&gt;
    var intro = makeElement(&#039;div&#039;, &#039;s3rm-semantic-intro&#039;);&lt;br /&gt;
    intro.appendChild(makeElement(&#039;div&#039;, &#039;s3rm-semantic-icon&#039;, &#039;⌕&#039;));&lt;br /&gt;
    var introCopy = makeElement(&#039;div&#039;, &#039;&#039;);&lt;br /&gt;
    introCopy.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;검색&#039;));&lt;br /&gt;
    introCopy.appendChild(makeElement(&#039;p&#039;, &#039;&#039;, &#039;현상이나 궁금한 점을 문장으로 입력하세요. 표현이 달라도 관련 메모를 찾습니다.&#039;));&lt;br /&gt;
    intro.appendChild(introCopy);&lt;br /&gt;
&lt;br /&gt;
    var form = makeElement(&#039;form&#039;, &#039;s3rm-semantic-form&#039;);&lt;br /&gt;
    var input = makeElement(&#039;input&#039;, &#039;s3rm-semantic-input&#039;);&lt;br /&gt;
    input.type = &#039;search&#039;;&lt;br /&gt;
    input.name = &#039;query&#039;;&lt;br /&gt;
    input.maxLength = 500;&lt;br /&gt;
    input.placeholder = &#039;예: GPU 메모리는 남아 있는데 할당이 실패하는 이유는?&#039;;&lt;br /&gt;
    input.setAttribute(&#039;aria-label&#039;, &#039;메모 검색&#039;);&lt;br /&gt;
    var submit = makeElement(&#039;button&#039;, &#039;s3rm-semantic-submit&#039;, &#039;검색&#039;);&lt;br /&gt;
    submit.type = &#039;submit&#039;;&lt;br /&gt;
    form.append(input, submit);&lt;br /&gt;
&lt;br /&gt;
    var examples = makeElement(&#039;div&#039;, &#039;s3rm-semantic-examples&#039;);&lt;br /&gt;
    examples.appendChild(makeElement(&#039;span&#039;, &#039;&#039;, &#039;검색 예시&#039;));&lt;br /&gt;
    [&lt;br /&gt;
      &#039;재시도가 한꺼번에 몰리는 이유&#039;,&lt;br /&gt;
      &#039;GPU와 SSD 사이 병목을 줄이는 방법&#039;,&lt;br /&gt;
      &#039;긴 작업에서 지연이 갑자기 커지는 원인&#039;&lt;br /&gt;
    ].forEach(function (example) {&lt;br /&gt;
      var button = makeElement(&#039;button&#039;, &#039;&#039;, example);&lt;br /&gt;
      button.type = &#039;button&#039;;&lt;br /&gt;
      button.addEventListener(&#039;click&#039;, function () {&lt;br /&gt;
        input.value = example;&lt;br /&gt;
        form.requestSubmit();&lt;br /&gt;
      });&lt;br /&gt;
      examples.appendChild(button);&lt;br /&gt;
    });&lt;br /&gt;
&lt;br /&gt;
    var feedback = makeElement(&#039;div&#039;, &#039;s3rm-semantic-feedback&#039;);&lt;br /&gt;
    feedback.setAttribute(&#039;aria-live&#039;, &#039;polite&#039;);&lt;br /&gt;
    feedback.hidden = true;&lt;br /&gt;
    semanticRoot.append(intro, form, examples, feedback);&lt;br /&gt;
&lt;br /&gt;
    function renderSearchResults(payload, query) {&lt;br /&gt;
      var items = Array.isArray(payload.items) ? payload.items : [];&lt;br /&gt;
      if (graphSearchController) {&lt;br /&gt;
        graphSearchController(items, query);&lt;br /&gt;
      } else {&lt;br /&gt;
        pendingGraphSearch = { items: items, query: query };&lt;br /&gt;
      }&lt;br /&gt;
      feedback.replaceChildren();&lt;br /&gt;
      feedback.hidden = false;&lt;br /&gt;
      var resultHeader = makeElement(&#039;div&#039;, &#039;s3rm-semantic-result-header&#039;);&lt;br /&gt;
      resultHeader.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;“&#039; + query + &#039;” 검색 결과&#039;));&lt;br /&gt;
      resultHeader.appendChild(makeElement(&#039;span&#039;, &#039;s3rm-semantic-mode&#039;, items.length + &#039;개 메모&#039;));&lt;br /&gt;
      feedback.appendChild(resultHeader);&lt;br /&gt;
&lt;br /&gt;
      if (!items.length) {&lt;br /&gt;
        feedback.appendChild(makeElement(&#039;p&#039;, &#039;s3rm-semantic-empty&#039;, &#039;가까운 메모를 찾지 못했습니다. 표현을 조금 바꿔 다시 검색해 보세요.&#039;));&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      feedback.appendChild(makeElement(&#039;p&#039;, &#039;s3rm-semantic-graph-note&#039;, &#039;아래 그래프에 검색 결과를 모았습니다. 점을 선택하면 메모와 연결을 볼 수 있습니다.&#039;));&lt;br /&gt;
      var list = makeElement(&#039;div&#039;, &#039;s3rm-semantic-result-links&#039;);&lt;br /&gt;
      items.forEach(function (item) {&lt;br /&gt;
        var link = makeElement(&#039;a&#039;, &#039;&#039;, item.title || String(item.id).replace(/^Lesson:/, &#039;&#039;));&lt;br /&gt;
        link.href = mw.util.getUrl(item.id);&lt;br /&gt;
        list.appendChild(link);&lt;br /&gt;
      });&lt;br /&gt;
      feedback.appendChild(list);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    form.addEventListener(&#039;submit&#039;, function (event) {&lt;br /&gt;
      event.preventDefault();&lt;br /&gt;
      var query = input.value.trim();&lt;br /&gt;
      if (query.length &amp;lt; 2) {&lt;br /&gt;
        feedback.replaceChildren(makeElement(&#039;p&#039;, &#039;s3rm-semantic-error&#039;, &#039;두 글자 이상 입력해 주세요.&#039;));&lt;br /&gt;
        feedback.hidden = false;&lt;br /&gt;
        input.focus();&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      submit.disabled = true;&lt;br /&gt;
      submit.textContent = &#039;찾는 중…&#039;;&lt;br /&gt;
      feedback.replaceChildren(makeElement(&#039;div&#039;, &#039;s3rm-semantic-searching&#039;, &#039;관련 메모를 찾고 있습니다.&#039;));&lt;br /&gt;
      feedback.hidden = false;&lt;br /&gt;
      searchSemantically(query).then(function (payload) {&lt;br /&gt;
        renderSearchResults(payload, query);&lt;br /&gt;
      }).catch(function () {&lt;br /&gt;
        feedback.replaceChildren();&lt;br /&gt;
        var error = makeElement(&#039;div&#039;, &#039;s3rm-semantic-error&#039;);&lt;br /&gt;
        error.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;지금은 검색을 사용할 수 없습니다.&#039;));&lt;br /&gt;
        var fallback = makeElement(&#039;a&#039;, &#039;&#039;, &#039;검색 열기&#039;);&lt;br /&gt;
        fallback.href = mw.util.getUrl(&#039;Special:Search&#039;, { search: query });&lt;br /&gt;
        error.appendChild(fallback);&lt;br /&gt;
        feedback.appendChild(error);&lt;br /&gt;
      }).finally(function () {&lt;br /&gt;
        submit.disabled = false;&lt;br /&gt;
        submit.textContent = &#039;검색&#039;;&lt;br /&gt;
      });&lt;br /&gt;
    });&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function renderEmpty() {&lt;br /&gt;
    root.replaceChildren();&lt;br /&gt;
    var empty = makeElement(&#039;div&#039;, &#039;s3rm-graph-empty&#039;);&lt;br /&gt;
    empty.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;아직 연결할 메모가 없습니다&#039;));&lt;br /&gt;
    empty.appendChild(makeElement(&#039;p&#039;, &#039;&#039;, &#039;첫 메모를 남기면 이곳에서 지식의 연결을 볼 수 있습니다.&#039;));&lt;br /&gt;
    var link = makeElement(&#039;a&#039;, &#039;s3rm-graph-primary-link&#039;, &#039;첫 메모 작성하기&#039;);&lt;br /&gt;
    link.href = &#039;#quick-start&#039;;&lt;br /&gt;
    empty.appendChild(link);&lt;br /&gt;
    root.appendChild(empty);&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function renderError() {&lt;br /&gt;
    root.replaceChildren();&lt;br /&gt;
    var fallback = makeElement(&#039;div&#039;, &#039;s3rm-graph-empty&#039;);&lt;br /&gt;
    fallback.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;그래프를 불러오지 못했습니다&#039;));&lt;br /&gt;
    fallback.appendChild(makeElement(&#039;p&#039;, &#039;&#039;, &#039;최근 메모와 검색은 계속 사용할 수 있습니다. 잠시 뒤 다시 시도해 주세요.&#039;));&lt;br /&gt;
    var link = makeElement(&#039;a&#039;, &#039;s3rm-graph-primary-link&#039;, &#039;전체 메모 보기&#039;);&lt;br /&gt;
    link.href = mw.util.getUrl(&#039;Special:AllPages/Lesson:&#039;);&lt;br /&gt;
    fallback.appendChild(link);&lt;br /&gt;
    root.appendChild(fallback);&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function createGraph(graph) {&lt;br /&gt;
    var nodes = graph.nodes;&lt;br /&gt;
    var edges = graph.edges;&lt;br /&gt;
    var byId = Object.create(null);&lt;br /&gt;
    function canonicalId(id) {&lt;br /&gt;
      return String(id || &#039;&#039;).replace(/_/g, &#039; &#039;).toLocaleLowerCase(&#039;en&#039;);&lt;br /&gt;
    }&lt;br /&gt;
    nodes.forEach(function (node) { byId[canonicalId(node.id)] = node; });&lt;br /&gt;
    var selected = null;&lt;br /&gt;
    var hovered = null;&lt;br /&gt;
    var searchCluster = null;&lt;br /&gt;
    var activeRelation = &#039;all&#039;;&lt;br /&gt;
    var transform = { x: 0, y: 0, scale: 1 };&lt;br /&gt;
    var dragging = null;&lt;br /&gt;
    var panning = false;&lt;br /&gt;
    var pointerStart = null;&lt;br /&gt;
    var animationFrame = 0;&lt;br /&gt;
    var simulationTicks = 0;&lt;br /&gt;
&lt;br /&gt;
    root.replaceChildren();&lt;br /&gt;
    var toolbar = makeElement(&#039;div&#039;, &#039;s3rm-graph-toolbar&#039;);&lt;br /&gt;
    var searchWrap = makeElement(&#039;div&#039;, &#039;s3rm-graph-search&#039;);&lt;br /&gt;
    var search = makeElement(&#039;input&#039;, &#039;s3rm-graph-search-input&#039;);&lt;br /&gt;
    search.type = &#039;search&#039;;&lt;br /&gt;
    search.placeholder = &#039;그래프에서 제목 필터&#039;;&lt;br /&gt;
    search.setAttribute(&#039;aria-label&#039;, &#039;그래프 제목 필터&#039;);&lt;br /&gt;
    search.autocomplete = &#039;off&#039;;&lt;br /&gt;
    var searchResults = makeElement(&#039;div&#039;, &#039;s3rm-graph-search-results&#039;);&lt;br /&gt;
    searchResults.hidden = true;&lt;br /&gt;
    searchWrap.append(search, searchResults);&lt;br /&gt;
&lt;br /&gt;
    var filter = makeElement(&#039;select&#039;, &#039;s3rm-graph-filter&#039;);&lt;br /&gt;
    filter.setAttribute(&#039;aria-label&#039;, &#039;관계 유형 필터&#039;);&lt;br /&gt;
    var allOption = makeElement(&#039;option&#039;, &#039;&#039;, &#039;모든 관계&#039;);&lt;br /&gt;
    allOption.value = &#039;all&#039;;&lt;br /&gt;
    filter.appendChild(allOption);&lt;br /&gt;
    RELATIONS.forEach(function (relation) {&lt;br /&gt;
      var option = makeElement(&#039;option&#039;, &#039;&#039;, relation.label);&lt;br /&gt;
      option.value = relation.key;&lt;br /&gt;
      filter.appendChild(option);&lt;br /&gt;
    });&lt;br /&gt;
    var reset = makeElement(&#039;button&#039;, &#039;s3rm-graph-reset&#039;, &#039;화면 맞춤&#039;);&lt;br /&gt;
    reset.type = &#039;button&#039;;&lt;br /&gt;
    var clearSearch = makeElement(&#039;button&#039;, &#039;s3rm-graph-clear-search&#039;, &#039;검색 결과 해제&#039;);&lt;br /&gt;
    clearSearch.type = &#039;button&#039;;&lt;br /&gt;
    clearSearch.hidden = true;&lt;br /&gt;
    toolbar.append(searchWrap, filter, reset, clearSearch);&lt;br /&gt;
&lt;br /&gt;
    var body = makeElement(&#039;div&#039;, &#039;s3rm-graph-body&#039;);&lt;br /&gt;
    var viewport = makeElement(&#039;div&#039;, &#039;s3rm-graph-viewport&#039;);&lt;br /&gt;
    var canvas = document.createElement(&#039;canvas&#039;);&lt;br /&gt;
    canvas.className = &#039;s3rm-graph-canvas&#039;;&lt;br /&gt;
    canvas.setAttribute(&#039;aria-hidden&#039;, &#039;true&#039;);&lt;br /&gt;
    viewport.appendChild(canvas);&lt;br /&gt;
    var hint = makeElement(&#039;div&#039;, &#039;s3rm-graph-hint&#039;, &#039;드래그하여 이동 · 스크롤하여 확대 · 두 번 눌러 메모 열기&#039;);&lt;br /&gt;
    viewport.appendChild(hint);&lt;br /&gt;
&lt;br /&gt;
    var inspector = makeElement(&#039;aside&#039;, &#039;s3rm-graph-inspector&#039;);&lt;br /&gt;
    inspector.setAttribute(&#039;aria-live&#039;, &#039;polite&#039;);&lt;br /&gt;
    body.append(viewport, inspector);&lt;br /&gt;
&lt;br /&gt;
    var footer = makeElement(&#039;div&#039;, &#039;s3rm-graph-footer&#039;);&lt;br /&gt;
    var counts = makeElement(&#039;span&#039;, &#039;s3rm-graph-counts&#039;, nodes.length + &#039;개 메모 · &#039; + edges.length + &#039;개 연결&#039;);&lt;br /&gt;
    var legend = makeElement(&#039;div&#039;, &#039;s3rm-graph-legend&#039;);&lt;br /&gt;
    RELATIONS.slice(0, 4).forEach(function (relation) {&lt;br /&gt;
      var item = makeElement(&#039;span&#039;, &#039;s3rm-graph-legend-item&#039;);&lt;br /&gt;
      var swatch = makeElement(&#039;i&#039;, &#039;&#039;);&lt;br /&gt;
      swatch.style.backgroundColor = relation.color;&lt;br /&gt;
      item.append(swatch, document.createTextNode(relation.label));&lt;br /&gt;
      legend.appendChild(item);&lt;br /&gt;
    });&lt;br /&gt;
    footer.append(counts, legend);&lt;br /&gt;
    root.append(toolbar, body, footer);&lt;br /&gt;
&lt;br /&gt;
    var context = canvas.getContext(&#039;2d&#039;);&lt;br /&gt;
    var size = { width: 0, height: 0, ratio: 1 };&lt;br /&gt;
&lt;br /&gt;
    function visibleEdges() {&lt;br /&gt;
      return activeRelation === &#039;all&#039;&lt;br /&gt;
        ? edges&lt;br /&gt;
        : edges.filter(function (edge) { return edge.relation.key === activeRelation; });&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function nodeRadius(node) {&lt;br /&gt;
      return 6 + Math.min(8, Math.sqrt(node.degree) * 2.2) + (node.isSearchResult ? 3 : 0);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function confidenceColor(confidence) {&lt;br /&gt;
      if (confidence === &#039;high&#039;) { return &#039;#53c18b&#039;; }&lt;br /&gt;
      if (confidence === &#039;medium&#039;) { return &#039;#8ca8ff&#039;; }&lt;br /&gt;
      return &#039;#c0c5d8&#039;;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function resize() {&lt;br /&gt;
      var rect = viewport.getBoundingClientRect();&lt;br /&gt;
      size.width = Math.max(320, rect.width);&lt;br /&gt;
      size.height = Math.max(360, rect.height);&lt;br /&gt;
      size.ratio = Math.min(window.devicePixelRatio || 1, 2);&lt;br /&gt;
      canvas.width = Math.round(size.width * size.ratio);&lt;br /&gt;
      canvas.height = Math.round(size.height * size.ratio);&lt;br /&gt;
      canvas.style.width = size.width + &#039;px&#039;;&lt;br /&gt;
      canvas.style.height = size.height + &#039;px&#039;;&lt;br /&gt;
      draw();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function fitGraph() {&lt;br /&gt;
      if (!nodes.length) { return; }&lt;br /&gt;
      var xs = nodes.map(function (node) { return node.x; });&lt;br /&gt;
      var ys = nodes.map(function (node) { return node.y; });&lt;br /&gt;
      var minX = Math.min.apply(null, xs) - 50;&lt;br /&gt;
      var maxX = Math.max.apply(null, xs) + 50;&lt;br /&gt;
      var minY = Math.min.apply(null, ys) - 50;&lt;br /&gt;
      var maxY = Math.max.apply(null, ys) + 50;&lt;br /&gt;
      transform.scale = Math.max(0.25, Math.min(1.35, Math.min(&lt;br /&gt;
        size.width / Math.max(1, maxX - minX),&lt;br /&gt;
        size.height / Math.max(1, maxY - minY)&lt;br /&gt;
      )));&lt;br /&gt;
      transform.x = size.width / 2 - ((minX + maxX) / 2) * transform.scale;&lt;br /&gt;
      transform.y = size.height / 2 - ((minY + maxY) / 2) * transform.scale;&lt;br /&gt;
      draw();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function draw() {&lt;br /&gt;
      context.setTransform(size.ratio, 0, 0, size.ratio, 0, 0);&lt;br /&gt;
      context.clearRect(0, 0, size.width, size.height);&lt;br /&gt;
      context.save();&lt;br /&gt;
      context.translate(transform.x, transform.y);&lt;br /&gt;
      context.scale(transform.scale, transform.scale);&lt;br /&gt;
&lt;br /&gt;
      if (searchCluster) {&lt;br /&gt;
        context.beginPath();&lt;br /&gt;
        context.arc(&lt;br /&gt;
          searchCluster.x,&lt;br /&gt;
          searchCluster.y,&lt;br /&gt;
          searchCluster.radius + 48,&lt;br /&gt;
          0,&lt;br /&gt;
          Math.PI * 2&lt;br /&gt;
        );&lt;br /&gt;
        context.fillStyle = &#039;rgba(104, 126, 233, 0.12)&#039;;&lt;br /&gt;
        context.fill();&lt;br /&gt;
        context.setLineDash([7 / transform.scale, 6 / transform.scale]);&lt;br /&gt;
        context.lineWidth = 1.5 / transform.scale;&lt;br /&gt;
        context.strokeStyle = &#039;rgba(140, 168, 255, 0.75)&#039;;&lt;br /&gt;
        context.stroke();&lt;br /&gt;
        context.setLineDash([]);&lt;br /&gt;
        context.font = &#039;700 &#039; + (12 / transform.scale) + &#039;px sans-serif&#039;;&lt;br /&gt;
        context.textAlign = &#039;center&#039;;&lt;br /&gt;
        context.textBaseline = &#039;bottom&#039;;&lt;br /&gt;
        context.fillStyle = &#039;#b9c7ff&#039;;&lt;br /&gt;
        context.fillText(&lt;br /&gt;
          &#039;검색 결과 · &#039; + searchCluster.nodes.length + &#039;개 메모&#039;,&lt;br /&gt;
          searchCluster.x,&lt;br /&gt;
          searchCluster.y - searchCluster.radius - 58 / transform.scale&lt;br /&gt;
        );&lt;br /&gt;
      }&lt;br /&gt;
&lt;br /&gt;
      visibleEdges().forEach(function (edge) {&lt;br /&gt;
        var emphasized = selected &amp;amp;&amp;amp; (edge.source === selected || edge.target === selected);&lt;br /&gt;
        var searchEdge = searchCluster &amp;amp;&amp;amp;&lt;br /&gt;
          (edge.source.isSearchResult || edge.target.isSearchResult);&lt;br /&gt;
        context.globalAlpha = searchCluster &amp;amp;&amp;amp; !searchEdge ? 0.1 : 1;&lt;br /&gt;
        context.beginPath();&lt;br /&gt;
        context.moveTo(edge.source.x, edge.source.y);&lt;br /&gt;
        context.lineTo(edge.target.x, edge.target.y);&lt;br /&gt;
        context.strokeStyle = emphasized ? edge.relation.color : edge.relation.color + &#039;55&#039;;&lt;br /&gt;
        context.lineWidth = (emphasized || searchEdge ? 2.2 : 1) / transform.scale;&lt;br /&gt;
        context.stroke();&lt;br /&gt;
      });&lt;br /&gt;
      context.globalAlpha = 1;&lt;br /&gt;
&lt;br /&gt;
      nodes.forEach(function (node) {&lt;br /&gt;
        var isSelected = node === selected;&lt;br /&gt;
        var isHovered = node === hovered;&lt;br /&gt;
        var isSearchResult = Boolean(node.isSearchResult);&lt;br /&gt;
        var radius = nodeRadius(node);&lt;br /&gt;
        context.globalAlpha = searchCluster &amp;amp;&amp;amp; !isSearchResult ? 0.12 : 1;&lt;br /&gt;
        context.beginPath();&lt;br /&gt;
        context.arc(node.x, node.y, radius + (isSelected ? 4 : 0), 0, Math.PI * 2);&lt;br /&gt;
        context.fillStyle = isSelected || isSearchResult ? &#039;#ffffff&#039; : confidenceColor(node.confidence);&lt;br /&gt;
        context.fill();&lt;br /&gt;
        context.lineWidth = (isSelected || isSearchResult ? 3 : 1.5) / transform.scale;&lt;br /&gt;
        context.strokeStyle = isSelected || isSearchResult ? &#039;#8ca8ff&#039; : &#039;#11182e&#039;;&lt;br /&gt;
        context.stroke();&lt;br /&gt;
&lt;br /&gt;
        if (isSelected || isHovered || isSearchResult || (transform.scale &amp;gt; 1.05 &amp;amp;&amp;amp; node.degree &amp;gt; 1)) {&lt;br /&gt;
          context.font = (isSelected || isSearchResult ? &#039;600 &#039; : &#039;&#039;) + (12 / transform.scale) + &#039;px sans-serif&#039;;&lt;br /&gt;
          context.textAlign = &#039;center&#039;;&lt;br /&gt;
          context.textBaseline = &#039;top&#039;;&lt;br /&gt;
          context.fillStyle = &#039;#f5f7ff&#039;;&lt;br /&gt;
          context.shadowColor = &#039;#080b16&#039;;&lt;br /&gt;
          context.shadowBlur = 5 / transform.scale;&lt;br /&gt;
          var label = node.title.length &amp;gt; 26 ? node.title.slice(0, 25) + &#039;…&#039; : node.title;&lt;br /&gt;
          context.fillText(label, node.x, node.y + radius + 7 / transform.scale);&lt;br /&gt;
          context.shadowBlur = 0;&lt;br /&gt;
        }&lt;br /&gt;
      });&lt;br /&gt;
      context.globalAlpha = 1;&lt;br /&gt;
      context.restore();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function simulate() {&lt;br /&gt;
      if (simulationTicks &amp;gt; 220) {&lt;br /&gt;
        if (searchCluster) {&lt;br /&gt;
          draw();&lt;br /&gt;
        } else {&lt;br /&gt;
          fitGraph();&lt;br /&gt;
        }&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      for (var pass = 0; pass &amp;lt; 3; pass += 1) {&lt;br /&gt;
        for (var i = 0; i &amp;lt; nodes.length; i += 1) {&lt;br /&gt;
          for (var j = i + 1; j &amp;lt; nodes.length; j += 1) {&lt;br /&gt;
            var first = nodes[i];&lt;br /&gt;
            var second = nodes[j];&lt;br /&gt;
            var dx = second.x - first.x || 0.01;&lt;br /&gt;
            var dy = second.y - first.y || 0.01;&lt;br /&gt;
            var distanceSquared = dx * dx + dy * dy + 80;&lt;br /&gt;
            var force = 62 / distanceSquared;&lt;br /&gt;
            first.vx -= dx * force;&lt;br /&gt;
            first.vy -= dy * force;&lt;br /&gt;
            second.vx += dx * force;&lt;br /&gt;
            second.vy += dy * force;&lt;br /&gt;
          }&lt;br /&gt;
        }&lt;br /&gt;
        edges.forEach(function (edge) {&lt;br /&gt;
          var dx = edge.target.x - edge.source.x;&lt;br /&gt;
          var dy = edge.target.y - edge.source.y;&lt;br /&gt;
          var distance = Math.sqrt(dx * dx + dy * dy) || 1;&lt;br /&gt;
          var force = (distance - 105) * 0.00075;&lt;br /&gt;
          edge.source.vx += dx * force;&lt;br /&gt;
          edge.source.vy += dy * force;&lt;br /&gt;
          edge.target.vx -= dx * force;&lt;br /&gt;
          edge.target.vy -= dy * force;&lt;br /&gt;
        });&lt;br /&gt;
        nodes.forEach(function (node) {&lt;br /&gt;
          if (node.isSearchResult) {&lt;br /&gt;
            node.vx = 0;&lt;br /&gt;
            node.vy = 0;&lt;br /&gt;
            return;&lt;br /&gt;
          }&lt;br /&gt;
          node.vx += -node.x * 0.00035;&lt;br /&gt;
          node.vy += -node.y * 0.00035;&lt;br /&gt;
          node.vx *= 0.84;&lt;br /&gt;
          node.vy *= 0.84;&lt;br /&gt;
          node.x += node.vx;&lt;br /&gt;
          node.y += node.vy;&lt;br /&gt;
        });&lt;br /&gt;
        simulationTicks += 1;&lt;br /&gt;
      }&lt;br /&gt;
      draw();&lt;br /&gt;
      animationFrame = window.requestAnimationFrame(simulate);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function relationDefinition(key) {&lt;br /&gt;
      return RELATIONS.find(function (relation) { return relation.key === key; });&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function updateCounts() {&lt;br /&gt;
      if (searchCluster) {&lt;br /&gt;
        var relatedEdges = edges.filter(function (edge) {&lt;br /&gt;
          return edge.source.isSearchResult || edge.target.isSearchResult;&lt;br /&gt;
        }).length;&lt;br /&gt;
        counts.textContent = &#039;검색 결과 &#039; + searchCluster.nodes.length + &#039;개 · &#039; +&lt;br /&gt;
          relatedEdges + &#039;개 연결&#039;;&lt;br /&gt;
      } else {&lt;br /&gt;
        counts.textContent = nodes.length + &#039;개 메모 · &#039; + visibleEdges().length + &#039;개 연결&#039;;&lt;br /&gt;
      }&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function resetSearchCluster() {&lt;br /&gt;
      var addedKeys = Object.create(null);&lt;br /&gt;
      edges.forEach(function (edge) {&lt;br /&gt;
        if (edge.searchAdded) {&lt;br /&gt;
          edge.source.degree = Math.max(0, edge.source.degree - 1);&lt;br /&gt;
          edge.target.degree = Math.max(0, edge.target.degree - 1);&lt;br /&gt;
        }&lt;br /&gt;
      });&lt;br /&gt;
      nodes.forEach(function (node) {&lt;br /&gt;
        if (node.searchAdded) {&lt;br /&gt;
          addedKeys[canonicalId(node.id)] = true;&lt;br /&gt;
          delete byId[canonicalId(node.id)];&lt;br /&gt;
        } else if (node.preSearchPosition) {&lt;br /&gt;
          node.x = node.preSearchPosition.x;&lt;br /&gt;
          node.y = node.preSearchPosition.y;&lt;br /&gt;
        }&lt;br /&gt;
        delete node.isSearchResult;&lt;br /&gt;
        delete node.preSearchPosition;&lt;br /&gt;
      });&lt;br /&gt;
      nodes = nodes.filter(function (node) { return !node.searchAdded; });&lt;br /&gt;
      edges = edges.filter(function (edge) {&lt;br /&gt;
        return !edge.searchAdded &amp;amp;&amp;amp;&lt;br /&gt;
          !addedKeys[canonicalId(edge.source.id)] &amp;amp;&amp;amp;&lt;br /&gt;
          !addedKeys[canonicalId(edge.target.id)];&lt;br /&gt;
      });&lt;br /&gt;
      searchCluster = null;&lt;br /&gt;
      clearSearch.hidden = true;&lt;br /&gt;
      selected = null;&lt;br /&gt;
      showInspector(null);&lt;br /&gt;
      updateCounts();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function focusSearchResults(items, query) {&lt;br /&gt;
      resetSearchCluster();&lt;br /&gt;
      if (!items.length) {&lt;br /&gt;
        fitGraph();&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
&lt;br /&gt;
      var resultNodes = [];&lt;br /&gt;
      items.forEach(function (item, index) {&lt;br /&gt;
        var key = canonicalId(item.id);&lt;br /&gt;
        var node = byId[key];&lt;br /&gt;
        if (!node) {&lt;br /&gt;
          node = {&lt;br /&gt;
            id: item.id,&lt;br /&gt;
            title: item.title || String(item.id).replace(/^Lesson:/, &#039;&#039;),&lt;br /&gt;
            confidence: item.confidence || &#039;low&#039;,&lt;br /&gt;
            updatedAt: &#039;&#039;,&lt;br /&gt;
            x: 0,&lt;br /&gt;
            y: 0,&lt;br /&gt;
            vx: 0,&lt;br /&gt;
            vy: 0,&lt;br /&gt;
            degree: 0,&lt;br /&gt;
            searchAdded: true&lt;br /&gt;
          };&lt;br /&gt;
          nodes.push(node);&lt;br /&gt;
          byId[key] = node;&lt;br /&gt;
        } else {&lt;br /&gt;
          node.preSearchPosition = { x: node.x, y: node.y };&lt;br /&gt;
          node.title = item.title || node.title;&lt;br /&gt;
          node.confidence = item.confidence || node.confidence;&lt;br /&gt;
        }&lt;br /&gt;
        node.isSearchResult = true;&lt;br /&gt;
        node.searchRank = index + 1;&lt;br /&gt;
        resultNodes.push(node);&lt;br /&gt;
      });&lt;br /&gt;
&lt;br /&gt;
      items.forEach(function (item) {&lt;br /&gt;
        (Array.isArray(item.relations) ? item.relations : []).forEach(function (relationItem) {&lt;br /&gt;
          var source = byId[canonicalId(relationItem.from || item.id)];&lt;br /&gt;
          var target = byId[canonicalId(relationItem.to)];&lt;br /&gt;
          var relation = relationDefinition(relationItem.relation);&lt;br /&gt;
          if (!source || !target || !relation || source === target) {&lt;br /&gt;
            return;&lt;br /&gt;
          }&lt;br /&gt;
          var duplicate = edges.some(function (edge) {&lt;br /&gt;
            return canonicalId(edge.source.id) === canonicalId(source.id) &amp;amp;&amp;amp;&lt;br /&gt;
              canonicalId(edge.target.id) === canonicalId(target.id) &amp;amp;&amp;amp;&lt;br /&gt;
              edge.relation.key === relation.key;&lt;br /&gt;
          });&lt;br /&gt;
          if (!duplicate) {&lt;br /&gt;
            edges.push({&lt;br /&gt;
              source: source,&lt;br /&gt;
              target: target,&lt;br /&gt;
              relation: relation,&lt;br /&gt;
              searchAdded: true&lt;br /&gt;
            });&lt;br /&gt;
            source.degree += 1;&lt;br /&gt;
            target.degree += 1;&lt;br /&gt;
          }&lt;br /&gt;
        });&lt;br /&gt;
      });&lt;br /&gt;
&lt;br /&gt;
      var clusterRadius = resultNodes.length === 1 ? 0 :&lt;br /&gt;
        Math.max(82, Math.min(132, resultNodes.length * 22));&lt;br /&gt;
      resultNodes.forEach(function (node, index) {&lt;br /&gt;
        var angle = -Math.PI / 2 + (Math.PI * 2 * index / resultNodes.length);&lt;br /&gt;
        node.x = Math.cos(angle) * clusterRadius;&lt;br /&gt;
        node.y = Math.sin(angle) * clusterRadius;&lt;br /&gt;
        node.vx = 0;&lt;br /&gt;
        node.vy = 0;&lt;br /&gt;
      });&lt;br /&gt;
      searchCluster = {&lt;br /&gt;
        query: query,&lt;br /&gt;
        nodes: resultNodes,&lt;br /&gt;
        x: 0,&lt;br /&gt;
        y: 0,&lt;br /&gt;
        radius: clusterRadius&lt;br /&gt;
      };&lt;br /&gt;
      activeRelation = &#039;all&#039;;&lt;br /&gt;
      filter.value = &#039;all&#039;;&lt;br /&gt;
      clearSearch.hidden = false;&lt;br /&gt;
      transform.scale = Math.max(0.72, Math.min(&lt;br /&gt;
        1.35,&lt;br /&gt;
        Math.min(size.width, size.height) / Math.max(360, clusterRadius * 2 + 150)&lt;br /&gt;
      ));&lt;br /&gt;
      transform.x = size.width / 2;&lt;br /&gt;
      transform.y = size.height / 2;&lt;br /&gt;
      selectNode(resultNodes[0]);&lt;br /&gt;
      updateCounts();&lt;br /&gt;
      draw();&lt;br /&gt;
      viewport.scrollIntoView({ behavior: &#039;smooth&#039;, block: &#039;center&#039; });&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function connectionSummary(node) {&lt;br /&gt;
      var connected = edges.filter(function (edge) {&lt;br /&gt;
        return edge.source === node || edge.target === node;&lt;br /&gt;
      });&lt;br /&gt;
      var summary = Object.create(null);&lt;br /&gt;
      connected.forEach(function (edge) {&lt;br /&gt;
        summary[edge.relation.label] = (summary[edge.relation.label] || 0) + 1;&lt;br /&gt;
      });&lt;br /&gt;
      return { edges: connected, counts: summary };&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function showInspector(node) {&lt;br /&gt;
      inspector.replaceChildren();&lt;br /&gt;
      if (!node) {&lt;br /&gt;
        var welcome = makeElement(&#039;div&#039;, &#039;s3rm-inspector-welcome&#039;);&lt;br /&gt;
        welcome.appendChild(makeElement(&#039;span&#039;, &#039;s3rm-inspector-symbol&#039;, &#039;⌁&#039;));&lt;br /&gt;
        welcome.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;메모를 선택하세요&#039;));&lt;br /&gt;
        welcome.appendChild(makeElement(&#039;p&#039;, &#039;&#039;, &#039;점 하나를 누르면 메모 정보와 연결된 기록을 보여 드립니다.&#039;));&lt;br /&gt;
        inspector.appendChild(welcome);&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      var summary = connectionSummary(node);&lt;br /&gt;
      inspector.appendChild(makeElement(&lt;br /&gt;
        &#039;div&#039;,&lt;br /&gt;
        &#039;s3rm-inspector-kicker&#039;,&lt;br /&gt;
        node.isSearchResult ? &#039;검색 결과 &#039; + node.searchRank : &#039;선택한 메모&#039;&lt;br /&gt;
      ));&lt;br /&gt;
      inspector.appendChild(makeElement(&#039;h3&#039;, &#039;&#039;, node.title));&lt;br /&gt;
      var meta = makeElement(&#039;div&#039;, &#039;s3rm-inspector-meta&#039;);&lt;br /&gt;
      meta.appendChild(makeElement(&#039;span&#039;, &#039;s3rm-confidence s3rm-confidence-&#039; + node.confidence, confidenceLabel(node.confidence)));&lt;br /&gt;
      meta.appendChild(makeElement(&#039;span&#039;, &#039;&#039;, summary.edges.length + &#039;개 연결&#039;));&lt;br /&gt;
      inspector.appendChild(meta);&lt;br /&gt;
      if (Object.keys(summary.counts).length) {&lt;br /&gt;
        var relationList = makeElement(&#039;ul&#039;, &#039;s3rm-inspector-relations&#039;);&lt;br /&gt;
        Object.keys(summary.counts).forEach(function (label) {&lt;br /&gt;
          var item = makeElement(&#039;li&#039;, &#039;&#039;);&lt;br /&gt;
          item.append(makeElement(&#039;span&#039;, &#039;&#039;, label), makeElement(&#039;strong&#039;, &#039;&#039;, String(summary.counts[label])));&lt;br /&gt;
          relationList.appendChild(item);&lt;br /&gt;
        });&lt;br /&gt;
        inspector.appendChild(relationList);&lt;br /&gt;
      } else {&lt;br /&gt;
        inspector.appendChild(makeElement(&#039;p&#039;, &#039;s3rm-inspector-muted&#039;, &#039;아직 다른 메모와 연결되지 않았습니다.&#039;));&lt;br /&gt;
      }&lt;br /&gt;
      var open = makeElement(&#039;a&#039;, &#039;s3rm-graph-primary-link&#039;, &#039;메모 열기&#039;);&lt;br /&gt;
      open.href = mw.util.getUrl(node.id);&lt;br /&gt;
      inspector.appendChild(open);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function canvasPoint(event) {&lt;br /&gt;
      var rect = canvas.getBoundingClientRect();&lt;br /&gt;
      return { x: event.clientX - rect.left, y: event.clientY - rect.top };&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function worldPoint(event) {&lt;br /&gt;
      var point = canvasPoint(event);&lt;br /&gt;
      return {&lt;br /&gt;
        x: (point.x - transform.x) / transform.scale,&lt;br /&gt;
        y: (point.y - transform.y) / transform.scale&lt;br /&gt;
      };&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function hitNode(event) {&lt;br /&gt;
      var point = worldPoint(event);&lt;br /&gt;
      for (var i = nodes.length - 1; i &amp;gt;= 0; i -= 1) {&lt;br /&gt;
        var node = nodes[i];&lt;br /&gt;
        var dx = point.x - node.x;&lt;br /&gt;
        var dy = point.y - node.y;&lt;br /&gt;
        if (dx * dx + dy * dy &amp;lt;= Math.pow(nodeRadius(node) + 6 / transform.scale, 2)) {&lt;br /&gt;
          return node;&lt;br /&gt;
        }&lt;br /&gt;
      }&lt;br /&gt;
      return null;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function selectNode(node) {&lt;br /&gt;
      selected = node;&lt;br /&gt;
      showInspector(node);&lt;br /&gt;
      draw();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    canvas.addEventListener(&#039;pointerdown&#039;, function (event) {&lt;br /&gt;
      dragging = hitNode(event);&lt;br /&gt;
      panning = !dragging;&lt;br /&gt;
      pointerStart = canvasPoint(event);&lt;br /&gt;
      canvas.setPointerCapture(event.pointerId);&lt;br /&gt;
      canvas.classList.add(&#039;is-dragging&#039;);&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;pointermove&#039;, function (event) {&lt;br /&gt;
      if (dragging) {&lt;br /&gt;
        var world = worldPoint(event);&lt;br /&gt;
        dragging.x = world.x;&lt;br /&gt;
        dragging.y = world.y;&lt;br /&gt;
        dragging.vx = 0;&lt;br /&gt;
        dragging.vy = 0;&lt;br /&gt;
      } else if (panning &amp;amp;&amp;amp; pointerStart) {&lt;br /&gt;
        var point = canvasPoint(event);&lt;br /&gt;
        transform.x += point.x - pointerStart.x;&lt;br /&gt;
        transform.y += point.y - pointerStart.y;&lt;br /&gt;
        pointerStart = point;&lt;br /&gt;
      } else {&lt;br /&gt;
        hovered = hitNode(event);&lt;br /&gt;
        canvas.style.cursor = hovered ? &#039;pointer&#039; : &#039;grab&#039;;&lt;br /&gt;
      }&lt;br /&gt;
      draw();&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;pointerup&#039;, function (event) {&lt;br /&gt;
      var hit = hitNode(event);&lt;br /&gt;
      if (dragging &amp;amp;&amp;amp; hit === dragging) {&lt;br /&gt;
        selectNode(dragging);&lt;br /&gt;
      }&lt;br /&gt;
      dragging = null;&lt;br /&gt;
      panning = false;&lt;br /&gt;
      pointerStart = null;&lt;br /&gt;
      canvas.classList.remove(&#039;is-dragging&#039;);&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;dblclick&#039;, function (event) {&lt;br /&gt;
      var node = hitNode(event);&lt;br /&gt;
      if (node) {&lt;br /&gt;
        window.location.href = mw.util.getUrl(node.id);&lt;br /&gt;
      }&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;wheel&#039;, function (event) {&lt;br /&gt;
      event.preventDefault();&lt;br /&gt;
      var point = canvasPoint(event);&lt;br /&gt;
      var oldScale = transform.scale;&lt;br /&gt;
      var nextScale = Math.max(0.25, Math.min(3.5, oldScale * Math.exp(-event.deltaY * 0.0012)));&lt;br /&gt;
      transform.x = point.x - ((point.x - transform.x) / oldScale) * nextScale;&lt;br /&gt;
      transform.y = point.y - ((point.y - transform.y) / oldScale) * nextScale;&lt;br /&gt;
      transform.scale = nextScale;&lt;br /&gt;
      draw();&lt;br /&gt;
    }, { passive: false });&lt;br /&gt;
&lt;br /&gt;
    search.addEventListener(&#039;input&#039;, function () {&lt;br /&gt;
      var query = search.value.trim().toLocaleLowerCase(&#039;ko&#039;);&lt;br /&gt;
      searchResults.replaceChildren();&lt;br /&gt;
      if (!query) {&lt;br /&gt;
        searchResults.hidden = true;&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      var matches = nodes.filter(function (node) {&lt;br /&gt;
        return node.title.toLocaleLowerCase(&#039;ko&#039;).includes(query) || node.id.toLowerCase().includes(query);&lt;br /&gt;
      }).slice(0, 6);&lt;br /&gt;
      matches.forEach(function (node) {&lt;br /&gt;
        var button = makeElement(&#039;button&#039;, &#039;&#039;, node.title);&lt;br /&gt;
        button.type = &#039;button&#039;;&lt;br /&gt;
        button.addEventListener(&#039;click&#039;, function () {&lt;br /&gt;
          selectNode(node);&lt;br /&gt;
          transform.x = size.width / 2 - node.x * transform.scale;&lt;br /&gt;
          transform.y = size.height / 2 - node.y * transform.scale;&lt;br /&gt;
          search.value = node.title;&lt;br /&gt;
          searchResults.hidden = true;&lt;br /&gt;
          draw();&lt;br /&gt;
        });&lt;br /&gt;
        searchResults.appendChild(button);&lt;br /&gt;
      });&lt;br /&gt;
      if (!matches.length) {&lt;br /&gt;
        searchResults.appendChild(makeElement(&#039;span&#039;, &#039;&#039;, &#039;일치하는 메모가 없습니다.&#039;));&lt;br /&gt;
      }&lt;br /&gt;
      searchResults.hidden = false;&lt;br /&gt;
    });&lt;br /&gt;
    search.addEventListener(&#039;keydown&#039;, function (event) {&lt;br /&gt;
      if (event.key === &#039;Escape&#039;) {&lt;br /&gt;
        searchResults.hidden = true;&lt;br /&gt;
      } else if (event.key === &#039;Enter&#039;) {&lt;br /&gt;
        var first = searchResults.querySelector(&#039;button&#039;);&lt;br /&gt;
        if (first) { first.click(); }&lt;br /&gt;
      }&lt;br /&gt;
    });&lt;br /&gt;
    filter.addEventListener(&#039;change&#039;, function () {&lt;br /&gt;
      activeRelation = filter.value;&lt;br /&gt;
      updateCounts();&lt;br /&gt;
      draw();&lt;br /&gt;
    });&lt;br /&gt;
    reset.addEventListener(&#039;click&#039;, fitGraph);&lt;br /&gt;
    clearSearch.addEventListener(&#039;click&#039;, function () {&lt;br /&gt;
      resetSearchCluster();&lt;br /&gt;
      fitGraph();&lt;br /&gt;
    });&lt;br /&gt;
&lt;br /&gt;
    showInspector(null);&lt;br /&gt;
    resize();&lt;br /&gt;
    var resizeObserver = window.ResizeObserver ? new ResizeObserver(resize) : null;&lt;br /&gt;
    if (resizeObserver) {&lt;br /&gt;
      resizeObserver.observe(viewport);&lt;br /&gt;
    } else {&lt;br /&gt;
      window.addEventListener(&#039;resize&#039;, resize);&lt;br /&gt;
    }&lt;br /&gt;
    graphSearchController = focusSearchResults;&lt;br /&gt;
    if (pendingGraphSearch) {&lt;br /&gt;
      focusSearchResults(pendingGraphSearch.items, pendingGraphSearch.query);&lt;br /&gt;
      pendingGraphSearch = null;&lt;br /&gt;
    }&lt;br /&gt;
    animationFrame = window.requestAnimationFrame(simulate);&lt;br /&gt;
    window.addEventListener(&#039;pagehide&#039;, function () {&lt;br /&gt;
      window.cancelAnimationFrame(animationFrame);&lt;br /&gt;
      if (resizeObserver) { resizeObserver.disconnect(); }&lt;br /&gt;
    }, { once: true });&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  var askQuery = &#039;[[Category:Lessons]]&#039; +&lt;br /&gt;
    &#039;|?Lesson title|?Confidence|?Updated at&#039; +&lt;br /&gt;
    RELATIONS.map(function (relation) { return &#039;|?&#039; + relation.property; }).join(&#039;&#039;) +&lt;br /&gt;
    &#039;|sort=Updated at|order=descending|limit=&#039; + NODE_LIMIT;&lt;br /&gt;
&lt;br /&gt;
  createSemanticSearch();&lt;br /&gt;
&lt;br /&gt;
  mw.loader.using([&#039;mediawiki.api&#039;, &#039;mediawiki.util&#039;]).then(function () {&lt;br /&gt;
    return new mw.Api().get({ action: &#039;ask&#039;, query: askQuery, formatversion: 2 });&lt;br /&gt;
  }).then(function (data) {&lt;br /&gt;
    var graph = parseGraph(data);&lt;br /&gt;
    if (!graph.nodes.length) {&lt;br /&gt;
      renderEmpty();&lt;br /&gt;
      return;&lt;br /&gt;
    }&lt;br /&gt;
    createGraph(graph);&lt;br /&gt;
  }).catch(renderError);&lt;br /&gt;
}());&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%EB%AF%B8%EB%94%94%EC%96%B4%EC%9C%84%ED%82%A4:Common.css&amp;diff=2722</id>
		<title>미디어위키:Common.css</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%EB%AF%B8%EB%94%94%EC%96%B4%EC%9C%84%ED%82%A4:Common.css&amp;diff=2722"/>
		<updated>2026-07-19T02:07:46Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 원본 스키마 동기화&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;:root {&lt;br /&gt;
  --s3-navy: #1d2040;&lt;br /&gt;
  --s3-navy-deep: #171933;&lt;br /&gt;
  --s3-indigo: #3e42d1;&lt;br /&gt;
  --s3-indigo-dark: #393c9f;&lt;br /&gt;
  --s3-sky: #8eb2ee;&lt;br /&gt;
  --s3-paper: #f0f0f0;&lt;br /&gt;
  --s3-canvas: #f7f7fa;&lt;br /&gt;
  --s3-text: #212121;&lt;br /&gt;
  --s3-muted: #626579;&lt;br /&gt;
  --s3-green: #2f6b4f;&lt;br /&gt;
  --s3-amber: #8a6a1f;&lt;br /&gt;
  --s3-amber-bg: #f6eed6;&lt;br /&gt;
  --s3-line: #d9dbe7;&lt;br /&gt;
  --s3-motion: 150ms ease;&lt;br /&gt;
  --s3-font-sans: &amp;quot;Noto Sans KR&amp;quot;, &amp;quot;Apple SD Gothic Neo&amp;quot;, &amp;quot;Malgun Gothic&amp;quot;, Montserrat, Arial, sans-serif;&lt;br /&gt;
  --s3-font-serif: &amp;quot;Noto Serif KR&amp;quot;, &amp;quot;Nanum Myeongjo&amp;quot;, Bitter, Georgia, serif;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
::selection {&lt;br /&gt;
  background: var(--s3-sky);&lt;br /&gt;
  color: var(--s3-navy-deep);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
html {&lt;br /&gt;
  scroll-behavior: smooth;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
body,&lt;br /&gt;
.mw-body-content {&lt;br /&gt;
  color: var(--s3-text);&lt;br /&gt;
  font-family: var(--s3-font-sans);&lt;br /&gt;
  font-size: 17px;&lt;br /&gt;
  line-height: 1.72;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
body {&lt;br /&gt;
  background: var(--s3-canvas);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
a,&lt;br /&gt;
.mw-body-content a:visited {&lt;br /&gt;
  color: var(--s3-indigo-dark);&lt;br /&gt;
  transition: color var(--s3-motion);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
a:hover {&lt;br /&gt;
  color: var(--s3-indigo);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
a:focus-visible,&lt;br /&gt;
button:focus-visible,&lt;br /&gt;
input:focus-visible,&lt;br /&gt;
textarea:focus-visible,&lt;br /&gt;
select:focus-visible {&lt;br /&gt;
  outline: 3px solid var(--s3-indigo);&lt;br /&gt;
  outline-offset: 2px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.vector-header-container,&lt;br /&gt;
.skin-vector-2022 .mw-header {&lt;br /&gt;
  background: var(--s3-navy);&lt;br /&gt;
  color: #fff;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.vector-header-container a,&lt;br /&gt;
.vector-header-container .vector-menu-heading,&lt;br /&gt;
.vector-header-container .vector-dropdown-label {&lt;br /&gt;
  color: #fff;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.vector-header-container .mw-logo-wordmark,&lt;br /&gt;
.vector-header-container .mw-logo-tagline,&lt;br /&gt;
.vector-header-container .mw-ui-icon::before {&lt;br /&gt;
  filter: brightness(0) invert(1);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.vector-header-container .mw-logo-icon {&lt;br /&gt;
  filter: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.vector-header-container .mw-logo-wordmark {&lt;br /&gt;
  color: #fff;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.vector-header-container .cdx-text-input__input,&lt;br /&gt;
.vector-header-container input {&lt;br /&gt;
  background: #fff;&lt;br /&gt;
  color: var(--s3-text);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-page-container {&lt;br /&gt;
  background: #fff;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-body-header {&lt;br /&gt;
  background: var(--s3-paper);&lt;br /&gt;
  border: 0;&lt;br /&gt;
  padding: 1.6rem clamp(1.25rem, 4vw, 4.4rem);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-body h1,&lt;br /&gt;
.mw-body h2,&lt;br /&gt;
.mw-body h3,&lt;br /&gt;
.mw-body h4,&lt;br /&gt;
.mw-body-header .firstHeading {&lt;br /&gt;
  color: var(--s3-text);&lt;br /&gt;
  font-family: var(--s3-font-sans);&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-body h2 {&lt;br /&gt;
  border-bottom: 1px solid var(--s3-line);&lt;br /&gt;
  font-size: clamp(1.4rem, 2.2vw, 1.75rem);&lt;br /&gt;
  padding-bottom: 0.45rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-body h3 {&lt;br /&gt;
  font-size: 1.18rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.page-Main_Page .mw-body-content,&lt;br /&gt;
.page-연구_메모리 .mw-body-content {&lt;br /&gt;
  font-size: 16px;&lt;br /&gt;
  line-height: 1.65;&lt;br /&gt;
  margin-top: 1.2rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.page-Main_Page .mw-body-header,&lt;br /&gt;
.page-연구_메모리 .mw-body-header {&lt;br /&gt;
  background: #fff;&lt;br /&gt;
  border-bottom: 1px solid var(--s3-line);&lt;br /&gt;
  margin: 0 auto;&lt;br /&gt;
  max-width: 960px;&lt;br /&gt;
  padding: 1.1rem 0 0.85rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.page-Main_Page .mw-body-header .firstHeading,&lt;br /&gt;
.page-연구_메모리 .mw-body-header .firstHeading {&lt;br /&gt;
  font-size: clamp(1.8rem, 3vw, 2.15rem);&lt;br /&gt;
  line-height: 1.3;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.page-Main_Page .mw-body-content &amp;gt; .mw-parser-output,&lt;br /&gt;
.page-연구_메모리 .mw-body-content &amp;gt; .mw-parser-output {&lt;br /&gt;
  margin: 0 auto;&lt;br /&gt;
  max-width: 960px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-home-intro {&lt;br /&gt;
  border-bottom: 1px solid var(--s3-line);&lt;br /&gt;
  border-top: 3px solid var(--s3-navy);&lt;br /&gt;
  padding: 1.15rem 0 1.25rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-home-intro p {&lt;br /&gt;
  color: var(--s3-navy-deep);&lt;br /&gt;
  font-family: var(--s3-font-serif);&lt;br /&gt;
  font-size: clamp(1.12rem, 1.8vw, 1.32rem);&lt;br /&gt;
  line-height: 1.6;&lt;br /&gt;
  margin: 0;&lt;br /&gt;
  max-width: 760px;&lt;br /&gt;
  text-wrap: pretty;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-content-section {&lt;br /&gt;
  margin: 0 auto 3.25rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-home-actions {&lt;br /&gt;
  display: grid;&lt;br /&gt;
  grid-template-columns: repeat(3, minmax(0, 1fr));&lt;br /&gt;
  border-bottom: 1px solid var(--s3-line);&lt;br /&gt;
  margin-bottom: 3.25rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-action-card {&lt;br /&gt;
  border-right: 1px solid var(--s3-line);&lt;br /&gt;
  box-shadow: inset 0 0 0 0 transparent;&lt;br /&gt;
  min-width: 0;&lt;br /&gt;
  padding: 1.05rem 1.35rem 1.15rem;&lt;br /&gt;
  transition: box-shadow var(--s3-motion);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-action-card:hover {&lt;br /&gt;
  box-shadow: inset 0 3px 0 -1px var(--s3-indigo);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-action-card:first-child {&lt;br /&gt;
  padding-left: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-action-card:last-child {&lt;br /&gt;
  border-right: 0;&lt;br /&gt;
  padding-right: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-action-title {&lt;br /&gt;
  font-family: var(--s3-font-sans);&lt;br /&gt;
  font-size: 1rem;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  margin-bottom: 0.3rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-action-title a {&lt;br /&gt;
  color: var(--s3-indigo-dark);&lt;br /&gt;
  text-decoration-thickness: 1px;&lt;br /&gt;
  text-underline-offset: 0.2em;&lt;br /&gt;
  transition: text-decoration-thickness var(--s3-motion),&lt;br /&gt;
    text-underline-offset var(--s3-motion);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-action-card:hover .s3rm-action-title a {&lt;br /&gt;
  text-decoration-thickness: 2px;&lt;br /&gt;
  text-underline-offset: 0.24em;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-action-card p {&lt;br /&gt;
  color: var(--s3-muted);&lt;br /&gt;
  font-size: 0.9rem;&lt;br /&gt;
  line-height: 1.55;&lt;br /&gt;
  margin: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-section-header {&lt;br /&gt;
  align-items: baseline;&lt;br /&gt;
  border-bottom: 1px solid var(--s3-line);&lt;br /&gt;
  display: flex;&lt;br /&gt;
  gap: 1rem;&lt;br /&gt;
  justify-content: space-between;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-section-header h2 {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  border: 0;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  font-size: 1.35rem;&lt;br /&gt;
  gap: 0.55rem;&lt;br /&gt;
  margin: 0;&lt;br /&gt;
  padding: 0 0 0.65rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-section-header h2::before {&lt;br /&gt;
  background: var(--s3-indigo);&lt;br /&gt;
  content: &amp;quot;&amp;quot;;&lt;br /&gt;
  height: 1.1em;&lt;br /&gt;
  width: 4px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-section-link {&lt;br /&gt;
  font-size: 0.88rem;&lt;br /&gt;
  white-space: nowrap;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-recent-list {&lt;br /&gt;
  border-bottom: 1px solid var(--s3-line);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-lesson-summary {&lt;br /&gt;
  background: #fff;&lt;br /&gt;
  border-bottom: 1px solid var(--s3-line);&lt;br /&gt;
  box-shadow: inset 0 0 0 0 transparent;&lt;br /&gt;
  margin: 0 -0.9rem;&lt;br /&gt;
  padding: 0.85rem 0.9rem;&lt;br /&gt;
  transition: background var(--s3-motion), box-shadow var(--s3-motion);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-lesson-summary:hover {&lt;br /&gt;
  background: var(--s3-canvas);&lt;br /&gt;
  box-shadow: inset 3px 0 0 0 var(--s3-indigo);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-lesson-summary:last-child {&lt;br /&gt;
  border-bottom: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-lesson-summary-title {&lt;br /&gt;
  font-family: var(--s3-font-sans);&lt;br /&gt;
  font-size: 1rem;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  line-height: 1.45;&lt;br /&gt;
  margin: 0 0 0.45rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-lesson-summary-title a {&lt;br /&gt;
  color: var(--s3-navy);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-lesson-summary-meta {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  color: var(--s3-muted);&lt;br /&gt;
  display: flex;&lt;br /&gt;
  flex-wrap: wrap;&lt;br /&gt;
  font-size: 0.82rem;&lt;br /&gt;
  gap: 0.45rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-empty-state {&lt;br /&gt;
  background: var(--s3-canvas);&lt;br /&gt;
  border: 1px dashed var(--s3-line);&lt;br /&gt;
  color: var(--s3-muted);&lt;br /&gt;
  font-size: 0.92rem;&lt;br /&gt;
  padding: 1.4rem 1rem;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-create-panel {&lt;br /&gt;
  border-bottom: 1px solid var(--s3-line);&lt;br /&gt;
  border-top: 1px solid var(--s3-line);&lt;br /&gt;
  display: grid;&lt;br /&gt;
  gap: 2.25rem;&lt;br /&gt;
  grid-template-columns: minmax(0, 1fr) minmax(300px, 0.9fr);&lt;br /&gt;
  padding: 1.4rem 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-create-panel h2 {&lt;br /&gt;
  border: 0;&lt;br /&gt;
  font-size: 1.35rem;&lt;br /&gt;
  margin-top: 0;&lt;br /&gt;
  padding: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-form-launcher {&lt;br /&gt;
  background: var(--s3-paper);&lt;br /&gt;
  min-width: 0;&lt;br /&gt;
  padding: 1rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-form-label {&lt;br /&gt;
  color: var(--s3-navy);&lt;br /&gt;
  font-family: var(--s3-font-sans);&lt;br /&gt;
  font-size: 0.86rem;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  margin-bottom: 0.45rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-form-launcher .pfFormInputWrapper {&lt;br /&gt;
  max-width: 100%;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-form-launcher .oo-ui-horizontalLayout {&lt;br /&gt;
  display: flex;&lt;br /&gt;
  flex-wrap: wrap;&lt;br /&gt;
  gap: 0.5rem;&lt;br /&gt;
  max-width: 100%;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-form-launcher .oo-ui-horizontalLayout &amp;gt; .oo-ui-widget {&lt;br /&gt;
  margin-right: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-form-launcher .pfPageNameWithoutNamespace {&lt;br /&gt;
  flex: 1 1 14rem;&lt;br /&gt;
  min-width: 0;&lt;br /&gt;
  width: auto !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-form-launcher input[type=&amp;quot;text&amp;quot;] {&lt;br /&gt;
  box-sizing: border-box;&lt;br /&gt;
  max-width: 100%;&lt;br /&gt;
  min-width: 0;&lt;br /&gt;
  width: 100% !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-form-note {&lt;br /&gt;
  background: #f5f5f8;&lt;br /&gt;
  border-left: 4px solid var(--s3-indigo);&lt;br /&gt;
  margin: 1.2rem 0 1.8rem;&lt;br /&gt;
  padding: 1rem 1.2rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-confidence,&lt;br /&gt;
.s3rm-kind,&lt;br /&gt;
.s3rm-relation-label {&lt;br /&gt;
  background: #ececf5;&lt;br /&gt;
  color: var(--s3-navy);&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  font-family: var(--s3-font-sans);&lt;br /&gt;
  font-size: 0.78rem;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  line-height: 1.4;&lt;br /&gt;
  padding: 0.2rem 0.55rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-confidence-high { background: #dceee5; color: var(--s3-green); }&lt;br /&gt;
.s3rm-confidence-low { background: #f4e6e3; color: #8a3f34; }&lt;br /&gt;
&lt;br /&gt;
.s3rm-lesson-meta {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  border-bottom: 1px solid var(--s3-line);&lt;br /&gt;
  display: flex;&lt;br /&gt;
  flex-wrap: wrap;&lt;br /&gt;
  gap: 0.55rem;&lt;br /&gt;
  margin-bottom: 1.5rem;&lt;br /&gt;
  padding-bottom: 1rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-lesson-actions {&lt;br /&gt;
  display: flex;&lt;br /&gt;
  flex-wrap: wrap;&lt;br /&gt;
  gap: 0.55rem;&lt;br /&gt;
  margin: -0.55rem 0 1.4rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-lesson-actions a {&lt;br /&gt;
  border: 1px solid var(--s3-indigo-dark);&lt;br /&gt;
  color: var(--s3-indigo-dark);&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  font-size: 0.86rem;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  padding: 0.42rem 0.7rem;&lt;br /&gt;
  text-decoration: none;&lt;br /&gt;
  transition: background var(--s3-motion), color var(--s3-motion);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-lesson-actions a:hover {&lt;br /&gt;
  background: var(--s3-indigo-dark);&lt;br /&gt;
  color: #fff;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-lesson-table,&lt;br /&gt;
.formtable,&lt;br /&gt;
.smwtable {&lt;br /&gt;
  border-collapse: collapse;&lt;br /&gt;
  width: 100%;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.formtable {&lt;br /&gt;
  table-layout: fixed;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-lesson-table th,&lt;br /&gt;
.s3rm-lesson-table td,&lt;br /&gt;
.formtable th,&lt;br /&gt;
.formtable td {&lt;br /&gt;
  border: 0;&lt;br /&gt;
  border-bottom: 1px solid var(--s3-line);&lt;br /&gt;
  padding: 0.85rem 0.75rem;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-lesson-table th,&lt;br /&gt;
.formtable th {&lt;br /&gt;
  color: var(--s3-navy);&lt;br /&gt;
  font-family: var(--s3-font-sans);&lt;br /&gt;
  font-size: 0.86rem;&lt;br /&gt;
  min-width: 12rem;&lt;br /&gt;
  text-align: left;&lt;br /&gt;
  width: 12rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-evidence {&lt;br /&gt;
  background: #f7f7fa;&lt;br /&gt;
  border-left: 4px solid var(--s3-indigo);&lt;br /&gt;
  margin: 0.75rem 0;&lt;br /&gt;
  padding: 0.85rem 1rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-relation {&lt;br /&gt;
  border-bottom: 1px solid var(--s3-line);&lt;br /&gt;
  display: block;&lt;br /&gt;
  margin: 0.35rem 0;&lt;br /&gt;
  padding: 0.55rem 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.pfFormInputWrapper input,&lt;br /&gt;
.createboxInput,&lt;br /&gt;
.formtable input[type=&amp;quot;text&amp;quot;],&lt;br /&gt;
.formtable textarea,&lt;br /&gt;
.formtable select {&lt;br /&gt;
  border: 1px solid #a7a9ba;&lt;br /&gt;
  box-sizing: border-box;&lt;br /&gt;
  font-family: var(--s3-font-sans);&lt;br /&gt;
  max-width: 100%;&lt;br /&gt;
  padding: 0.55rem 0.65rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.formtable input[type=&amp;quot;text&amp;quot;],&lt;br /&gt;
.formtable textarea {&lt;br /&gt;
  width: 100%;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.oo-ui-buttonElement-framed.oo-ui-widget-enabled &amp;gt; .oo-ui-buttonElement-button,&lt;br /&gt;
.cdx-button--action-progressive.cdx-button--weight-primary {&lt;br /&gt;
  background: var(--s3-indigo);&lt;br /&gt;
  border-color: var(--s3-indigo);&lt;br /&gt;
  color: #fff;&lt;br /&gt;
  min-height: 44px;&lt;br /&gt;
  transition: background var(--s3-motion);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.oo-ui-buttonElement-framed.oo-ui-widget-enabled &amp;gt; .oo-ui-buttonElement-button:hover,&lt;br /&gt;
.cdx-button--action-progressive.cdx-button--weight-primary:hover {&lt;br /&gt;
  background: var(--s3-indigo-dark);&lt;br /&gt;
  border-color: var(--s3-indigo-dark);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Knowledge graph home */&lt;br /&gt;
.page-Main_Page .mw-body-header,&lt;br /&gt;
.page-연구_메모리 .mw-body-header,&lt;br /&gt;
.page-Main_Page .mw-body-content &amp;gt; .mw-parser-output,&lt;br /&gt;
.page-연구_메모리 .mw-body-content &amp;gt; .mw-parser-output {&lt;br /&gt;
  max-width: 1440px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.page-Main_Page .mw-body-content,&lt;br /&gt;
.page-연구_메모리 .mw-body-content {&lt;br /&gt;
  margin-top: 0.75rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-home-intro {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  border: 0;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  min-height: 2.7rem;&lt;br /&gt;
  padding: 0 0 0.75rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-home-intro p {&lt;br /&gt;
  color: var(--s3-muted);&lt;br /&gt;
  font-family: var(--s3-font-sans);&lt;br /&gt;
  font-size: 0.94rem;&lt;br /&gt;
  line-height: 1.55;&lt;br /&gt;
  max-width: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-eyebrow {&lt;br /&gt;
  color: var(--s3-indigo);&lt;br /&gt;
  font-size: 0.7rem;&lt;br /&gt;
  font-weight: 800;&lt;br /&gt;
  letter-spacing: 0.06em;&lt;br /&gt;
  line-height: 1.4;&lt;br /&gt;
  margin-bottom: 0.35rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-section {&lt;br /&gt;
  background: #fff;&lt;br /&gt;
  border: 1px solid var(--s3-line);&lt;br /&gt;
  box-shadow: 0 16px 42px rgba(23, 25, 51, 0.09);&lt;br /&gt;
  color: var(--s3-text);&lt;br /&gt;
  margin: 0 0 3.2rem;&lt;br /&gt;
  overflow: hidden;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-heading {&lt;br /&gt;
  align-items: flex-end;&lt;br /&gt;
  border-bottom: 1px solid var(--s3-line);&lt;br /&gt;
  display: flex;&lt;br /&gt;
  gap: 2rem;&lt;br /&gt;
  justify-content: space-between;&lt;br /&gt;
  padding: 1.35rem 1.5rem 1.2rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-heading .s3rm-eyebrow {&lt;br /&gt;
  color: var(--s3-indigo);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-heading h2 {&lt;br /&gt;
  border: 0;&lt;br /&gt;
  color: var(--s3-navy);&lt;br /&gt;
  font-size: clamp(1.25rem, 2vw, 1.65rem);&lt;br /&gt;
  line-height: 1.35;&lt;br /&gt;
  margin: 0 0 0.35rem;&lt;br /&gt;
  padding: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-heading p {&lt;br /&gt;
  color: var(--s3-muted);&lt;br /&gt;
  font-size: 0.88rem;&lt;br /&gt;
  line-height: 1.5;&lt;br /&gt;
  margin: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-shortcuts {&lt;br /&gt;
  display: flex;&lt;br /&gt;
  flex: 0 0 auto;&lt;br /&gt;
  gap: 0.55rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-shortcuts a {&lt;br /&gt;
  border: 1px solid var(--s3-indigo-dark);&lt;br /&gt;
  color: var(--s3-indigo-dark) !important;&lt;br /&gt;
  font-size: 0.8rem;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  padding: 0.42rem 0.72rem;&lt;br /&gt;
  text-decoration: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-shortcuts a:last-child {&lt;br /&gt;
  background: var(--s3-indigo);&lt;br /&gt;
  border-color: var(--s3-indigo);&lt;br /&gt;
  color: #fff !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-search {&lt;br /&gt;
  background: var(--s3-canvas);&lt;br /&gt;
  border-bottom: 1px solid var(--s3-line);&lt;br /&gt;
  padding: 1.1rem 1.5rem 1.2rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-search-loading {&lt;br /&gt;
  color: var(--s3-muted);&lt;br /&gt;
  font-size: 0.82rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-intro {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  gap: 0.75rem;&lt;br /&gt;
  margin-bottom: 0.8rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-icon {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  background: #ececf5;&lt;br /&gt;
  border: 1px solid var(--s3-line);&lt;br /&gt;
  color: var(--s3-indigo);&lt;br /&gt;
  display: flex;&lt;br /&gt;
  flex: 0 0 2.35rem;&lt;br /&gt;
  font-size: 1.35rem;&lt;br /&gt;
  height: 2.35rem;&lt;br /&gt;
  justify-content: center;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-intro strong {&lt;br /&gt;
  color: var(--s3-navy);&lt;br /&gt;
  display: block;&lt;br /&gt;
  font-size: 0.92rem;&lt;br /&gt;
  margin-bottom: 0.1rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-intro p {&lt;br /&gt;
  color: var(--s3-muted);&lt;br /&gt;
  font-size: 0.75rem;&lt;br /&gt;
  line-height: 1.45;&lt;br /&gt;
  margin: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-form {&lt;br /&gt;
  display: flex;&lt;br /&gt;
  gap: 0.55rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-input {&lt;br /&gt;
  background: #fff;&lt;br /&gt;
  border: 1px solid #a7a9ba;&lt;br /&gt;
  border-radius: 0;&lt;br /&gt;
  box-sizing: border-box;&lt;br /&gt;
  color: var(--s3-text);&lt;br /&gt;
  flex: 1 1 auto;&lt;br /&gt;
  font-family: var(--s3-font-sans);&lt;br /&gt;
  font-size: 0.93rem;&lt;br /&gt;
  height: 3rem;&lt;br /&gt;
  min-width: 0;&lt;br /&gt;
  padding: 0 0.95rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-input::placeholder {&lt;br /&gt;
  color: #777b8f;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-submit {&lt;br /&gt;
  background: var(--s3-indigo);&lt;br /&gt;
  border: 1px solid var(--s3-indigo);&lt;br /&gt;
  border-radius: 0;&lt;br /&gt;
  color: #fff;&lt;br /&gt;
  cursor: pointer;&lt;br /&gt;
  flex: 0 0 auto;&lt;br /&gt;
  font-family: var(--s3-font-sans);&lt;br /&gt;
  font-size: 0.84rem;&lt;br /&gt;
  font-weight: 800;&lt;br /&gt;
  min-width: 7rem;&lt;br /&gt;
  padding: 0 1rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-submit:hover {&lt;br /&gt;
  background: var(--s3-indigo-dark);&lt;br /&gt;
  border-color: var(--s3-indigo-dark);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-submit:disabled {&lt;br /&gt;
  cursor: wait;&lt;br /&gt;
  opacity: 0.7;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-examples {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  flex-wrap: wrap;&lt;br /&gt;
  gap: 0.4rem;&lt;br /&gt;
  margin-top: 0.55rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-examples &amp;gt; span {&lt;br /&gt;
  color: var(--s3-muted);&lt;br /&gt;
  font-size: 0.67rem;&lt;br /&gt;
  margin-right: 0.1rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-examples button {&lt;br /&gt;
  background: #fff;&lt;br /&gt;
  border: 1px solid var(--s3-line);&lt;br /&gt;
  border-radius: 1rem;&lt;br /&gt;
  color: var(--s3-indigo-dark);&lt;br /&gt;
  cursor: pointer;&lt;br /&gt;
  font-family: var(--s3-font-sans);&lt;br /&gt;
  font-size: 0.67rem;&lt;br /&gt;
  padding: 0.25rem 0.55rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-examples button:hover {&lt;br /&gt;
  background: #ececf5;&lt;br /&gt;
  border-color: #b9bcce;&lt;br /&gt;
  color: var(--s3-navy);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-feedback {&lt;br /&gt;
  border-top: 1px solid var(--s3-line);&lt;br /&gt;
  margin-top: 0.9rem;&lt;br /&gt;
  padding-top: 0.9rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-result-header {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  gap: 1rem;&lt;br /&gt;
  justify-content: space-between;&lt;br /&gt;
  margin-bottom: 0.6rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-result-header &amp;gt; strong {&lt;br /&gt;
  color: var(--s3-navy);&lt;br /&gt;
  font-size: 0.78rem;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-mode {&lt;br /&gt;
  color: var(--s3-indigo-dark);&lt;br /&gt;
  font-size: 0.67rem;&lt;br /&gt;
  white-space: nowrap;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-graph-note {&lt;br /&gt;
  color: var(--s3-muted);&lt;br /&gt;
  font-size: 0.72rem;&lt;br /&gt;
  line-height: 1.5;&lt;br /&gt;
  margin: -0.1rem 0 0.55rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-result-links {&lt;br /&gt;
  display: flex;&lt;br /&gt;
  flex-wrap: wrap;&lt;br /&gt;
  gap: 0.35rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-result-links a {&lt;br /&gt;
  background: #fff;&lt;br /&gt;
  border: 1px solid var(--s3-line);&lt;br /&gt;
  color: var(--s3-indigo-dark) !important;&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  font-size: 0.65rem;&lt;br /&gt;
  line-height: 1.35;&lt;br /&gt;
  max-width: 15rem;&lt;br /&gt;
  overflow: hidden;&lt;br /&gt;
  padding: 0.28rem 0.5rem;&lt;br /&gt;
  text-decoration: none;&lt;br /&gt;
  text-overflow: ellipsis;&lt;br /&gt;
  white-space: nowrap;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-result-links a:hover {&lt;br /&gt;
  background: #ececf5;&lt;br /&gt;
  border-color: var(--s3-indigo);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-results {&lt;br /&gt;
  display: grid;&lt;br /&gt;
  gap: 0.5rem;&lt;br /&gt;
  grid-template-columns: repeat(3, minmax(0, 1fr));&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-result {&lt;br /&gt;
  background: #fff;&lt;br /&gt;
  border: 1px solid var(--s3-line);&lt;br /&gt;
  box-sizing: border-box;&lt;br /&gt;
  color: var(--s3-navy) !important;&lt;br /&gt;
  min-width: 0;&lt;br /&gt;
  padding: 0.75rem;&lt;br /&gt;
  text-decoration: none;&lt;br /&gt;
  transition: background var(--s3-motion), border-color var(--s3-motion);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-result:hover {&lt;br /&gt;
  background: #f1f2f7;&lt;br /&gt;
  border-color: var(--s3-indigo);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-result-top {&lt;br /&gt;
  align-items: flex-start;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  gap: 0.55rem;&lt;br /&gt;
  justify-content: space-between;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-result-top &amp;gt; strong {&lt;br /&gt;
  font-size: 0.76rem;&lt;br /&gt;
  line-height: 1.4;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-result .s3rm-confidence {&lt;br /&gt;
  flex: 0 0 auto;&lt;br /&gt;
  font-size: 0.58rem;&lt;br /&gt;
  padding: 0.12rem 0.35rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-result p {&lt;br /&gt;
  color: var(--s3-muted);&lt;br /&gt;
  display: -webkit-box;&lt;br /&gt;
  font-size: 0.68rem;&lt;br /&gt;
  -webkit-box-orient: vertical;&lt;br /&gt;
  -webkit-line-clamp: 2;&lt;br /&gt;
  line-height: 1.45;&lt;br /&gt;
  margin: 0.45rem 0 0;&lt;br /&gt;
  overflow: hidden;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-searching,&lt;br /&gt;
.s3rm-semantic-empty,&lt;br /&gt;
.s3rm-semantic-error {&lt;br /&gt;
  color: var(--s3-muted);&lt;br /&gt;
  font-size: 0.76rem;&lt;br /&gt;
  margin: 0;&lt;br /&gt;
  padding: 0.45rem 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-error {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  gap: 0.7rem;&lt;br /&gt;
  justify-content: space-between;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-error a {&lt;br /&gt;
  color: var(--s3-indigo-dark) !important;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-knowledge-graph {&lt;br /&gt;
  min-height: 590px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-loading,&lt;br /&gt;
.s3rm-graph-empty {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  flex-direction: column;&lt;br /&gt;
  justify-content: center;&lt;br /&gt;
  min-height: 590px;&lt;br /&gt;
  padding: 2rem;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-loading {&lt;br /&gt;
  color: #d7dcf2;&lt;br /&gt;
  gap: 0.25rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-loading &amp;gt; span:last-child {&lt;br /&gt;
  color: #8e96b4;&lt;br /&gt;
  font-size: 0.82rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-loading-mark {&lt;br /&gt;
  animation: s3rm-graph-pulse 1.4s ease-in-out infinite;&lt;br /&gt;
  background: #8ca8ff;&lt;br /&gt;
  border-radius: 50%;&lt;br /&gt;
  box-shadow: 18px 8px 0 -2px #53c18b, -18px 11px 0 -3px #a17bd7;&lt;br /&gt;
  display: block;&lt;br /&gt;
  height: 12px;&lt;br /&gt;
  margin: 0 0 1.25rem;&lt;br /&gt;
  width: 12px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
@keyframes s3rm-graph-pulse {&lt;br /&gt;
  0%, 100% { opacity: 0.45; transform: scale(0.92); }&lt;br /&gt;
  50% { opacity: 1; transform: scale(1.08); }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-toolbar {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  background: #151a2f;&lt;br /&gt;
  border-bottom: 1px solid #292f4b;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  gap: 0.6rem;&lt;br /&gt;
  padding: 0.7rem 0.85rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-search {&lt;br /&gt;
  flex: 1 1 19rem;&lt;br /&gt;
  max-width: 30rem;&lt;br /&gt;
  position: relative;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-search-input,&lt;br /&gt;
.s3rm-graph-filter,&lt;br /&gt;
.s3rm-graph-reset,&lt;br /&gt;
.s3rm-graph-clear-search {&lt;br /&gt;
  background: #0f1324;&lt;br /&gt;
  border: 1px solid #353c5d;&lt;br /&gt;
  border-radius: 0;&lt;br /&gt;
  box-sizing: border-box;&lt;br /&gt;
  color: #edf0ff;&lt;br /&gt;
  font-family: var(--s3-font-sans);&lt;br /&gt;
  font-size: 0.82rem;&lt;br /&gt;
  height: 2.35rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-search-input {&lt;br /&gt;
  padding: 0 0.8rem 0 2.25rem;&lt;br /&gt;
  width: 100%;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-search::before {&lt;br /&gt;
  color: #7d87aa;&lt;br /&gt;
  content: &amp;quot;⌕&amp;quot;;&lt;br /&gt;
  font-size: 1.2rem;&lt;br /&gt;
  left: 0.75rem;&lt;br /&gt;
  line-height: 1;&lt;br /&gt;
  pointer-events: none;&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  top: 0.55rem;&lt;br /&gt;
  z-index: 2;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-search-input::placeholder {&lt;br /&gt;
  color: #7d87aa;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-filter,&lt;br /&gt;
.s3rm-graph-reset,&lt;br /&gt;
.s3rm-graph-clear-search {&lt;br /&gt;
  padding: 0 0.75rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-reset,&lt;br /&gt;
.s3rm-graph-clear-search {&lt;br /&gt;
  cursor: pointer;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-reset:hover,&lt;br /&gt;
.s3rm-graph-clear-search:hover {&lt;br /&gt;
  background: #252c4a;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-clear-search {&lt;br /&gt;
  background: #263464;&lt;br /&gt;
  border-color: #6378d9;&lt;br /&gt;
  color: #fff;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-search-results {&lt;br /&gt;
  background: #181e35;&lt;br /&gt;
  border: 1px solid #3d456a;&lt;br /&gt;
  box-shadow: 0 12px 28px rgba(0, 0, 0, 0.34);&lt;br /&gt;
  left: 0;&lt;br /&gt;
  max-height: 17rem;&lt;br /&gt;
  overflow-y: auto;&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  right: 0;&lt;br /&gt;
  top: calc(100% + 0.35rem);&lt;br /&gt;
  z-index: 5;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-search-results button,&lt;br /&gt;
.s3rm-graph-search-results &amp;gt; span {&lt;br /&gt;
  background: transparent;&lt;br /&gt;
  border: 0;&lt;br /&gt;
  border-bottom: 1px solid #2d3452;&lt;br /&gt;
  box-sizing: border-box;&lt;br /&gt;
  color: #edf0ff;&lt;br /&gt;
  display: block;&lt;br /&gt;
  font-family: var(--s3-font-sans);&lt;br /&gt;
  font-size: 0.82rem;&lt;br /&gt;
  padding: 0.7rem 0.8rem;&lt;br /&gt;
  text-align: left;&lt;br /&gt;
  width: 100%;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-search-results button {&lt;br /&gt;
  cursor: pointer;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-search-results button:hover,&lt;br /&gt;
.s3rm-graph-search-results button:focus-visible {&lt;br /&gt;
  background: #252d50;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-body {&lt;br /&gt;
  display: grid;&lt;br /&gt;
  grid-template-columns: minmax(0, 1fr) 260px;&lt;br /&gt;
  min-height: 472px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-viewport {&lt;br /&gt;
  background-color: #0d1120;&lt;br /&gt;
  background-image: radial-gradient(circle, rgba(140, 168, 255, 0.13) 1px, transparent 1px);&lt;br /&gt;
  background-size: 24px 24px;&lt;br /&gt;
  min-width: 0;&lt;br /&gt;
  overflow: hidden;&lt;br /&gt;
  position: relative;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-canvas {&lt;br /&gt;
  display: block;&lt;br /&gt;
  height: 100%;&lt;br /&gt;
  min-height: 472px;&lt;br /&gt;
  touch-action: none;&lt;br /&gt;
  width: 100%;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-canvas.is-dragging {&lt;br /&gt;
  cursor: grabbing !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-hint {&lt;br /&gt;
  background: rgba(16, 20, 38, 0.86);&lt;br /&gt;
  bottom: 0.75rem;&lt;br /&gt;
  color: #8992b2;&lt;br /&gt;
  font-size: 0.68rem;&lt;br /&gt;
  left: 0.75rem;&lt;br /&gt;
  padding: 0.32rem 0.5rem;&lt;br /&gt;
  pointer-events: none;&lt;br /&gt;
  position: absolute;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-inspector {&lt;br /&gt;
  background: #151a2f;&lt;br /&gt;
  border-left: 1px solid #292f4b;&lt;br /&gt;
  box-sizing: border-box;&lt;br /&gt;
  min-width: 0;&lt;br /&gt;
  padding: 1.2rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-inspector-welcome {&lt;br /&gt;
  align-items: flex-start;&lt;br /&gt;
  color: #aeb5cf;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  flex-direction: column;&lt;br /&gt;
  height: 100%;&lt;br /&gt;
  justify-content: center;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-inspector-welcome strong {&lt;br /&gt;
  color: #f5f7ff;&lt;br /&gt;
  margin-bottom: 0.3rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-inspector-welcome p {&lt;br /&gt;
  font-size: 0.8rem;&lt;br /&gt;
  line-height: 1.55;&lt;br /&gt;
  margin: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-inspector-symbol {&lt;br /&gt;
  color: #8ca8ff;&lt;br /&gt;
  font-size: 2.2rem;&lt;br /&gt;
  line-height: 1;&lt;br /&gt;
  margin-bottom: 0.9rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-inspector-kicker {&lt;br /&gt;
  color: #8ca8ff;&lt;br /&gt;
  font-size: 0.68rem;&lt;br /&gt;
  font-weight: 800;&lt;br /&gt;
  letter-spacing: 0.08em;&lt;br /&gt;
  margin-bottom: 0.5rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-inspector h3 {&lt;br /&gt;
  color: #fff;&lt;br /&gt;
  font-size: 1.05rem;&lt;br /&gt;
  line-height: 1.45;&lt;br /&gt;
  margin: 0 0 0.85rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-inspector-meta {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  color: #9fa8c8;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  font-size: 0.76rem;&lt;br /&gt;
  gap: 0.6rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-inspector-relations {&lt;br /&gt;
  border-top: 1px solid #303754;&lt;br /&gt;
  list-style: none;&lt;br /&gt;
  margin: 1.1rem 0;&lt;br /&gt;
  padding: 0.6rem 0 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-inspector-relations li {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  color: #aeb5cf;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  font-size: 0.78rem;&lt;br /&gt;
  justify-content: space-between;&lt;br /&gt;
  padding: 0.25rem 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-inspector-relations strong {&lt;br /&gt;
  color: #fff;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-inspector-muted {&lt;br /&gt;
  color: #929ab8;&lt;br /&gt;
  font-size: 0.78rem;&lt;br /&gt;
  line-height: 1.5;&lt;br /&gt;
  margin: 1.1rem 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-primary-link {&lt;br /&gt;
  background: #687ee9;&lt;br /&gt;
  color: #fff !important;&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  font-size: 0.8rem;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  margin-top: 0.75rem;&lt;br /&gt;
  padding: 0.52rem 0.78rem;&lt;br /&gt;
  text-decoration: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-footer {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  background: #151a2f;&lt;br /&gt;
  border-top: 1px solid #292f4b;&lt;br /&gt;
  color: #8f98b8;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  font-size: 0.7rem;&lt;br /&gt;
  gap: 1rem;&lt;br /&gt;
  justify-content: space-between;&lt;br /&gt;
  min-height: 2.45rem;&lt;br /&gt;
  padding: 0 0.9rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-legend {&lt;br /&gt;
  display: flex;&lt;br /&gt;
  flex-wrap: wrap;&lt;br /&gt;
  gap: 0.8rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-legend-item {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  display: inline-flex;&lt;br /&gt;
  gap: 0.3rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-legend-item i {&lt;br /&gt;
  border-radius: 50%;&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  height: 0.4rem;&lt;br /&gt;
  width: 0.4rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-empty {&lt;br /&gt;
  background: #0d1120;&lt;br /&gt;
  color: #aeb5cf;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-empty strong {&lt;br /&gt;
  color: #fff;&lt;br /&gt;
  font-size: 1.05rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-empty p {&lt;br /&gt;
  font-size: 0.83rem;&lt;br /&gt;
  margin: 0.35rem 0 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-fallback {&lt;br /&gt;
  background: #0d1120;&lt;br /&gt;
  color: #c8cee5;&lt;br /&gt;
  min-height: 12rem;&lt;br /&gt;
  padding: 4rem 1rem;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-home-lower {&lt;br /&gt;
  display: grid;&lt;br /&gt;
  gap: clamp(2.5rem, 5vw, 5rem);&lt;br /&gt;
  grid-template-columns: minmax(0, 1.45fr) minmax(300px, 0.72fr);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-quick-start-header {&lt;br /&gt;
  align-items: flex-end;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-quick-start-header h2,&lt;br /&gt;
.s3rm-recent-section .s3rm-section-header h2 {&lt;br /&gt;
  padding-bottom: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-quick-start-header h2::before,&lt;br /&gt;
.s3rm-recent-section .s3rm-section-header h2::before {&lt;br /&gt;
  display: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-onboarding-steps {&lt;br /&gt;
  display: grid;&lt;br /&gt;
  gap: 0;&lt;br /&gt;
  grid-template-columns: repeat(3, minmax(0, 1fr));&lt;br /&gt;
  list-style: none;&lt;br /&gt;
  margin: 0;&lt;br /&gt;
  padding: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-onboarding-steps li {&lt;br /&gt;
  border-bottom: 1px solid var(--s3-line);&lt;br /&gt;
  border-right: 1px solid var(--s3-line);&lt;br /&gt;
  display: flex;&lt;br /&gt;
  gap: 0.75rem;&lt;br /&gt;
  min-width: 0;&lt;br /&gt;
  padding: 1rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-onboarding-steps li:first-child {&lt;br /&gt;
  padding-left: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-onboarding-steps li:last-child {&lt;br /&gt;
  border-right: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-onboarding-steps li &amp;gt; span {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  background: var(--s3-navy);&lt;br /&gt;
  border-radius: 50%;&lt;br /&gt;
  color: #fff;&lt;br /&gt;
  display: inline-flex;&lt;br /&gt;
  flex: 0 0 1.65rem;&lt;br /&gt;
  font-size: 0.72rem;&lt;br /&gt;
  font-weight: 800;&lt;br /&gt;
  height: 1.65rem;&lt;br /&gt;
  justify-content: center;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-onboarding-steps strong {&lt;br /&gt;
  color: var(--s3-navy);&lt;br /&gt;
  display: block;&lt;br /&gt;
  font-size: 0.86rem;&lt;br /&gt;
  margin: 0.1rem 0 0.22rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-onboarding-steps p {&lt;br /&gt;
  color: var(--s3-muted);&lt;br /&gt;
  font-size: 0.76rem;&lt;br /&gt;
  line-height: 1.48;&lt;br /&gt;
  margin: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-quick-start .s3rm-create-panel {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  border-top: 0;&lt;br /&gt;
  gap: 1.5rem;&lt;br /&gt;
  grid-template-columns: minmax(0, 1fr) minmax(290px, 0.8fr);&lt;br /&gt;
  padding: 1.2rem 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-quick-start .s3rm-create-panel h3 {&lt;br /&gt;
  font-size: 1rem;&lt;br /&gt;
  margin: 0 0 0.35rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-quick-start .s3rm-create-panel p {&lt;br /&gt;
  color: var(--s3-muted);&lt;br /&gt;
  font-size: 0.79rem;&lt;br /&gt;
  line-height: 1.55;&lt;br /&gt;
  margin: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-quick-start .s3rm-create-panel .s3rm-create-hint {&lt;br /&gt;
  margin-top: 0.35rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-quick-start .s3rm-form-launcher {&lt;br /&gt;
  background: #f1f2f7;&lt;br /&gt;
  border: 1px solid var(--s3-line);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-recent-section .s3rm-section-header {&lt;br /&gt;
  align-items: flex-end;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-recent-section .s3rm-recent-list {&lt;br /&gt;
  border-bottom: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-recent-section .s3rm-lesson-summary {&lt;br /&gt;
  margin-left: 0;&lt;br /&gt;
  margin-right: 0;&lt;br /&gt;
  padding-left: 0;&lt;br /&gt;
  padding-right: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
@media screen and (max-width: 960px) {&lt;br /&gt;
  .s3rm-home-lower {&lt;br /&gt;
    gap: 2.5rem;&lt;br /&gt;
    grid-template-columns: 1fr;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-create-panel {&lt;br /&gt;
    grid-template-columns: 1fr;&lt;br /&gt;
    gap: 1.1rem;&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
@media screen and (max-width: 720px) {&lt;br /&gt;
  .mw-body-header {&lt;br /&gt;
    padding: 1.35rem 1.15rem;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .page-Main_Page .mw-body-header,&lt;br /&gt;
  .page-연구_메모리 .mw-body-header {&lt;br /&gt;
    padding: 1rem 0 0.75rem;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-home-actions {&lt;br /&gt;
    grid-template-columns: 1fr;&lt;br /&gt;
    margin-bottom: 2.5rem;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-graph-section {&lt;br /&gt;
    margin-left: -0.75rem;&lt;br /&gt;
    margin-right: -0.75rem;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-graph-heading {&lt;br /&gt;
    align-items: flex-start;&lt;br /&gt;
    flex-direction: column;&lt;br /&gt;
    gap: 0.85rem;&lt;br /&gt;
    padding: 1.1rem;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-semantic-search {&lt;br /&gt;
    padding: 1rem;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-semantic-form {&lt;br /&gt;
    align-items: stretch;&lt;br /&gt;
    flex-direction: column;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-semantic-submit {&lt;br /&gt;
    min-height: 2.75rem;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-semantic-examples {&lt;br /&gt;
    display: none;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-semantic-results {&lt;br /&gt;
    grid-template-columns: 1fr;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-graph-toolbar {&lt;br /&gt;
    align-items: stretch;&lt;br /&gt;
    flex-wrap: wrap;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-graph-search {&lt;br /&gt;
    flex-basis: 100%;&lt;br /&gt;
    max-width: none;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-graph-filter {&lt;br /&gt;
    flex: 1;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-graph-body {&lt;br /&gt;
    grid-template-columns: 1fr;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-graph-canvas,&lt;br /&gt;
  .s3rm-graph-viewport {&lt;br /&gt;
    min-height: 390px;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-graph-inspector {&lt;br /&gt;
    border-left: 0;&lt;br /&gt;
    border-top: 1px solid #292f4b;&lt;br /&gt;
    min-height: 10rem;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-inspector-welcome {&lt;br /&gt;
    min-height: 8rem;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-graph-footer {&lt;br /&gt;
    align-items: flex-start;&lt;br /&gt;
    flex-direction: column;&lt;br /&gt;
    padding-bottom: 0.6rem;&lt;br /&gt;
    padding-top: 0.6rem;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-graph-legend {&lt;br /&gt;
    gap: 0.55rem;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-onboarding-steps {&lt;br /&gt;
    grid-template-columns: 1fr;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-onboarding-steps li,&lt;br /&gt;
  .s3rm-onboarding-steps li:first-child,&lt;br /&gt;
  .s3rm-onboarding-steps li:last-child {&lt;br /&gt;
    border-right: 0;&lt;br /&gt;
    padding: 0.8rem 0;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-action-card,&lt;br /&gt;
  .s3rm-action-card:first-child,&lt;br /&gt;
  .s3rm-action-card:last-child {&lt;br /&gt;
    border-bottom: 1px solid var(--s3-line);&lt;br /&gt;
    border-right: 0;&lt;br /&gt;
    padding: 0.85rem 0;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-action-card:last-child {&lt;br /&gt;
    border-bottom: 0;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-content-section {&lt;br /&gt;
    margin-bottom: 2.5rem;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-lesson-table th,&lt;br /&gt;
  .s3rm-lesson-table td,&lt;br /&gt;
  .formtable th,&lt;br /&gt;
  .formtable td {&lt;br /&gt;
    display: block;&lt;br /&gt;
    min-width: 0;&lt;br /&gt;
    width: auto;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-lesson-table th,&lt;br /&gt;
  .formtable th {&lt;br /&gt;
    border-bottom: 0;&lt;br /&gt;
    padding-bottom: 0.15rem;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-lesson-table td,&lt;br /&gt;
  .formtable td {&lt;br /&gt;
    padding-top: 0.15rem;&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
@media (prefers-reduced-motion: reduce) {&lt;br /&gt;
  html { scroll-behavior: auto; }&lt;br /&gt;
&lt;br /&gt;
  *,&lt;br /&gt;
  *::before,&lt;br /&gt;
  *::after {&lt;br /&gt;
    transition-duration: 0.01ms !important;&lt;br /&gt;
  }&lt;br /&gt;
}&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC&amp;diff=2719</id>
		<title>연구 메모리</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC&amp;diff=2719"/>
		<updated>2026-07-19T02:01:19Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 원본 스키마 동기화&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;__NOTOC__&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-home-intro&amp;quot;&amp;gt;&lt;br /&gt;
{{:Help:S3 연구 메모리}}&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-graph-section&amp;quot; id=&amp;quot;knowledge-graph&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-graph-heading&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;div&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-eyebrow&amp;quot;&amp;gt;지식 그래프&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;h2&amp;gt;메모 사이의 연결을 탐색하세요&amp;lt;/h2&amp;gt;&lt;br /&gt;
&amp;lt;p&amp;gt;점은 메모, 선은 메모 사이의 관계입니다. 메모를 선택하면 연결된 기록을 함께 볼 수 있습니다.&amp;lt;/p&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-graph-shortcuts&amp;quot;&amp;gt;&lt;br /&gt;
[[Special:Search|검색]]&lt;br /&gt;
[[연구 메모리#quick-start|새 메모]]&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;div id=&amp;quot;s3rm-semantic-search&amp;quot; class=&amp;quot;s3rm-semantic-search&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-semantic-search-loading&amp;quot;&amp;gt;검색을 준비하는 중입니다.&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;div id=&amp;quot;s3rm-knowledge-graph&amp;quot; class=&amp;quot;s3rm-knowledge-graph&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-graph-loading&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;span class=&amp;quot;s3rm-graph-loading-mark&amp;quot; aria-hidden=&amp;quot;true&amp;quot;&amp;gt;&amp;lt;/span&amp;gt;&lt;br /&gt;
&amp;lt;strong&amp;gt;메모 연결을 불러오는 중입니다&amp;lt;/strong&amp;gt;&lt;br /&gt;
&amp;lt;span&amp;gt;잠시만 기다려 주세요.&amp;lt;/span&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;noscript&amp;gt;&amp;lt;div class=&amp;quot;s3rm-graph-fallback&amp;quot;&amp;gt;그래프를 보려면 JavaScript를 켜세요. 아래 최근 메모와 전체 목록은 그대로 사용할 수 있습니다.&amp;lt;/div&amp;gt;&amp;lt;/noscript&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-home-lower&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-content-section s3rm-quick-start&amp;quot; id=&amp;quot;quick-start&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-section-header s3rm-quick-start-header&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;div&amp;gt;&lt;br /&gt;
&amp;lt;h2&amp;gt;처음이라면 이렇게 시작하세요&amp;lt;/h2&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;span class=&amp;quot;s3rm-section-link&amp;quot;&amp;gt;[[Help:S3 연구 메모리|자세한 사용법]]&amp;lt;/span&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;ol class=&amp;quot;s3rm-onboarding-steps&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;li&amp;gt;&amp;lt;span&amp;gt;1&amp;lt;/span&amp;gt;&amp;lt;div&amp;gt;&amp;lt;strong&amp;gt;찾기&amp;lt;/strong&amp;gt;&amp;lt;p&amp;gt;검색창에 현상, 오류 메시지 또는 도구 이름을 입력하세요.&amp;lt;/p&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/li&amp;gt;&lt;br /&gt;
&amp;lt;li&amp;gt;&amp;lt;span&amp;gt;2&amp;lt;/span&amp;gt;&amp;lt;div&amp;gt;&amp;lt;strong&amp;gt;연결 보기&amp;lt;/strong&amp;gt;&amp;lt;p&amp;gt;그래프에서 점을 선택해 앞뒤 메모를 함께 살펴보세요.&amp;lt;/p&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/li&amp;gt;&lt;br /&gt;
&amp;lt;li&amp;gt;&amp;lt;span&amp;gt;3&amp;lt;/span&amp;gt;&amp;lt;div&amp;gt;&amp;lt;strong&amp;gt;남기기&amp;lt;/strong&amp;gt;&amp;lt;p&amp;gt;새로 알게 된 내용은 같은 저장소에 바로 기록하세요.&amp;lt;/p&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/li&amp;gt;&lt;br /&gt;
&amp;lt;/ol&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-create-panel&amp;quot; id=&amp;quot;create-a-lesson&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;div&amp;gt;&lt;br /&gt;
&amp;lt;h3&amp;gt;새 메모 남기기&amp;lt;/h3&amp;gt;&lt;br /&gt;
&amp;lt;p&amp;gt;누구나 읽을 수 있습니다. 새 메모를 쓰거나 고치려면 [[Special:UserLogin|로그인]]하세요. 저장한 메모는 바로 검색됩니다.&amp;lt;/p&amp;gt;&lt;br /&gt;
&amp;lt;p class=&amp;quot;s3rm-create-hint&amp;quot;&amp;gt;짧은 영문 문서 ID를 입력하세요. 소문자, 숫자, 밑줄(_)과 하이픈(-)을 쓸 수 있습니다.&amp;lt;/p&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-form-launcher&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-form-label&amp;quot;&amp;gt;문서 ID&amp;lt;/div&amp;gt;&lt;br /&gt;
{{#forminput:&lt;br /&gt;
 |form=Lesson&lt;br /&gt;
 |size=42&lt;br /&gt;
 |placeholder=gpu_memory_reuse&lt;br /&gt;
 |button text=작성 시작&lt;br /&gt;
 |query string=namespace=Lesson&lt;br /&gt;
}}&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-content-section s3rm-recent-section&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-section-header&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;div&amp;gt;&lt;br /&gt;
&amp;lt;h2&amp;gt;최근 메모&amp;lt;/h2&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;span class=&amp;quot;s3rm-section-link&amp;quot;&amp;gt;[[Special:AllPages/Lesson:|모든 메모]]&amp;lt;/span&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-recent-list&amp;quot;&amp;gt;&lt;br /&gt;
{{#ask:&lt;br /&gt;
 [[Category:Lessons]]&lt;br /&gt;
 |?Lesson title&lt;br /&gt;
 |?Confidence&lt;br /&gt;
 |?Updated at#ISO&lt;br /&gt;
 |sort=Updated at&lt;br /&gt;
 |order=descending&lt;br /&gt;
 |limit=6&lt;br /&gt;
 |format=plainlist&lt;br /&gt;
 |template=S3RM lesson summary&lt;br /&gt;
 |link=none&lt;br /&gt;
 |sep=&lt;br /&gt;
 |searchlabel=&lt;br /&gt;
 |default=&amp;lt;div class=&amp;quot;s3rm-empty-state&amp;quot;&amp;gt;아직 작성된 메모가 없습니다.&amp;lt;/div&amp;gt;&lt;br /&gt;
}}&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC/%EB%A9%94%EB%AA%A8_%EC%82%AC%EC%9A%A9%EB%B2%95&amp;diff=2718</id>
		<title>도움말:S3 연구 메모리/메모 사용법</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC/%EB%A9%94%EB%AA%A8_%EC%82%AC%EC%9A%A9%EB%B2%95&amp;diff=2718"/>
		<updated>2026-07-19T01:59:07Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 저장소 문서 동기화&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;!-- S3RM managed repository document; edit the Markdown source, not this page. --&amp;gt;&lt;br /&gt;
&#039;&#039;이 페이지는 저장소 문서에서 자동으로 동기화됩니다. 위키에서 직접 편집하지 마세요.&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
S3 연구 메모리는 프로젝트 일지나 실험 원본을 모으는 곳이 아닙니다. 다른 작업에서도 다시 참고할 만한 시행착오를 짧은 메모로 남깁니다.&lt;br /&gt;
&lt;br /&gt;
=== 먼저 검색하기 ===&lt;br /&gt;
&lt;br /&gt;
브라우저 메인 화면의 검색창에는 궁금한 점을 문장으로 입력할 수 있습니다. 표현이 달라도 관련된 메모를 함께 찾습니다. 그 아래 지식 그래프에서 점을 누르면 연결 수와 관계 유형을 볼 수 있고, 두 번 누르면 메모가 열립니다. 제목 검색, 관계 필터, 확대와 이동도 사용할 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
본문까지 찾으려면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Special:Search&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 에이전트에서는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;search_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 사용합니다. 프로젝트 이름보다 현상과 원인을 나타내는 말로 찾는 편이 잘 맞습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
메모리 단편화, retry storm, lock contention, cold start&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
같은 내용이 이미 있으면 새 메모를 만들지 않습니다. 기존 메모에 자료를 더하거나, 내용이 다른 이유를 관계로 연결하면 됩니다.&lt;br /&gt;
&lt;br /&gt;
=== 브라우저에서 메모 쓰기 ===&lt;br /&gt;
&lt;br /&gt;
로그인한 뒤 다음 화면을 엽니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
/index.php/연구_메모리#create-a-lesson&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
문서 ID에는 영문 소문자, 숫자, 밑줄, 하이픈만 씁니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Lesson:&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 입력하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
gpu_allocator_fragmentation&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
문서 ID는 에이전트가 메모를 인용할 때도 쓰므로 날짜, 사람 이름, 프로젝트에서만 통하는 줄임말은 피하세요. 화면에 보이는 제목은 나중에 바꿀 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
입력란에는 다음 내용을 적습니다.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 화면 !! 필드 !! 적을 내용&lt;br /&gt;
|-&lt;br /&gt;
| 제목 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;title&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 메모의 결론이 드러나는 짧은 제목&lt;br /&gt;
|-&lt;br /&gt;
| 궁금했던 점 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;question&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 시도를 시작한 이유&lt;br /&gt;
|-&lt;br /&gt;
| 해본 것 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;attempt&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 실제로 바꿨거나 비교한 것&lt;br /&gt;
|-&lt;br /&gt;
| 당시 조건 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;context&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 장비, 버전, 설정, 워크로드 등 결과에 영향을 준 조건&lt;br /&gt;
|-&lt;br /&gt;
| 실제 결과 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;observation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 직접 확인한 수치나 동작&lt;br /&gt;
|-&lt;br /&gt;
| 왜 그랬는지 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;interpretation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 가능한 원인과 아직 확실하지 않은 부분&lt;br /&gt;
|-&lt;br /&gt;
| 다음에 기억할 것 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;reusable_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 다른 작업에서도 써먹을 한 문장&lt;br /&gt;
|-&lt;br /&gt;
| 언제 맞는지 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;applicability&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 맞는 조건과 예외&lt;br /&gt;
|-&lt;br /&gt;
| 신뢰도 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;confidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 자료의 질에 따라 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;low&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;medium&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;high&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 중 선택&lt;br /&gt;
|-&lt;br /&gt;
| 관련 자료 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;evidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아래에 연결할 자료를 짧게 설명&lt;br /&gt;
|-&lt;br /&gt;
| 자료 출처 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;record_origin&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 직접 얻은 내용은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;lab&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 외부 자료를 정리했으면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;imported&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
관찰과 해석은 나눠 쓰세요. 예를 들어 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;동시 compaction 16개에서 p99가 18 ms에서 71 ms로 늘었다&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 관찰입니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;공유 writeback 제한 때문에 queue가 묶였다&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 해석이며, 새 자료가 나오면 달라질 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
저장한 메모는 바로 검색되고 메인 화면의 지식 그래프와 최근 메모에도 나타납니다.&lt;br /&gt;
&lt;br /&gt;
=== 자료 연결하기 ===&lt;br /&gt;
&lt;br /&gt;
메모마다 자료를 하나 이상 연결합니다. 원본 전체를 위키에 복사하지 말고 나중에 다시 찾을 수 있는 위치를 적으세요.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 입력란 !! 내용&lt;br /&gt;
|-&lt;br /&gt;
| 자료 ID || 메모 안에서 겹치지 않는 짧은 ID&lt;br /&gt;
|-&lt;br /&gt;
| 출처 || 저장소, 커밋, 실행 ID, 문서 절, 시간 구간 등&lt;br /&gt;
|-&lt;br /&gt;
| URL || 바로 열 수 있는 주소가 있을 때만 입력&lt;br /&gt;
|-&lt;br /&gt;
| 자료 종류 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;paper&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;code&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;dataset&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;log&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;benchmark&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;other&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 중 선택&lt;br /&gt;
|-&lt;br /&gt;
| 확인 범위 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;full_text&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;official_abstract&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;partial_source&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;metadata_only&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 중 선택&lt;br /&gt;
|-&lt;br /&gt;
| 메모 || 이 자료에서 확인할 부분과 한계&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
git yonsei-s3/kernel-probes@4f19a2e, results/lockstat/run-031/summary.json&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
확인 범위는 자료에서 실제로 읽은 범위이며 Lesson의 종합 신뢰도인 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;confidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 다릅니다. 서지정보만 확인했다면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;metadata_only&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 표시하고 기술 내용을 추측하지 않습니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;not_recorded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 이전 근거에 값이 없을 때 표시되는 호환값이므로 새 근거에는 선택하지 않습니다. 브라우저 양식에도 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;not_recorded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 기본값이 없고 새 근거에 선택할 수 없습니다. MCP로 새 근거를 붙일 때는 나머지 네 값 중 하나를 명시해야 합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;latest&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;대시보드&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 작성자만 아는 로컬 경로처럼 시간이 지나면 찾기 어려운 표현은 피합니다. 비밀번호, 토큰, 개인 정보, 공개할 수 없는 대화, 대용량 원시 데이터도 본문에 넣지 않습니다.&lt;br /&gt;
&lt;br /&gt;
=== 근거를 검증한 기록 남기기 ===&lt;br /&gt;
&lt;br /&gt;
자료를 실제로 읽어 Lesson 주장을 확인했다면 기존 evidence를 고치지 말고 새 검증 기록을 붙입니다. 같은 evidence를 나중에 다시 확인했다면 이전 기록을 수정·삭제하지 않고 새 기록을 append-only로 붙입니다. evidence별 마지막 기록이 현재 판정입니다.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 입력란 !! 내용&lt;br /&gt;
|-&lt;br /&gt;
| evidence ID || 검사한 기존 근거 ID&lt;br /&gt;
|-&lt;br /&gt;
| evidence digest || 읽기 시점의 evidence 내용 digest&lt;br /&gt;
|-&lt;br /&gt;
| 확인 범위 || 이번에 실제로 검사한 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;verification_basis&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| 자료 식별자 || 파일, 커밋, 데이터 버전, 문서 버전 등 고정 식별자&lt;br /&gt;
|-&lt;br /&gt;
| 자료 SHA-256 || 실제로 검사한 정확한 바이트의 SHA-256&lt;br /&gt;
|-&lt;br /&gt;
| 위치 || 페이지, 절, 라인, 실험 구간 등 주장을 확인한 위치&lt;br /&gt;
|-&lt;br /&gt;
| 확인 범위와 한계 || 읽은 범위와 제외한 부분&lt;br /&gt;
|-&lt;br /&gt;
| 판정 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supports&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;contradicts&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;inconclusive&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 중 하나&lt;br /&gt;
|-&lt;br /&gt;
| 연결 주장 || 자료가 실제로 확인한 Lesson 필드&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
원본 파일을 검사했다면 그 파일 바이트를 해시합니다. 원본 대신 저장·정규화한 텍스트를 검사했다면 그 정확한 텍스트의 UTF-8 바이트를 해시합니다. 이 경우 변환 방법과 보존한 산출물을 자료 식별자와 확인 범위에 명시합니다. MediaWiki revision의 저장 계정과 시각이 권위 출처입니다. 검증 기록의 출처는 현재 저장 계정과 정확히 같아야 하고, 기록 시각은 revision 저장 시각 전후 5분 안이며 이전 기록보다 늦어야 하는 보조 필드입니다. 기존 출처와 시각은 수정할 수 없습니다.&lt;br /&gt;
&lt;br /&gt;
high confidence는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;full_text&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 또는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;official_abstract&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 기반으로 한 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supports&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 검증이 observation, interpretation, reusable_lesson 세 필드를 모두 뒷받침할 때만 사용합니다. observation, interpretation, reusable_lesson을 수정한 후 결과 confidence가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;medium&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 또는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;high&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이면 실제로 바꾸는 주장과 연결한 같은 강도의 검증이 필요합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;contradicts&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;나 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;inconclusive&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 지지 근거로 세지 않습니다. 결과 confidence가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;low&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;인 교정은 기존 confidence를 낮추는 경우를 포함해 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;not_recorded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 아닌 basis evidence로 할 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
=== 다른 메모와 연결하기 ===&lt;br /&gt;
&lt;br /&gt;
관계는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;출발 메모 관계값 대상 메모&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 순서로 읽습니다.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 관계 !! 뜻&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supports&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 대상 메모와 맞는 자료나 원인을 보탬&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;contradicts&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 대상 메모의 주요 주장과 관찰이 충돌함&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;analogous_to&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 분야는 달라도 같은 원인이나 실패 형태를 보임&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;failed_because&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 대상 메모에 적힌 이유로 시도가 실패함&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;works_when&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 대상 메모의 조건에서 방법이 잘 작동함&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;suggests&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 다음 가설이나 시도를 제안함&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supersedes&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 새 메모가 이전 안내를 대신함. 상태는 자동으로 바뀌지 않음&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
연결 이유에는 두 메모가 닮거나 충돌하는 지점을 한두 문장으로 씁니다. 관련성이 애매하면 억지로 연결하지 않아도 됩니다.&lt;br /&gt;
&lt;br /&gt;
=== 메모 고치기 ===&lt;br /&gt;
&lt;br /&gt;
메모 화면에서 &#039;&#039;&#039;메모 편집&#039;&#039;&#039;을 누릅니다. 잘못 적은 수치나 해석을 고치고, 수정 요약에 이유를 짧게 남기세요. 이전 내용은 MediaWiki 이력에서 확인할 수 있습니다. 에이전트가 고칠 때는 현재 revision과 근거를 다시 읽은 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;revise_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 사용합니다. 같은 논문의 보강 내용을 evidence note나 새 Technical Review 메모에 숨기지 않습니다. 새 근거를 먼저 붙였다면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;attach_evidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 응답의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;current_revision_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 다음 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;revise_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;expected_revision_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 사용합니다. 그 사이 다른 변경이 있었다면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;get_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 다시 읽고 변경안을 재계산합니다.&lt;br /&gt;
&lt;br /&gt;
=== MCP로 찾기 ===&lt;br /&gt;
&lt;br /&gt;
처음에는 결과를 3~5개만 받고 필요한 필드만 요청하는 편이 빠릅니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
S3 Research Memory에서 tail latency와 관련된 메모를 최대 5개 찾아 줘.&lt;br /&gt;
id, title, reusable_lesson, applicability, confidence, citations만 반환해.&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
후보를 고른 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;get_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 자세히 읽고, 관계는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;find_related_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 다른 분야의 비슷한 사례는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;find_analogies&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 찾습니다. 근거 note와 추가 정보를 확인하려면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;citation_detail=full&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 지정합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;get_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 revision ID는 이어지는 조건부 수정과 감사에 사용합니다. 답변에 메모를 사용했다면 반환된 자료 인용도 같이 보관하세요.&lt;br /&gt;
&lt;br /&gt;
서버에서 의미 검색을 켠 경우에는 표현이 다른 문제도 찾도록 다음처럼 요청할 수 있습니다. 기본 검색은 계속 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;legacy&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이므로 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;hybrid_v1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 직접 지정합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
search_lessons를 retrieval_mode=hybrid_v1로 실행해,&lt;br /&gt;
문제의 제약과 해결 원리가 비슷한 메모를 최대 5개 찾아 줘.&lt;br /&gt;
effective_mode와 semantic_status도 알려 줘.&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
의미 유사도는 저장된 관계나 사실이 아닙니다. 후보의 적용 조건과 근거를 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;get_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 확인한 뒤 실제 관계를 남길지 판단하세요.&lt;br /&gt;
&lt;br /&gt;
=== 에이전트에게 작성을 맡길 때 ===&lt;br /&gt;
&lt;br /&gt;
쓰기 토큰이 있는 에이전트는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;create_lesson_draft&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;link_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;attach_evidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;record_evidence_verification&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;revise_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 쓸 수 있습니다. 에이전트가 만든 메모와 revision은 저장 즉시 검색됩니다. 상태 변경, 삭제, 이동, 관리자 작업은 MCP에서 할 수 없습니다. 다섯 쓰기 도구는 모두 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;operation_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 필요합니다. 같은 Lesson 안에서 일반 쓰기, revision, 검증 기록은 ID 충돌 범위를 공유합니다. 같은 종류·요청의 정확한 재시도일 때만 같은 ID와 같은 요청을 보내고, 다른 작업에는 새 ID를 사용합니다. Lesson 생성에는 구조화된 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;initial_evidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 필수이며 Lesson 본문과 같은 revision에 저장됩니다.&lt;br /&gt;
&lt;br /&gt;
자료 위치와 확인한 사실을 먼저 제공하고 다음을 요청하세요.&lt;br /&gt;
&lt;br /&gt;
* 같은 메모가 있는지 먼저 검색&lt;br /&gt;
* 관찰과 해석을 분리&lt;br /&gt;
* 맞는 조건과 예외를 명시&lt;br /&gt;
* 자료를 추측하지 않기&lt;br /&gt;
* 검증 기록이 있다면 자료 digest·위치·범위·판정·연결 주장을 함께 확인&lt;br /&gt;
* 작업이 끝나면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Lesson:&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; ID 반환&lt;br /&gt;
* 여러 건을 바꿨다면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;audit_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 revision·내용·근거·관계를 확인&lt;br /&gt;
반환된 ID를 브라우저에서 열어 내용과 자료 위치를 확인합니다. 틀린 부분은 같은 ID의 새 revision으로 고칩니다. 출처만 추가할 때는 evidence, 독립된 새 주장일 때만 새 Lesson과 관계를 사용합니다. 감사는 저장된 구조, digest, 검증 수령 기록의 일관성을 확인하며 외부 자료의 사실 진위를 자동으로 판정하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
도구 입력과 응답 형식은 [[Help:S3 연구 메모리/MCP 클라이언트|MCP 클라이언트 안내]], 필드와 관계의 기계 계약은 [[Help:S3 연구 메모리/아키텍처|아키텍처]]에 있습니다.&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC&amp;diff=2717</id>
		<title>연구 메모리</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC&amp;diff=2717"/>
		<updated>2026-07-19T01:59:07Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 원본 스키마 동기화&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;__NOTOC__&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-home-intro&amp;quot;&amp;gt;&lt;br /&gt;
{{:Help:S3 연구 메모리}}&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-graph-section&amp;quot; id=&amp;quot;knowledge-graph&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-graph-heading&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;div&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-eyebrow&amp;quot;&amp;gt;지식 그래프&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;h2&amp;gt;메모 사이의 연결을 탐색하세요&amp;lt;/h2&amp;gt;&lt;br /&gt;
&amp;lt;p&amp;gt;점은 메모, 선은 메모 사이의 관계입니다. 메모를 선택하면 연결된 기록을 함께 볼 수 있습니다.&amp;lt;/p&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-graph-shortcuts&amp;quot;&amp;gt;&lt;br /&gt;
[[Special:Search|본문 검색]]&lt;br /&gt;
[[연구 메모리#quick-start|새 메모]]&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;div id=&amp;quot;s3rm-semantic-search&amp;quot; class=&amp;quot;s3rm-semantic-search&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-semantic-search-loading&amp;quot;&amp;gt;검색을 준비하는 중입니다.&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;div id=&amp;quot;s3rm-knowledge-graph&amp;quot; class=&amp;quot;s3rm-knowledge-graph&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-graph-loading&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;span class=&amp;quot;s3rm-graph-loading-mark&amp;quot; aria-hidden=&amp;quot;true&amp;quot;&amp;gt;&amp;lt;/span&amp;gt;&lt;br /&gt;
&amp;lt;strong&amp;gt;메모 연결을 불러오는 중입니다&amp;lt;/strong&amp;gt;&lt;br /&gt;
&amp;lt;span&amp;gt;잠시만 기다려 주세요.&amp;lt;/span&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;noscript&amp;gt;&amp;lt;div class=&amp;quot;s3rm-graph-fallback&amp;quot;&amp;gt;그래프를 보려면 JavaScript를 켜세요. 아래 최근 메모와 전체 목록은 그대로 사용할 수 있습니다.&amp;lt;/div&amp;gt;&amp;lt;/noscript&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-home-lower&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-content-section s3rm-quick-start&amp;quot; id=&amp;quot;quick-start&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-section-header s3rm-quick-start-header&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;div&amp;gt;&lt;br /&gt;
&amp;lt;h2&amp;gt;처음이라면 이렇게 시작하세요&amp;lt;/h2&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;span class=&amp;quot;s3rm-section-link&amp;quot;&amp;gt;[[Help:S3 연구 메모리|자세한 사용법]]&amp;lt;/span&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;ol class=&amp;quot;s3rm-onboarding-steps&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;li&amp;gt;&amp;lt;span&amp;gt;1&amp;lt;/span&amp;gt;&amp;lt;div&amp;gt;&amp;lt;strong&amp;gt;찾기&amp;lt;/strong&amp;gt;&amp;lt;p&amp;gt;검색창에 현상, 오류 메시지 또는 도구 이름을 입력하세요.&amp;lt;/p&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/li&amp;gt;&lt;br /&gt;
&amp;lt;li&amp;gt;&amp;lt;span&amp;gt;2&amp;lt;/span&amp;gt;&amp;lt;div&amp;gt;&amp;lt;strong&amp;gt;연결 보기&amp;lt;/strong&amp;gt;&amp;lt;p&amp;gt;그래프에서 점을 선택해 앞뒤 메모를 함께 살펴보세요.&amp;lt;/p&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/li&amp;gt;&lt;br /&gt;
&amp;lt;li&amp;gt;&amp;lt;span&amp;gt;3&amp;lt;/span&amp;gt;&amp;lt;div&amp;gt;&amp;lt;strong&amp;gt;남기기&amp;lt;/strong&amp;gt;&amp;lt;p&amp;gt;새로 알게 된 내용은 같은 저장소에 바로 기록하세요.&amp;lt;/p&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/li&amp;gt;&lt;br /&gt;
&amp;lt;/ol&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-create-panel&amp;quot; id=&amp;quot;create-a-lesson&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;div&amp;gt;&lt;br /&gt;
&amp;lt;h3&amp;gt;새 메모 남기기&amp;lt;/h3&amp;gt;&lt;br /&gt;
&amp;lt;p&amp;gt;누구나 읽을 수 있습니다. 새 메모를 쓰거나 고치려면 [[Special:UserLogin|로그인]]하세요. 저장한 메모는 바로 검색됩니다.&amp;lt;/p&amp;gt;&lt;br /&gt;
&amp;lt;p class=&amp;quot;s3rm-create-hint&amp;quot;&amp;gt;짧은 영문 문서 ID를 입력하세요. 소문자, 숫자, 밑줄(_)과 하이픈(-)을 쓸 수 있습니다.&amp;lt;/p&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-form-launcher&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-form-label&amp;quot;&amp;gt;문서 ID&amp;lt;/div&amp;gt;&lt;br /&gt;
{{#forminput:&lt;br /&gt;
 |form=Lesson&lt;br /&gt;
 |size=42&lt;br /&gt;
 |placeholder=gpu_memory_reuse&lt;br /&gt;
 |button text=작성 시작&lt;br /&gt;
 |query string=namespace=Lesson&lt;br /&gt;
}}&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-content-section s3rm-recent-section&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-section-header&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;div&amp;gt;&lt;br /&gt;
&amp;lt;h2&amp;gt;최근 메모&amp;lt;/h2&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;span class=&amp;quot;s3rm-section-link&amp;quot;&amp;gt;[[Special:AllPages/Lesson:|모든 메모]]&amp;lt;/span&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-recent-list&amp;quot;&amp;gt;&lt;br /&gt;
{{#ask:&lt;br /&gt;
 [[Category:Lessons]]&lt;br /&gt;
 |?Lesson title&lt;br /&gt;
 |?Confidence&lt;br /&gt;
 |?Updated at#ISO&lt;br /&gt;
 |sort=Updated at&lt;br /&gt;
 |order=descending&lt;br /&gt;
 |limit=6&lt;br /&gt;
 |format=plainlist&lt;br /&gt;
 |template=S3RM lesson summary&lt;br /&gt;
 |link=none&lt;br /&gt;
 |sep=&lt;br /&gt;
 |searchlabel=&lt;br /&gt;
 |default=&amp;lt;div class=&amp;quot;s3rm-empty-state&amp;quot;&amp;gt;아직 작성된 메모가 없습니다.&amp;lt;/div&amp;gt;&lt;br /&gt;
}}&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%EB%AF%B8%EB%94%94%EC%96%B4%EC%9C%84%ED%82%A4:Common.js&amp;diff=2716</id>
		<title>미디어위키:Common.js</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%EB%AF%B8%EB%94%94%EC%96%B4%EC%9C%84%ED%82%A4:Common.js&amp;diff=2716"/>
		<updated>2026-07-19T01:59:07Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 원본 스키마 동기화&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;(function () {&lt;br /&gt;
  &#039;use strict&#039;;&lt;br /&gt;
&lt;br /&gt;
  var root = document.getElementById(&#039;s3rm-knowledge-graph&#039;);&lt;br /&gt;
  var semanticRoot = document.getElementById(&#039;s3rm-semantic-search&#039;);&lt;br /&gt;
  if (!root || !window.mw) {&lt;br /&gt;
    return;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  var RELATIONS = [&lt;br /&gt;
    { property: &#039;Supports&#039;, key: &#039;supports&#039;, label: &#039;뒷받침함&#039;, color: &#039;#5f7ae6&#039; },&lt;br /&gt;
    { property: &#039;Contradicts&#039;, key: &#039;contradicts&#039;, label: &#039;상충함&#039;, color: &#039;#d0605b&#039; },&lt;br /&gt;
    { property: &#039;Analogous to&#039;, key: &#039;analogous_to&#039;, label: &#039;유사함&#039;, color: &#039;#a17bd7&#039; },&lt;br /&gt;
    { property: &#039;Failed because&#039;, key: &#039;failed_because&#039;, label: &#039;실패 원인&#039;, color: &#039;#dc8b45&#039; },&lt;br /&gt;
    { property: &#039;Works when&#039;, key: &#039;works_when&#039;, label: &#039;작동 조건&#039;, color: &#039;#43a27b&#039; },&lt;br /&gt;
    { property: &#039;Suggests&#039;, key: &#039;suggests&#039;, label: &#039;후속 제안&#039;, color: &#039;#3f9db7&#039; },&lt;br /&gt;
    { property: &#039;Supersedes&#039;, key: &#039;supersedes&#039;, label: &#039;대체함&#039;, color: &#039;#7d849e&#039; }&lt;br /&gt;
  ];&lt;br /&gt;
  var NODE_LIMIT = 160;&lt;br /&gt;
  var EDGE_LIMIT = 480;&lt;br /&gt;
  var mcpReady = null;&lt;br /&gt;
&lt;br /&gt;
  function mcpRequest(payload, expectBody) {&lt;br /&gt;
    return window.fetch(&#039;/mcp&#039;, {&lt;br /&gt;
      method: &#039;POST&#039;,&lt;br /&gt;
      credentials: &#039;same-origin&#039;,&lt;br /&gt;
      headers: {&lt;br /&gt;
        &#039;Content-Type&#039;: &#039;application/json&#039;,&lt;br /&gt;
        &#039;Accept&#039;: &#039;application/json, text/event-stream&#039;&lt;br /&gt;
      },&lt;br /&gt;
      body: JSON.stringify(payload)&lt;br /&gt;
    }).then(function (response) {&lt;br /&gt;
      if (!response.ok) {&lt;br /&gt;
        throw new Error(&#039;MCP HTTP &#039; + response.status);&lt;br /&gt;
      }&lt;br /&gt;
      if (!expectBody) {&lt;br /&gt;
        return null;&lt;br /&gt;
      }&lt;br /&gt;
      return response.json();&lt;br /&gt;
    });&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function initializeMcp() {&lt;br /&gt;
    if (!mcpReady) {&lt;br /&gt;
      mcpReady = mcpRequest({&lt;br /&gt;
        jsonrpc: &#039;2.0&#039;,&lt;br /&gt;
        id: 1,&lt;br /&gt;
        method: &#039;initialize&#039;,&lt;br /&gt;
        params: {&lt;br /&gt;
          protocolVersion: &#039;2025-06-18&#039;,&lt;br /&gt;
          capabilities: {},&lt;br /&gt;
          clientInfo: { name: &#039;s3rm-wiki-search&#039;, version: &#039;1&#039; }&lt;br /&gt;
        }&lt;br /&gt;
      }, true).then(function (response) {&lt;br /&gt;
        if (!response || response.error) {&lt;br /&gt;
          throw new Error(&#039;MCP initialization failed&#039;);&lt;br /&gt;
        }&lt;br /&gt;
        return mcpRequest({&lt;br /&gt;
          jsonrpc: &#039;2.0&#039;,&lt;br /&gt;
          method: &#039;notifications/initialized&#039;,&lt;br /&gt;
          params: {}&lt;br /&gt;
        }, false);&lt;br /&gt;
      });&lt;br /&gt;
    }&lt;br /&gt;
    return mcpReady;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function searchSemantically(query) {&lt;br /&gt;
    return initializeMcp().then(function () {&lt;br /&gt;
      return mcpRequest({&lt;br /&gt;
        jsonrpc: &#039;2.0&#039;,&lt;br /&gt;
        id: 2,&lt;br /&gt;
        method: &#039;tools/call&#039;,&lt;br /&gt;
        params: {&lt;br /&gt;
          name: &#039;search_lessons&#039;,&lt;br /&gt;
          arguments: {&lt;br /&gt;
            query: query,&lt;br /&gt;
            retrieval_mode: &#039;hybrid_v1&#039;,&lt;br /&gt;
            fields: [&#039;title&#039;, &#039;question&#039;, &#039;reusable_lesson&#039;, &#039;confidence&#039;],&lt;br /&gt;
            limit: 6&lt;br /&gt;
          }&lt;br /&gt;
        }&lt;br /&gt;
      }, true);&lt;br /&gt;
    }).then(function (response) {&lt;br /&gt;
      if (!response || response.error || !response.result || response.result.isError) {&lt;br /&gt;
        throw new Error(&#039;Semantic search failed&#039;);&lt;br /&gt;
      }&lt;br /&gt;
      return response.result.structuredContent || {};&lt;br /&gt;
    });&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function textValue(value) {&lt;br /&gt;
    if (value === null || value === undefined) {&lt;br /&gt;
      return &#039;&#039;;&lt;br /&gt;
    }&lt;br /&gt;
    if (typeof value === &#039;string&#039; || typeof value === &#039;number&#039;) {&lt;br /&gt;
      return String(value);&lt;br /&gt;
    }&lt;br /&gt;
    return String(value.fulltext || value.displaytitle || value.raw || value.timestamp || &#039;&#039;);&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function firstValue(values, fallback) {&lt;br /&gt;
    return values &amp;amp;&amp;amp; values.length ? textValue(values[0]) : fallback;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function confidenceLabel(confidence) {&lt;br /&gt;
    if (confidence === &#039;high&#039;) { return &#039;신뢰도: 높음&#039;; }&lt;br /&gt;
    if (confidence === &#039;medium&#039;) { return &#039;신뢰도: 중간&#039;; }&lt;br /&gt;
    return &#039;신뢰도: 낮음&#039;;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function parseGraph(data) {&lt;br /&gt;
    var rawResults = data &amp;amp;&amp;amp; data.query &amp;amp;&amp;amp; data.query.results ? data.query.results : {};&lt;br /&gt;
    var entries = Array.isArray(rawResults)&lt;br /&gt;
      ? rawResults.map(function (item) { return [item.fulltext, item]; })&lt;br /&gt;
      : Object.keys(rawResults).map(function (key) { return [key, rawResults[key]]; });&lt;br /&gt;
    var nodes = [];&lt;br /&gt;
    var byId = Object.create(null);&lt;br /&gt;
&lt;br /&gt;
    entries.slice(0, NODE_LIMIT).forEach(function (entry, index) {&lt;br /&gt;
      var id = entry[0];&lt;br /&gt;
      var result = entry[1] || {};&lt;br /&gt;
      var printouts = result.printouts || {};&lt;br /&gt;
      var angle = index * 2.399963229728653;&lt;br /&gt;
      var radius = 38 * Math.sqrt(index + 1);&lt;br /&gt;
      var node = {&lt;br /&gt;
        id: id,&lt;br /&gt;
        title: firstValue(printouts[&#039;Lesson title&#039;], id.replace(/^Lesson:/, &#039;&#039;)),&lt;br /&gt;
        confidence: firstValue(printouts.Confidence, &#039;low&#039;).toLowerCase(),&lt;br /&gt;
        updatedAt: firstValue(printouts[&#039;Updated at&#039;], &#039;&#039;),&lt;br /&gt;
        x: Math.cos(angle) * radius,&lt;br /&gt;
        y: Math.sin(angle) * radius,&lt;br /&gt;
        vx: 0,&lt;br /&gt;
        vy: 0,&lt;br /&gt;
        degree: 0,&lt;br /&gt;
        printouts: printouts&lt;br /&gt;
      };&lt;br /&gt;
      nodes.push(node);&lt;br /&gt;
      byId[id] = node;&lt;br /&gt;
    });&lt;br /&gt;
&lt;br /&gt;
    var edges = [];&lt;br /&gt;
    nodes.forEach(function (node) {&lt;br /&gt;
      RELATIONS.forEach(function (relation) {&lt;br /&gt;
        var targets = node.printouts[relation.property] || [];&lt;br /&gt;
        targets.forEach(function (target) {&lt;br /&gt;
          var targetId = textValue(target);&lt;br /&gt;
          if (edges.length &amp;lt; EDGE_LIMIT &amp;amp;&amp;amp; byId[targetId] &amp;amp;&amp;amp; targetId !== node.id) {&lt;br /&gt;
            edges.push({ source: node, target: byId[targetId], relation: relation });&lt;br /&gt;
            node.degree += 1;&lt;br /&gt;
            byId[targetId].degree += 1;&lt;br /&gt;
          }&lt;br /&gt;
        });&lt;br /&gt;
      });&lt;br /&gt;
      delete node.printouts;&lt;br /&gt;
    });&lt;br /&gt;
    return { nodes: nodes, edges: edges };&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function makeElement(tag, className, text) {&lt;br /&gt;
    var element = document.createElement(tag);&lt;br /&gt;
    if (className) {&lt;br /&gt;
      element.className = className;&lt;br /&gt;
    }&lt;br /&gt;
    if (text !== undefined) {&lt;br /&gt;
      element.textContent = text;&lt;br /&gt;
    }&lt;br /&gt;
    return element;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function createSemanticSearch() {&lt;br /&gt;
    if (!semanticRoot) {&lt;br /&gt;
      return;&lt;br /&gt;
    }&lt;br /&gt;
    semanticRoot.replaceChildren();&lt;br /&gt;
    var intro = makeElement(&#039;div&#039;, &#039;s3rm-semantic-intro&#039;);&lt;br /&gt;
    intro.appendChild(makeElement(&#039;div&#039;, &#039;s3rm-semantic-icon&#039;, &#039;⌕&#039;));&lt;br /&gt;
    var introCopy = makeElement(&#039;div&#039;, &#039;&#039;);&lt;br /&gt;
    introCopy.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;검색&#039;));&lt;br /&gt;
    introCopy.appendChild(makeElement(&#039;p&#039;, &#039;&#039;, &#039;현상이나 궁금한 점을 문장으로 입력하세요. 표현이 달라도 관련 메모를 찾습니다.&#039;));&lt;br /&gt;
    intro.appendChild(introCopy);&lt;br /&gt;
&lt;br /&gt;
    var form = makeElement(&#039;form&#039;, &#039;s3rm-semantic-form&#039;);&lt;br /&gt;
    var input = makeElement(&#039;input&#039;, &#039;s3rm-semantic-input&#039;);&lt;br /&gt;
    input.type = &#039;search&#039;;&lt;br /&gt;
    input.name = &#039;query&#039;;&lt;br /&gt;
    input.maxLength = 500;&lt;br /&gt;
    input.placeholder = &#039;예: GPU 메모리는 남아 있는데 할당이 실패하는 이유는?&#039;;&lt;br /&gt;
    input.setAttribute(&#039;aria-label&#039;, &#039;메모 검색&#039;);&lt;br /&gt;
    var submit = makeElement(&#039;button&#039;, &#039;s3rm-semantic-submit&#039;, &#039;검색&#039;);&lt;br /&gt;
    submit.type = &#039;submit&#039;;&lt;br /&gt;
    form.append(input, submit);&lt;br /&gt;
&lt;br /&gt;
    var examples = makeElement(&#039;div&#039;, &#039;s3rm-semantic-examples&#039;);&lt;br /&gt;
    examples.appendChild(makeElement(&#039;span&#039;, &#039;&#039;, &#039;검색 예시&#039;));&lt;br /&gt;
    [&lt;br /&gt;
      &#039;재시도가 한꺼번에 몰리는 이유&#039;,&lt;br /&gt;
      &#039;GPU와 SSD 사이 병목을 줄이는 방법&#039;,&lt;br /&gt;
      &#039;긴 작업에서 지연이 갑자기 커지는 원인&#039;&lt;br /&gt;
    ].forEach(function (example) {&lt;br /&gt;
      var button = makeElement(&#039;button&#039;, &#039;&#039;, example);&lt;br /&gt;
      button.type = &#039;button&#039;;&lt;br /&gt;
      button.addEventListener(&#039;click&#039;, function () {&lt;br /&gt;
        input.value = example;&lt;br /&gt;
        form.requestSubmit();&lt;br /&gt;
      });&lt;br /&gt;
      examples.appendChild(button);&lt;br /&gt;
    });&lt;br /&gt;
&lt;br /&gt;
    var feedback = makeElement(&#039;div&#039;, &#039;s3rm-semantic-feedback&#039;);&lt;br /&gt;
    feedback.setAttribute(&#039;aria-live&#039;, &#039;polite&#039;);&lt;br /&gt;
    feedback.hidden = true;&lt;br /&gt;
    semanticRoot.append(intro, form, examples, feedback);&lt;br /&gt;
&lt;br /&gt;
    function renderSearchResults(payload, query) {&lt;br /&gt;
      var items = Array.isArray(payload.items) ? payload.items : [];&lt;br /&gt;
      feedback.replaceChildren();&lt;br /&gt;
      feedback.hidden = false;&lt;br /&gt;
      var resultHeader = makeElement(&#039;div&#039;, &#039;s3rm-semantic-result-header&#039;);&lt;br /&gt;
      resultHeader.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;“&#039; + query + &#039;” 검색 결과&#039;));&lt;br /&gt;
      resultHeader.appendChild(makeElement(&#039;span&#039;, &#039;s3rm-semantic-mode&#039;, items.length + &#039;개 메모&#039;));&lt;br /&gt;
      feedback.appendChild(resultHeader);&lt;br /&gt;
&lt;br /&gt;
      if (!items.length) {&lt;br /&gt;
        feedback.appendChild(makeElement(&#039;p&#039;, &#039;s3rm-semantic-empty&#039;, &#039;가까운 메모를 찾지 못했습니다. 표현을 조금 바꿔 다시 검색해 보세요.&#039;));&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      var list = makeElement(&#039;div&#039;, &#039;s3rm-semantic-results&#039;);&lt;br /&gt;
      items.forEach(function (item) {&lt;br /&gt;
        var card = makeElement(&#039;a&#039;, &#039;s3rm-semantic-result&#039;);&lt;br /&gt;
        card.href = mw.util.getUrl(item.id);&lt;br /&gt;
        var top = makeElement(&#039;div&#039;, &#039;s3rm-semantic-result-top&#039;);&lt;br /&gt;
        top.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, item.title || String(item.id).replace(/^Lesson:/, &#039;&#039;)));&lt;br /&gt;
        if (item.confidence) {&lt;br /&gt;
          top.appendChild(makeElement(&#039;span&#039;, &#039;s3rm-confidence s3rm-confidence-&#039; + item.confidence, confidenceLabel(item.confidence)));&lt;br /&gt;
        }&lt;br /&gt;
        card.appendChild(top);&lt;br /&gt;
        var summary = item.reusable_lesson || item.question || &#039;&#039;;&lt;br /&gt;
        if (summary) {&lt;br /&gt;
          card.appendChild(makeElement(&#039;p&#039;, &#039;&#039;, summary));&lt;br /&gt;
        }&lt;br /&gt;
        list.appendChild(card);&lt;br /&gt;
      });&lt;br /&gt;
      feedback.appendChild(list);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    form.addEventListener(&#039;submit&#039;, function (event) {&lt;br /&gt;
      event.preventDefault();&lt;br /&gt;
      var query = input.value.trim();&lt;br /&gt;
      if (query.length &amp;lt; 2) {&lt;br /&gt;
        feedback.replaceChildren(makeElement(&#039;p&#039;, &#039;s3rm-semantic-error&#039;, &#039;두 글자 이상 입력해 주세요.&#039;));&lt;br /&gt;
        feedback.hidden = false;&lt;br /&gt;
        input.focus();&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      submit.disabled = true;&lt;br /&gt;
      submit.textContent = &#039;찾는 중…&#039;;&lt;br /&gt;
      feedback.replaceChildren(makeElement(&#039;div&#039;, &#039;s3rm-semantic-searching&#039;, &#039;관련 메모를 찾고 있습니다.&#039;));&lt;br /&gt;
      feedback.hidden = false;&lt;br /&gt;
      searchSemantically(query).then(function (payload) {&lt;br /&gt;
        renderSearchResults(payload, query);&lt;br /&gt;
      }).catch(function () {&lt;br /&gt;
        feedback.replaceChildren();&lt;br /&gt;
        var error = makeElement(&#039;div&#039;, &#039;s3rm-semantic-error&#039;);&lt;br /&gt;
        error.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;지금은 검색을 사용할 수 없습니다.&#039;));&lt;br /&gt;
        var fallback = makeElement(&#039;a&#039;, &#039;&#039;, &#039;검색 열기&#039;);&lt;br /&gt;
        fallback.href = mw.util.getUrl(&#039;Special:Search&#039;, { search: query });&lt;br /&gt;
        error.appendChild(fallback);&lt;br /&gt;
        feedback.appendChild(error);&lt;br /&gt;
      }).finally(function () {&lt;br /&gt;
        submit.disabled = false;&lt;br /&gt;
        submit.textContent = &#039;검색&#039;;&lt;br /&gt;
      });&lt;br /&gt;
    });&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function renderEmpty() {&lt;br /&gt;
    root.replaceChildren();&lt;br /&gt;
    var empty = makeElement(&#039;div&#039;, &#039;s3rm-graph-empty&#039;);&lt;br /&gt;
    empty.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;아직 연결할 메모가 없습니다&#039;));&lt;br /&gt;
    empty.appendChild(makeElement(&#039;p&#039;, &#039;&#039;, &#039;첫 메모를 남기면 이곳에서 지식의 연결을 볼 수 있습니다.&#039;));&lt;br /&gt;
    var link = makeElement(&#039;a&#039;, &#039;s3rm-graph-primary-link&#039;, &#039;첫 메모 작성하기&#039;);&lt;br /&gt;
    link.href = &#039;#quick-start&#039;;&lt;br /&gt;
    empty.appendChild(link);&lt;br /&gt;
    root.appendChild(empty);&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function renderError() {&lt;br /&gt;
    root.replaceChildren();&lt;br /&gt;
    var fallback = makeElement(&#039;div&#039;, &#039;s3rm-graph-empty&#039;);&lt;br /&gt;
    fallback.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;그래프를 불러오지 못했습니다&#039;));&lt;br /&gt;
    fallback.appendChild(makeElement(&#039;p&#039;, &#039;&#039;, &#039;최근 메모와 검색은 계속 사용할 수 있습니다. 잠시 뒤 다시 시도해 주세요.&#039;));&lt;br /&gt;
    var link = makeElement(&#039;a&#039;, &#039;s3rm-graph-primary-link&#039;, &#039;전체 메모 보기&#039;);&lt;br /&gt;
    link.href = mw.util.getUrl(&#039;Special:AllPages/Lesson:&#039;);&lt;br /&gt;
    fallback.appendChild(link);&lt;br /&gt;
    root.appendChild(fallback);&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function createGraph(graph) {&lt;br /&gt;
    var nodes = graph.nodes;&lt;br /&gt;
    var edges = graph.edges;&lt;br /&gt;
    var selected = null;&lt;br /&gt;
    var hovered = null;&lt;br /&gt;
    var activeRelation = &#039;all&#039;;&lt;br /&gt;
    var transform = { x: 0, y: 0, scale: 1 };&lt;br /&gt;
    var dragging = null;&lt;br /&gt;
    var panning = false;&lt;br /&gt;
    var pointerStart = null;&lt;br /&gt;
    var animationFrame = 0;&lt;br /&gt;
    var simulationTicks = 0;&lt;br /&gt;
&lt;br /&gt;
    root.replaceChildren();&lt;br /&gt;
    var toolbar = makeElement(&#039;div&#039;, &#039;s3rm-graph-toolbar&#039;);&lt;br /&gt;
    var searchWrap = makeElement(&#039;div&#039;, &#039;s3rm-graph-search&#039;);&lt;br /&gt;
    var search = makeElement(&#039;input&#039;, &#039;s3rm-graph-search-input&#039;);&lt;br /&gt;
    search.type = &#039;search&#039;;&lt;br /&gt;
    search.placeholder = &#039;그래프에서 제목 필터&#039;;&lt;br /&gt;
    search.setAttribute(&#039;aria-label&#039;, &#039;그래프 제목 필터&#039;);&lt;br /&gt;
    search.autocomplete = &#039;off&#039;;&lt;br /&gt;
    var searchResults = makeElement(&#039;div&#039;, &#039;s3rm-graph-search-results&#039;);&lt;br /&gt;
    searchResults.hidden = true;&lt;br /&gt;
    searchWrap.append(search, searchResults);&lt;br /&gt;
&lt;br /&gt;
    var filter = makeElement(&#039;select&#039;, &#039;s3rm-graph-filter&#039;);&lt;br /&gt;
    filter.setAttribute(&#039;aria-label&#039;, &#039;관계 유형 필터&#039;);&lt;br /&gt;
    var allOption = makeElement(&#039;option&#039;, &#039;&#039;, &#039;모든 관계&#039;);&lt;br /&gt;
    allOption.value = &#039;all&#039;;&lt;br /&gt;
    filter.appendChild(allOption);&lt;br /&gt;
    RELATIONS.forEach(function (relation) {&lt;br /&gt;
      var option = makeElement(&#039;option&#039;, &#039;&#039;, relation.label);&lt;br /&gt;
      option.value = relation.key;&lt;br /&gt;
      filter.appendChild(option);&lt;br /&gt;
    });&lt;br /&gt;
    var reset = makeElement(&#039;button&#039;, &#039;s3rm-graph-reset&#039;, &#039;화면 맞춤&#039;);&lt;br /&gt;
    reset.type = &#039;button&#039;;&lt;br /&gt;
    toolbar.append(searchWrap, filter, reset);&lt;br /&gt;
&lt;br /&gt;
    var body = makeElement(&#039;div&#039;, &#039;s3rm-graph-body&#039;);&lt;br /&gt;
    var viewport = makeElement(&#039;div&#039;, &#039;s3rm-graph-viewport&#039;);&lt;br /&gt;
    var canvas = document.createElement(&#039;canvas&#039;);&lt;br /&gt;
    canvas.className = &#039;s3rm-graph-canvas&#039;;&lt;br /&gt;
    canvas.setAttribute(&#039;aria-hidden&#039;, &#039;true&#039;);&lt;br /&gt;
    viewport.appendChild(canvas);&lt;br /&gt;
    var hint = makeElement(&#039;div&#039;, &#039;s3rm-graph-hint&#039;, &#039;드래그하여 이동 · 스크롤하여 확대 · 두 번 눌러 메모 열기&#039;);&lt;br /&gt;
    viewport.appendChild(hint);&lt;br /&gt;
&lt;br /&gt;
    var inspector = makeElement(&#039;aside&#039;, &#039;s3rm-graph-inspector&#039;);&lt;br /&gt;
    inspector.setAttribute(&#039;aria-live&#039;, &#039;polite&#039;);&lt;br /&gt;
    body.append(viewport, inspector);&lt;br /&gt;
&lt;br /&gt;
    var footer = makeElement(&#039;div&#039;, &#039;s3rm-graph-footer&#039;);&lt;br /&gt;
    var counts = makeElement(&#039;span&#039;, &#039;s3rm-graph-counts&#039;, nodes.length + &#039;개 메모 · &#039; + edges.length + &#039;개 연결&#039;);&lt;br /&gt;
    var legend = makeElement(&#039;div&#039;, &#039;s3rm-graph-legend&#039;);&lt;br /&gt;
    RELATIONS.slice(0, 4).forEach(function (relation) {&lt;br /&gt;
      var item = makeElement(&#039;span&#039;, &#039;s3rm-graph-legend-item&#039;);&lt;br /&gt;
      var swatch = makeElement(&#039;i&#039;, &#039;&#039;);&lt;br /&gt;
      swatch.style.backgroundColor = relation.color;&lt;br /&gt;
      item.append(swatch, document.createTextNode(relation.label));&lt;br /&gt;
      legend.appendChild(item);&lt;br /&gt;
    });&lt;br /&gt;
    footer.append(counts, legend);&lt;br /&gt;
    root.append(toolbar, body, footer);&lt;br /&gt;
&lt;br /&gt;
    var context = canvas.getContext(&#039;2d&#039;);&lt;br /&gt;
    var size = { width: 0, height: 0, ratio: 1 };&lt;br /&gt;
&lt;br /&gt;
    function visibleEdges() {&lt;br /&gt;
      return activeRelation === &#039;all&#039;&lt;br /&gt;
        ? edges&lt;br /&gt;
        : edges.filter(function (edge) { return edge.relation.key === activeRelation; });&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function nodeRadius(node) {&lt;br /&gt;
      return 6 + Math.min(8, Math.sqrt(node.degree) * 2.2);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function confidenceColor(confidence) {&lt;br /&gt;
      if (confidence === &#039;high&#039;) { return &#039;#53c18b&#039;; }&lt;br /&gt;
      if (confidence === &#039;medium&#039;) { return &#039;#8ca8ff&#039;; }&lt;br /&gt;
      return &#039;#c0c5d8&#039;;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function resize() {&lt;br /&gt;
      var rect = viewport.getBoundingClientRect();&lt;br /&gt;
      size.width = Math.max(320, rect.width);&lt;br /&gt;
      size.height = Math.max(360, rect.height);&lt;br /&gt;
      size.ratio = Math.min(window.devicePixelRatio || 1, 2);&lt;br /&gt;
      canvas.width = Math.round(size.width * size.ratio);&lt;br /&gt;
      canvas.height = Math.round(size.height * size.ratio);&lt;br /&gt;
      canvas.style.width = size.width + &#039;px&#039;;&lt;br /&gt;
      canvas.style.height = size.height + &#039;px&#039;;&lt;br /&gt;
      draw();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function fitGraph() {&lt;br /&gt;
      if (!nodes.length) { return; }&lt;br /&gt;
      var xs = nodes.map(function (node) { return node.x; });&lt;br /&gt;
      var ys = nodes.map(function (node) { return node.y; });&lt;br /&gt;
      var minX = Math.min.apply(null, xs) - 50;&lt;br /&gt;
      var maxX = Math.max.apply(null, xs) + 50;&lt;br /&gt;
      var minY = Math.min.apply(null, ys) - 50;&lt;br /&gt;
      var maxY = Math.max.apply(null, ys) + 50;&lt;br /&gt;
      transform.scale = Math.max(0.25, Math.min(1.35, Math.min(&lt;br /&gt;
        size.width / Math.max(1, maxX - minX),&lt;br /&gt;
        size.height / Math.max(1, maxY - minY)&lt;br /&gt;
      )));&lt;br /&gt;
      transform.x = size.width / 2 - ((minX + maxX) / 2) * transform.scale;&lt;br /&gt;
      transform.y = size.height / 2 - ((minY + maxY) / 2) * transform.scale;&lt;br /&gt;
      draw();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function draw() {&lt;br /&gt;
      context.setTransform(size.ratio, 0, 0, size.ratio, 0, 0);&lt;br /&gt;
      context.clearRect(0, 0, size.width, size.height);&lt;br /&gt;
      context.save();&lt;br /&gt;
      context.translate(transform.x, transform.y);&lt;br /&gt;
      context.scale(transform.scale, transform.scale);&lt;br /&gt;
&lt;br /&gt;
      visibleEdges().forEach(function (edge) {&lt;br /&gt;
        var emphasized = selected &amp;amp;&amp;amp; (edge.source === selected || edge.target === selected);&lt;br /&gt;
        context.beginPath();&lt;br /&gt;
        context.moveTo(edge.source.x, edge.source.y);&lt;br /&gt;
        context.lineTo(edge.target.x, edge.target.y);&lt;br /&gt;
        context.strokeStyle = emphasized ? edge.relation.color : edge.relation.color + &#039;55&#039;;&lt;br /&gt;
        context.lineWidth = (emphasized ? 2.2 : 1) / transform.scale;&lt;br /&gt;
        context.stroke();&lt;br /&gt;
      });&lt;br /&gt;
&lt;br /&gt;
      nodes.forEach(function (node) {&lt;br /&gt;
        var isSelected = node === selected;&lt;br /&gt;
        var isHovered = node === hovered;&lt;br /&gt;
        var radius = nodeRadius(node);&lt;br /&gt;
        context.beginPath();&lt;br /&gt;
        context.arc(node.x, node.y, radius + (isSelected ? 4 : 0), 0, Math.PI * 2);&lt;br /&gt;
        context.fillStyle = isSelected ? &#039;#ffffff&#039; : confidenceColor(node.confidence);&lt;br /&gt;
        context.fill();&lt;br /&gt;
        context.lineWidth = (isSelected ? 3 : 1.5) / transform.scale;&lt;br /&gt;
        context.strokeStyle = isSelected ? &#039;#8ca8ff&#039; : &#039;#11182e&#039;;&lt;br /&gt;
        context.stroke();&lt;br /&gt;
&lt;br /&gt;
        if (isSelected || isHovered || (transform.scale &amp;gt; 1.05 &amp;amp;&amp;amp; node.degree &amp;gt; 1)) {&lt;br /&gt;
          context.font = (isSelected ? &#039;600 &#039; : &#039;&#039;) + (12 / transform.scale) + &#039;px sans-serif&#039;;&lt;br /&gt;
          context.textAlign = &#039;center&#039;;&lt;br /&gt;
          context.textBaseline = &#039;top&#039;;&lt;br /&gt;
          context.fillStyle = &#039;#f5f7ff&#039;;&lt;br /&gt;
          context.shadowColor = &#039;#080b16&#039;;&lt;br /&gt;
          context.shadowBlur = 5 / transform.scale;&lt;br /&gt;
          var label = node.title.length &amp;gt; 26 ? node.title.slice(0, 25) + &#039;…&#039; : node.title;&lt;br /&gt;
          context.fillText(label, node.x, node.y + radius + 7 / transform.scale);&lt;br /&gt;
          context.shadowBlur = 0;&lt;br /&gt;
        }&lt;br /&gt;
      });&lt;br /&gt;
      context.restore();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function simulate() {&lt;br /&gt;
      if (simulationTicks &amp;gt; 220) {&lt;br /&gt;
        fitGraph();&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      for (var pass = 0; pass &amp;lt; 3; pass += 1) {&lt;br /&gt;
        for (var i = 0; i &amp;lt; nodes.length; i += 1) {&lt;br /&gt;
          for (var j = i + 1; j &amp;lt; nodes.length; j += 1) {&lt;br /&gt;
            var first = nodes[i];&lt;br /&gt;
            var second = nodes[j];&lt;br /&gt;
            var dx = second.x - first.x || 0.01;&lt;br /&gt;
            var dy = second.y - first.y || 0.01;&lt;br /&gt;
            var distanceSquared = dx * dx + dy * dy + 80;&lt;br /&gt;
            var force = 62 / distanceSquared;&lt;br /&gt;
            first.vx -= dx * force;&lt;br /&gt;
            first.vy -= dy * force;&lt;br /&gt;
            second.vx += dx * force;&lt;br /&gt;
            second.vy += dy * force;&lt;br /&gt;
          }&lt;br /&gt;
        }&lt;br /&gt;
        edges.forEach(function (edge) {&lt;br /&gt;
          var dx = edge.target.x - edge.source.x;&lt;br /&gt;
          var dy = edge.target.y - edge.source.y;&lt;br /&gt;
          var distance = Math.sqrt(dx * dx + dy * dy) || 1;&lt;br /&gt;
          var force = (distance - 105) * 0.00075;&lt;br /&gt;
          edge.source.vx += dx * force;&lt;br /&gt;
          edge.source.vy += dy * force;&lt;br /&gt;
          edge.target.vx -= dx * force;&lt;br /&gt;
          edge.target.vy -= dy * force;&lt;br /&gt;
        });&lt;br /&gt;
        nodes.forEach(function (node) {&lt;br /&gt;
          node.vx += -node.x * 0.00035;&lt;br /&gt;
          node.vy += -node.y * 0.00035;&lt;br /&gt;
          node.vx *= 0.84;&lt;br /&gt;
          node.vy *= 0.84;&lt;br /&gt;
          node.x += node.vx;&lt;br /&gt;
          node.y += node.vy;&lt;br /&gt;
        });&lt;br /&gt;
        simulationTicks += 1;&lt;br /&gt;
      }&lt;br /&gt;
      draw();&lt;br /&gt;
      animationFrame = window.requestAnimationFrame(simulate);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function connectionSummary(node) {&lt;br /&gt;
      var connected = edges.filter(function (edge) {&lt;br /&gt;
        return edge.source === node || edge.target === node;&lt;br /&gt;
      });&lt;br /&gt;
      var summary = Object.create(null);&lt;br /&gt;
      connected.forEach(function (edge) {&lt;br /&gt;
        summary[edge.relation.label] = (summary[edge.relation.label] || 0) + 1;&lt;br /&gt;
      });&lt;br /&gt;
      return { edges: connected, counts: summary };&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function showInspector(node) {&lt;br /&gt;
      inspector.replaceChildren();&lt;br /&gt;
      if (!node) {&lt;br /&gt;
        var welcome = makeElement(&#039;div&#039;, &#039;s3rm-inspector-welcome&#039;);&lt;br /&gt;
        welcome.appendChild(makeElement(&#039;span&#039;, &#039;s3rm-inspector-symbol&#039;, &#039;⌁&#039;));&lt;br /&gt;
        welcome.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;메모를 선택하세요&#039;));&lt;br /&gt;
        welcome.appendChild(makeElement(&#039;p&#039;, &#039;&#039;, &#039;점 하나를 누르면 메모 정보와 연결된 기록을 보여 드립니다.&#039;));&lt;br /&gt;
        inspector.appendChild(welcome);&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      var summary = connectionSummary(node);&lt;br /&gt;
      inspector.appendChild(makeElement(&#039;div&#039;, &#039;s3rm-inspector-kicker&#039;, &#039;선택한 메모&#039;));&lt;br /&gt;
      inspector.appendChild(makeElement(&#039;h3&#039;, &#039;&#039;, node.title));&lt;br /&gt;
      var meta = makeElement(&#039;div&#039;, &#039;s3rm-inspector-meta&#039;);&lt;br /&gt;
      meta.appendChild(makeElement(&#039;span&#039;, &#039;s3rm-confidence s3rm-confidence-&#039; + node.confidence, confidenceLabel(node.confidence)));&lt;br /&gt;
      meta.appendChild(makeElement(&#039;span&#039;, &#039;&#039;, summary.edges.length + &#039;개 연결&#039;));&lt;br /&gt;
      inspector.appendChild(meta);&lt;br /&gt;
      if (Object.keys(summary.counts).length) {&lt;br /&gt;
        var relationList = makeElement(&#039;ul&#039;, &#039;s3rm-inspector-relations&#039;);&lt;br /&gt;
        Object.keys(summary.counts).forEach(function (label) {&lt;br /&gt;
          var item = makeElement(&#039;li&#039;, &#039;&#039;);&lt;br /&gt;
          item.append(makeElement(&#039;span&#039;, &#039;&#039;, label), makeElement(&#039;strong&#039;, &#039;&#039;, String(summary.counts[label])));&lt;br /&gt;
          relationList.appendChild(item);&lt;br /&gt;
        });&lt;br /&gt;
        inspector.appendChild(relationList);&lt;br /&gt;
      } else {&lt;br /&gt;
        inspector.appendChild(makeElement(&#039;p&#039;, &#039;s3rm-inspector-muted&#039;, &#039;아직 다른 메모와 연결되지 않았습니다.&#039;));&lt;br /&gt;
      }&lt;br /&gt;
      var open = makeElement(&#039;a&#039;, &#039;s3rm-graph-primary-link&#039;, &#039;메모 열기&#039;);&lt;br /&gt;
      open.href = mw.util.getUrl(node.id);&lt;br /&gt;
      inspector.appendChild(open);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function canvasPoint(event) {&lt;br /&gt;
      var rect = canvas.getBoundingClientRect();&lt;br /&gt;
      return { x: event.clientX - rect.left, y: event.clientY - rect.top };&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function worldPoint(event) {&lt;br /&gt;
      var point = canvasPoint(event);&lt;br /&gt;
      return {&lt;br /&gt;
        x: (point.x - transform.x) / transform.scale,&lt;br /&gt;
        y: (point.y - transform.y) / transform.scale&lt;br /&gt;
      };&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function hitNode(event) {&lt;br /&gt;
      var point = worldPoint(event);&lt;br /&gt;
      for (var i = nodes.length - 1; i &amp;gt;= 0; i -= 1) {&lt;br /&gt;
        var node = nodes[i];&lt;br /&gt;
        var dx = point.x - node.x;&lt;br /&gt;
        var dy = point.y - node.y;&lt;br /&gt;
        if (dx * dx + dy * dy &amp;lt;= Math.pow(nodeRadius(node) + 6 / transform.scale, 2)) {&lt;br /&gt;
          return node;&lt;br /&gt;
        }&lt;br /&gt;
      }&lt;br /&gt;
      return null;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function selectNode(node) {&lt;br /&gt;
      selected = node;&lt;br /&gt;
      showInspector(node);&lt;br /&gt;
      draw();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    canvas.addEventListener(&#039;pointerdown&#039;, function (event) {&lt;br /&gt;
      dragging = hitNode(event);&lt;br /&gt;
      panning = !dragging;&lt;br /&gt;
      pointerStart = canvasPoint(event);&lt;br /&gt;
      canvas.setPointerCapture(event.pointerId);&lt;br /&gt;
      canvas.classList.add(&#039;is-dragging&#039;);&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;pointermove&#039;, function (event) {&lt;br /&gt;
      if (dragging) {&lt;br /&gt;
        var world = worldPoint(event);&lt;br /&gt;
        dragging.x = world.x;&lt;br /&gt;
        dragging.y = world.y;&lt;br /&gt;
        dragging.vx = 0;&lt;br /&gt;
        dragging.vy = 0;&lt;br /&gt;
      } else if (panning &amp;amp;&amp;amp; pointerStart) {&lt;br /&gt;
        var point = canvasPoint(event);&lt;br /&gt;
        transform.x += point.x - pointerStart.x;&lt;br /&gt;
        transform.y += point.y - pointerStart.y;&lt;br /&gt;
        pointerStart = point;&lt;br /&gt;
      } else {&lt;br /&gt;
        hovered = hitNode(event);&lt;br /&gt;
        canvas.style.cursor = hovered ? &#039;pointer&#039; : &#039;grab&#039;;&lt;br /&gt;
      }&lt;br /&gt;
      draw();&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;pointerup&#039;, function (event) {&lt;br /&gt;
      var hit = hitNode(event);&lt;br /&gt;
      if (dragging &amp;amp;&amp;amp; hit === dragging) {&lt;br /&gt;
        selectNode(dragging);&lt;br /&gt;
      }&lt;br /&gt;
      dragging = null;&lt;br /&gt;
      panning = false;&lt;br /&gt;
      pointerStart = null;&lt;br /&gt;
      canvas.classList.remove(&#039;is-dragging&#039;);&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;dblclick&#039;, function (event) {&lt;br /&gt;
      var node = hitNode(event);&lt;br /&gt;
      if (node) {&lt;br /&gt;
        window.location.href = mw.util.getUrl(node.id);&lt;br /&gt;
      }&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;wheel&#039;, function (event) {&lt;br /&gt;
      event.preventDefault();&lt;br /&gt;
      var point = canvasPoint(event);&lt;br /&gt;
      var oldScale = transform.scale;&lt;br /&gt;
      var nextScale = Math.max(0.25, Math.min(3.5, oldScale * Math.exp(-event.deltaY * 0.0012)));&lt;br /&gt;
      transform.x = point.x - ((point.x - transform.x) / oldScale) * nextScale;&lt;br /&gt;
      transform.y = point.y - ((point.y - transform.y) / oldScale) * nextScale;&lt;br /&gt;
      transform.scale = nextScale;&lt;br /&gt;
      draw();&lt;br /&gt;
    }, { passive: false });&lt;br /&gt;
&lt;br /&gt;
    search.addEventListener(&#039;input&#039;, function () {&lt;br /&gt;
      var query = search.value.trim().toLocaleLowerCase(&#039;ko&#039;);&lt;br /&gt;
      searchResults.replaceChildren();&lt;br /&gt;
      if (!query) {&lt;br /&gt;
        searchResults.hidden = true;&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      var matches = nodes.filter(function (node) {&lt;br /&gt;
        return node.title.toLocaleLowerCase(&#039;ko&#039;).includes(query) || node.id.toLowerCase().includes(query);&lt;br /&gt;
      }).slice(0, 6);&lt;br /&gt;
      matches.forEach(function (node) {&lt;br /&gt;
        var button = makeElement(&#039;button&#039;, &#039;&#039;, node.title);&lt;br /&gt;
        button.type = &#039;button&#039;;&lt;br /&gt;
        button.addEventListener(&#039;click&#039;, function () {&lt;br /&gt;
          selectNode(node);&lt;br /&gt;
          transform.x = size.width / 2 - node.x * transform.scale;&lt;br /&gt;
          transform.y = size.height / 2 - node.y * transform.scale;&lt;br /&gt;
          search.value = node.title;&lt;br /&gt;
          searchResults.hidden = true;&lt;br /&gt;
          draw();&lt;br /&gt;
        });&lt;br /&gt;
        searchResults.appendChild(button);&lt;br /&gt;
      });&lt;br /&gt;
      if (!matches.length) {&lt;br /&gt;
        searchResults.appendChild(makeElement(&#039;span&#039;, &#039;&#039;, &#039;일치하는 메모가 없습니다.&#039;));&lt;br /&gt;
      }&lt;br /&gt;
      searchResults.hidden = false;&lt;br /&gt;
    });&lt;br /&gt;
    search.addEventListener(&#039;keydown&#039;, function (event) {&lt;br /&gt;
      if (event.key === &#039;Escape&#039;) {&lt;br /&gt;
        searchResults.hidden = true;&lt;br /&gt;
      } else if (event.key === &#039;Enter&#039;) {&lt;br /&gt;
        var first = searchResults.querySelector(&#039;button&#039;);&lt;br /&gt;
        if (first) { first.click(); }&lt;br /&gt;
      }&lt;br /&gt;
    });&lt;br /&gt;
    filter.addEventListener(&#039;change&#039;, function () {&lt;br /&gt;
      activeRelation = filter.value;&lt;br /&gt;
      counts.textContent = nodes.length + &#039;개 메모 · &#039; + visibleEdges().length + &#039;개 연결&#039;;&lt;br /&gt;
      draw();&lt;br /&gt;
    });&lt;br /&gt;
    reset.addEventListener(&#039;click&#039;, fitGraph);&lt;br /&gt;
&lt;br /&gt;
    showInspector(null);&lt;br /&gt;
    resize();&lt;br /&gt;
    var resizeObserver = window.ResizeObserver ? new ResizeObserver(resize) : null;&lt;br /&gt;
    if (resizeObserver) {&lt;br /&gt;
      resizeObserver.observe(viewport);&lt;br /&gt;
    } else {&lt;br /&gt;
      window.addEventListener(&#039;resize&#039;, resize);&lt;br /&gt;
    }&lt;br /&gt;
    animationFrame = window.requestAnimationFrame(simulate);&lt;br /&gt;
    window.addEventListener(&#039;pagehide&#039;, function () {&lt;br /&gt;
      window.cancelAnimationFrame(animationFrame);&lt;br /&gt;
      if (resizeObserver) { resizeObserver.disconnect(); }&lt;br /&gt;
    }, { once: true });&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  var askQuery = &#039;[[Category:Lessons]]&#039; +&lt;br /&gt;
    &#039;|?Lesson title|?Confidence|?Updated at&#039; +&lt;br /&gt;
    RELATIONS.map(function (relation) { return &#039;|?&#039; + relation.property; }).join(&#039;&#039;) +&lt;br /&gt;
    &#039;|sort=Updated at|order=descending|limit=&#039; + NODE_LIMIT;&lt;br /&gt;
&lt;br /&gt;
  createSemanticSearch();&lt;br /&gt;
&lt;br /&gt;
  mw.loader.using([&#039;mediawiki.api&#039;, &#039;mediawiki.util&#039;]).then(function () {&lt;br /&gt;
    return new mw.Api().get({ action: &#039;ask&#039;, query: askQuery, formatversion: 2 });&lt;br /&gt;
  }).then(function (data) {&lt;br /&gt;
    var graph = parseGraph(data);&lt;br /&gt;
    if (!graph.nodes.length) {&lt;br /&gt;
      renderEmpty();&lt;br /&gt;
      return;&lt;br /&gt;
    }&lt;br /&gt;
    createGraph(graph);&lt;br /&gt;
  }).catch(renderError);&lt;br /&gt;
}());&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%EB%AF%B8%EB%94%94%EC%96%B4%EC%9C%84%ED%82%A4:Common.css&amp;diff=2715</id>
		<title>미디어위키:Common.css</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%EB%AF%B8%EB%94%94%EC%96%B4%EC%9C%84%ED%82%A4:Common.css&amp;diff=2715"/>
		<updated>2026-07-19T01:59:06Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 원본 스키마 동기화&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;:root {&lt;br /&gt;
  --s3-navy: #1d2040;&lt;br /&gt;
  --s3-navy-deep: #171933;&lt;br /&gt;
  --s3-indigo: #3e42d1;&lt;br /&gt;
  --s3-indigo-dark: #393c9f;&lt;br /&gt;
  --s3-sky: #8eb2ee;&lt;br /&gt;
  --s3-paper: #f0f0f0;&lt;br /&gt;
  --s3-canvas: #f7f7fa;&lt;br /&gt;
  --s3-text: #212121;&lt;br /&gt;
  --s3-muted: #626579;&lt;br /&gt;
  --s3-green: #2f6b4f;&lt;br /&gt;
  --s3-amber: #8a6a1f;&lt;br /&gt;
  --s3-amber-bg: #f6eed6;&lt;br /&gt;
  --s3-line: #d9dbe7;&lt;br /&gt;
  --s3-motion: 150ms ease;&lt;br /&gt;
  --s3-font-sans: &amp;quot;Noto Sans KR&amp;quot;, &amp;quot;Apple SD Gothic Neo&amp;quot;, &amp;quot;Malgun Gothic&amp;quot;, Montserrat, Arial, sans-serif;&lt;br /&gt;
  --s3-font-serif: &amp;quot;Noto Serif KR&amp;quot;, &amp;quot;Nanum Myeongjo&amp;quot;, Bitter, Georgia, serif;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
::selection {&lt;br /&gt;
  background: var(--s3-sky);&lt;br /&gt;
  color: var(--s3-navy-deep);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
html {&lt;br /&gt;
  scroll-behavior: smooth;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
body,&lt;br /&gt;
.mw-body-content {&lt;br /&gt;
  color: var(--s3-text);&lt;br /&gt;
  font-family: var(--s3-font-sans);&lt;br /&gt;
  font-size: 17px;&lt;br /&gt;
  line-height: 1.72;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
body {&lt;br /&gt;
  background: var(--s3-canvas);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
a,&lt;br /&gt;
.mw-body-content a:visited {&lt;br /&gt;
  color: var(--s3-indigo-dark);&lt;br /&gt;
  transition: color var(--s3-motion);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
a:hover {&lt;br /&gt;
  color: var(--s3-indigo);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
a:focus-visible,&lt;br /&gt;
button:focus-visible,&lt;br /&gt;
input:focus-visible,&lt;br /&gt;
textarea:focus-visible,&lt;br /&gt;
select:focus-visible {&lt;br /&gt;
  outline: 3px solid var(--s3-indigo);&lt;br /&gt;
  outline-offset: 2px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.vector-header-container,&lt;br /&gt;
.skin-vector-2022 .mw-header {&lt;br /&gt;
  background: var(--s3-navy);&lt;br /&gt;
  color: #fff;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.vector-header-container a,&lt;br /&gt;
.vector-header-container .vector-menu-heading,&lt;br /&gt;
.vector-header-container .vector-dropdown-label {&lt;br /&gt;
  color: #fff;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.vector-header-container .mw-logo-wordmark,&lt;br /&gt;
.vector-header-container .mw-logo-tagline,&lt;br /&gt;
.vector-header-container .mw-ui-icon::before {&lt;br /&gt;
  filter: brightness(0) invert(1);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.vector-header-container .mw-logo-icon {&lt;br /&gt;
  filter: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.vector-header-container .mw-logo-wordmark {&lt;br /&gt;
  color: #fff;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.vector-header-container .cdx-text-input__input,&lt;br /&gt;
.vector-header-container input {&lt;br /&gt;
  background: #fff;&lt;br /&gt;
  color: var(--s3-text);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-page-container {&lt;br /&gt;
  background: #fff;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-body-header {&lt;br /&gt;
  background: var(--s3-paper);&lt;br /&gt;
  border: 0;&lt;br /&gt;
  padding: 1.6rem clamp(1.25rem, 4vw, 4.4rem);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-body h1,&lt;br /&gt;
.mw-body h2,&lt;br /&gt;
.mw-body h3,&lt;br /&gt;
.mw-body h4,&lt;br /&gt;
.mw-body-header .firstHeading {&lt;br /&gt;
  color: var(--s3-text);&lt;br /&gt;
  font-family: var(--s3-font-sans);&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-body h2 {&lt;br /&gt;
  border-bottom: 1px solid var(--s3-line);&lt;br /&gt;
  font-size: clamp(1.4rem, 2.2vw, 1.75rem);&lt;br /&gt;
  padding-bottom: 0.45rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-body h3 {&lt;br /&gt;
  font-size: 1.18rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.page-Main_Page .mw-body-content,&lt;br /&gt;
.page-연구_메모리 .mw-body-content {&lt;br /&gt;
  font-size: 16px;&lt;br /&gt;
  line-height: 1.65;&lt;br /&gt;
  margin-top: 1.2rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.page-Main_Page .mw-body-header,&lt;br /&gt;
.page-연구_메모리 .mw-body-header {&lt;br /&gt;
  background: #fff;&lt;br /&gt;
  border-bottom: 1px solid var(--s3-line);&lt;br /&gt;
  margin: 0 auto;&lt;br /&gt;
  max-width: 960px;&lt;br /&gt;
  padding: 1.1rem 0 0.85rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.page-Main_Page .mw-body-header .firstHeading,&lt;br /&gt;
.page-연구_메모리 .mw-body-header .firstHeading {&lt;br /&gt;
  font-size: clamp(1.8rem, 3vw, 2.15rem);&lt;br /&gt;
  line-height: 1.3;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.page-Main_Page .mw-body-content &amp;gt; .mw-parser-output,&lt;br /&gt;
.page-연구_메모리 .mw-body-content &amp;gt; .mw-parser-output {&lt;br /&gt;
  margin: 0 auto;&lt;br /&gt;
  max-width: 960px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-home-intro {&lt;br /&gt;
  border-bottom: 1px solid var(--s3-line);&lt;br /&gt;
  border-top: 3px solid var(--s3-navy);&lt;br /&gt;
  padding: 1.15rem 0 1.25rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-home-intro p {&lt;br /&gt;
  color: var(--s3-navy-deep);&lt;br /&gt;
  font-family: var(--s3-font-serif);&lt;br /&gt;
  font-size: clamp(1.12rem, 1.8vw, 1.32rem);&lt;br /&gt;
  line-height: 1.6;&lt;br /&gt;
  margin: 0;&lt;br /&gt;
  max-width: 760px;&lt;br /&gt;
  text-wrap: pretty;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-content-section {&lt;br /&gt;
  margin: 0 auto 3.25rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-home-actions {&lt;br /&gt;
  display: grid;&lt;br /&gt;
  grid-template-columns: repeat(3, minmax(0, 1fr));&lt;br /&gt;
  border-bottom: 1px solid var(--s3-line);&lt;br /&gt;
  margin-bottom: 3.25rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-action-card {&lt;br /&gt;
  border-right: 1px solid var(--s3-line);&lt;br /&gt;
  box-shadow: inset 0 0 0 0 transparent;&lt;br /&gt;
  min-width: 0;&lt;br /&gt;
  padding: 1.05rem 1.35rem 1.15rem;&lt;br /&gt;
  transition: box-shadow var(--s3-motion);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-action-card:hover {&lt;br /&gt;
  box-shadow: inset 0 3px 0 -1px var(--s3-indigo);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-action-card:first-child {&lt;br /&gt;
  padding-left: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-action-card:last-child {&lt;br /&gt;
  border-right: 0;&lt;br /&gt;
  padding-right: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-action-title {&lt;br /&gt;
  font-family: var(--s3-font-sans);&lt;br /&gt;
  font-size: 1rem;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  margin-bottom: 0.3rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-action-title a {&lt;br /&gt;
  color: var(--s3-indigo-dark);&lt;br /&gt;
  text-decoration-thickness: 1px;&lt;br /&gt;
  text-underline-offset: 0.2em;&lt;br /&gt;
  transition: text-decoration-thickness var(--s3-motion),&lt;br /&gt;
    text-underline-offset var(--s3-motion);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-action-card:hover .s3rm-action-title a {&lt;br /&gt;
  text-decoration-thickness: 2px;&lt;br /&gt;
  text-underline-offset: 0.24em;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-action-card p {&lt;br /&gt;
  color: var(--s3-muted);&lt;br /&gt;
  font-size: 0.9rem;&lt;br /&gt;
  line-height: 1.55;&lt;br /&gt;
  margin: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-section-header {&lt;br /&gt;
  align-items: baseline;&lt;br /&gt;
  border-bottom: 1px solid var(--s3-line);&lt;br /&gt;
  display: flex;&lt;br /&gt;
  gap: 1rem;&lt;br /&gt;
  justify-content: space-between;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-section-header h2 {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  border: 0;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  font-size: 1.35rem;&lt;br /&gt;
  gap: 0.55rem;&lt;br /&gt;
  margin: 0;&lt;br /&gt;
  padding: 0 0 0.65rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-section-header h2::before {&lt;br /&gt;
  background: var(--s3-indigo);&lt;br /&gt;
  content: &amp;quot;&amp;quot;;&lt;br /&gt;
  height: 1.1em;&lt;br /&gt;
  width: 4px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-section-link {&lt;br /&gt;
  font-size: 0.88rem;&lt;br /&gt;
  white-space: nowrap;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-recent-list {&lt;br /&gt;
  border-bottom: 1px solid var(--s3-line);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-lesson-summary {&lt;br /&gt;
  background: #fff;&lt;br /&gt;
  border-bottom: 1px solid var(--s3-line);&lt;br /&gt;
  box-shadow: inset 0 0 0 0 transparent;&lt;br /&gt;
  margin: 0 -0.9rem;&lt;br /&gt;
  padding: 0.85rem 0.9rem;&lt;br /&gt;
  transition: background var(--s3-motion), box-shadow var(--s3-motion);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-lesson-summary:hover {&lt;br /&gt;
  background: var(--s3-canvas);&lt;br /&gt;
  box-shadow: inset 3px 0 0 0 var(--s3-indigo);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-lesson-summary:last-child {&lt;br /&gt;
  border-bottom: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-lesson-summary-title {&lt;br /&gt;
  font-family: var(--s3-font-sans);&lt;br /&gt;
  font-size: 1rem;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  line-height: 1.45;&lt;br /&gt;
  margin: 0 0 0.45rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-lesson-summary-title a {&lt;br /&gt;
  color: var(--s3-navy);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-lesson-summary-meta {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  color: var(--s3-muted);&lt;br /&gt;
  display: flex;&lt;br /&gt;
  flex-wrap: wrap;&lt;br /&gt;
  font-size: 0.82rem;&lt;br /&gt;
  gap: 0.45rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-empty-state {&lt;br /&gt;
  background: var(--s3-canvas);&lt;br /&gt;
  border: 1px dashed var(--s3-line);&lt;br /&gt;
  color: var(--s3-muted);&lt;br /&gt;
  font-size: 0.92rem;&lt;br /&gt;
  padding: 1.4rem 1rem;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-create-panel {&lt;br /&gt;
  border-bottom: 1px solid var(--s3-line);&lt;br /&gt;
  border-top: 1px solid var(--s3-line);&lt;br /&gt;
  display: grid;&lt;br /&gt;
  gap: 2.25rem;&lt;br /&gt;
  grid-template-columns: minmax(0, 1fr) minmax(300px, 0.9fr);&lt;br /&gt;
  padding: 1.4rem 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-create-panel h2 {&lt;br /&gt;
  border: 0;&lt;br /&gt;
  font-size: 1.35rem;&lt;br /&gt;
  margin-top: 0;&lt;br /&gt;
  padding: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-form-launcher {&lt;br /&gt;
  background: var(--s3-paper);&lt;br /&gt;
  min-width: 0;&lt;br /&gt;
  padding: 1rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-form-label {&lt;br /&gt;
  color: var(--s3-navy);&lt;br /&gt;
  font-family: var(--s3-font-sans);&lt;br /&gt;
  font-size: 0.86rem;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  margin-bottom: 0.45rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-form-launcher .pfFormInputWrapper {&lt;br /&gt;
  max-width: 100%;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-form-launcher .oo-ui-horizontalLayout {&lt;br /&gt;
  display: flex;&lt;br /&gt;
  flex-wrap: wrap;&lt;br /&gt;
  gap: 0.5rem;&lt;br /&gt;
  max-width: 100%;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-form-launcher .oo-ui-horizontalLayout &amp;gt; .oo-ui-widget {&lt;br /&gt;
  margin-right: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-form-launcher .pfPageNameWithoutNamespace {&lt;br /&gt;
  flex: 1 1 14rem;&lt;br /&gt;
  min-width: 0;&lt;br /&gt;
  width: auto !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-form-launcher input[type=&amp;quot;text&amp;quot;] {&lt;br /&gt;
  box-sizing: border-box;&lt;br /&gt;
  max-width: 100%;&lt;br /&gt;
  min-width: 0;&lt;br /&gt;
  width: 100% !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-form-note {&lt;br /&gt;
  background: #f5f5f8;&lt;br /&gt;
  border-left: 4px solid var(--s3-indigo);&lt;br /&gt;
  margin: 1.2rem 0 1.8rem;&lt;br /&gt;
  padding: 1rem 1.2rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-confidence,&lt;br /&gt;
.s3rm-kind,&lt;br /&gt;
.s3rm-relation-label {&lt;br /&gt;
  background: #ececf5;&lt;br /&gt;
  color: var(--s3-navy);&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  font-family: var(--s3-font-sans);&lt;br /&gt;
  font-size: 0.78rem;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  line-height: 1.4;&lt;br /&gt;
  padding: 0.2rem 0.55rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-confidence-high { background: #dceee5; color: var(--s3-green); }&lt;br /&gt;
.s3rm-confidence-low { background: #f4e6e3; color: #8a3f34; }&lt;br /&gt;
&lt;br /&gt;
.s3rm-lesson-meta {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  border-bottom: 1px solid var(--s3-line);&lt;br /&gt;
  display: flex;&lt;br /&gt;
  flex-wrap: wrap;&lt;br /&gt;
  gap: 0.55rem;&lt;br /&gt;
  margin-bottom: 1.5rem;&lt;br /&gt;
  padding-bottom: 1rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-lesson-actions {&lt;br /&gt;
  display: flex;&lt;br /&gt;
  flex-wrap: wrap;&lt;br /&gt;
  gap: 0.55rem;&lt;br /&gt;
  margin: -0.55rem 0 1.4rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-lesson-actions a {&lt;br /&gt;
  border: 1px solid var(--s3-indigo-dark);&lt;br /&gt;
  color: var(--s3-indigo-dark);&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  font-size: 0.86rem;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  padding: 0.42rem 0.7rem;&lt;br /&gt;
  text-decoration: none;&lt;br /&gt;
  transition: background var(--s3-motion), color var(--s3-motion);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-lesson-actions a:hover {&lt;br /&gt;
  background: var(--s3-indigo-dark);&lt;br /&gt;
  color: #fff;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-lesson-table,&lt;br /&gt;
.formtable,&lt;br /&gt;
.smwtable {&lt;br /&gt;
  border-collapse: collapse;&lt;br /&gt;
  width: 100%;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.formtable {&lt;br /&gt;
  table-layout: fixed;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-lesson-table th,&lt;br /&gt;
.s3rm-lesson-table td,&lt;br /&gt;
.formtable th,&lt;br /&gt;
.formtable td {&lt;br /&gt;
  border: 0;&lt;br /&gt;
  border-bottom: 1px solid var(--s3-line);&lt;br /&gt;
  padding: 0.85rem 0.75rem;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-lesson-table th,&lt;br /&gt;
.formtable th {&lt;br /&gt;
  color: var(--s3-navy);&lt;br /&gt;
  font-family: var(--s3-font-sans);&lt;br /&gt;
  font-size: 0.86rem;&lt;br /&gt;
  min-width: 12rem;&lt;br /&gt;
  text-align: left;&lt;br /&gt;
  width: 12rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-evidence {&lt;br /&gt;
  background: #f7f7fa;&lt;br /&gt;
  border-left: 4px solid var(--s3-indigo);&lt;br /&gt;
  margin: 0.75rem 0;&lt;br /&gt;
  padding: 0.85rem 1rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-relation {&lt;br /&gt;
  border-bottom: 1px solid var(--s3-line);&lt;br /&gt;
  display: block;&lt;br /&gt;
  margin: 0.35rem 0;&lt;br /&gt;
  padding: 0.55rem 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.pfFormInputWrapper input,&lt;br /&gt;
.createboxInput,&lt;br /&gt;
.formtable input[type=&amp;quot;text&amp;quot;],&lt;br /&gt;
.formtable textarea,&lt;br /&gt;
.formtable select {&lt;br /&gt;
  border: 1px solid #a7a9ba;&lt;br /&gt;
  box-sizing: border-box;&lt;br /&gt;
  font-family: var(--s3-font-sans);&lt;br /&gt;
  max-width: 100%;&lt;br /&gt;
  padding: 0.55rem 0.65rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.formtable input[type=&amp;quot;text&amp;quot;],&lt;br /&gt;
.formtable textarea {&lt;br /&gt;
  width: 100%;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.oo-ui-buttonElement-framed.oo-ui-widget-enabled &amp;gt; .oo-ui-buttonElement-button,&lt;br /&gt;
.cdx-button--action-progressive.cdx-button--weight-primary {&lt;br /&gt;
  background: var(--s3-indigo);&lt;br /&gt;
  border-color: var(--s3-indigo);&lt;br /&gt;
  color: #fff;&lt;br /&gt;
  min-height: 44px;&lt;br /&gt;
  transition: background var(--s3-motion);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.oo-ui-buttonElement-framed.oo-ui-widget-enabled &amp;gt; .oo-ui-buttonElement-button:hover,&lt;br /&gt;
.cdx-button--action-progressive.cdx-button--weight-primary:hover {&lt;br /&gt;
  background: var(--s3-indigo-dark);&lt;br /&gt;
  border-color: var(--s3-indigo-dark);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Knowledge graph home */&lt;br /&gt;
.page-Main_Page .mw-body-header,&lt;br /&gt;
.page-연구_메모리 .mw-body-header,&lt;br /&gt;
.page-Main_Page .mw-body-content &amp;gt; .mw-parser-output,&lt;br /&gt;
.page-연구_메모리 .mw-body-content &amp;gt; .mw-parser-output {&lt;br /&gt;
  max-width: 1440px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.page-Main_Page .mw-body-content,&lt;br /&gt;
.page-연구_메모리 .mw-body-content {&lt;br /&gt;
  margin-top: 0.75rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-home-intro {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  border: 0;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  min-height: 2.7rem;&lt;br /&gt;
  padding: 0 0 0.75rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-home-intro p {&lt;br /&gt;
  color: var(--s3-muted);&lt;br /&gt;
  font-family: var(--s3-font-sans);&lt;br /&gt;
  font-size: 0.94rem;&lt;br /&gt;
  line-height: 1.55;&lt;br /&gt;
  max-width: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-eyebrow {&lt;br /&gt;
  color: var(--s3-indigo);&lt;br /&gt;
  font-size: 0.7rem;&lt;br /&gt;
  font-weight: 800;&lt;br /&gt;
  letter-spacing: 0.06em;&lt;br /&gt;
  line-height: 1.4;&lt;br /&gt;
  margin-bottom: 0.35rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-section {&lt;br /&gt;
  background: #fff;&lt;br /&gt;
  border: 1px solid var(--s3-line);&lt;br /&gt;
  box-shadow: 0 16px 42px rgba(23, 25, 51, 0.09);&lt;br /&gt;
  color: var(--s3-text);&lt;br /&gt;
  margin: 0 0 3.2rem;&lt;br /&gt;
  overflow: hidden;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-heading {&lt;br /&gt;
  align-items: flex-end;&lt;br /&gt;
  border-bottom: 1px solid var(--s3-line);&lt;br /&gt;
  display: flex;&lt;br /&gt;
  gap: 2rem;&lt;br /&gt;
  justify-content: space-between;&lt;br /&gt;
  padding: 1.35rem 1.5rem 1.2rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-heading .s3rm-eyebrow {&lt;br /&gt;
  color: var(--s3-indigo);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-heading h2 {&lt;br /&gt;
  border: 0;&lt;br /&gt;
  color: var(--s3-navy);&lt;br /&gt;
  font-size: clamp(1.25rem, 2vw, 1.65rem);&lt;br /&gt;
  line-height: 1.35;&lt;br /&gt;
  margin: 0 0 0.35rem;&lt;br /&gt;
  padding: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-heading p {&lt;br /&gt;
  color: var(--s3-muted);&lt;br /&gt;
  font-size: 0.88rem;&lt;br /&gt;
  line-height: 1.5;&lt;br /&gt;
  margin: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-shortcuts {&lt;br /&gt;
  display: flex;&lt;br /&gt;
  flex: 0 0 auto;&lt;br /&gt;
  gap: 0.55rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-shortcuts a {&lt;br /&gt;
  border: 1px solid var(--s3-indigo-dark);&lt;br /&gt;
  color: var(--s3-indigo-dark) !important;&lt;br /&gt;
  font-size: 0.8rem;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  padding: 0.42rem 0.72rem;&lt;br /&gt;
  text-decoration: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-shortcuts a:last-child {&lt;br /&gt;
  background: var(--s3-indigo);&lt;br /&gt;
  border-color: var(--s3-indigo);&lt;br /&gt;
  color: #fff !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-search {&lt;br /&gt;
  background: var(--s3-canvas);&lt;br /&gt;
  border-bottom: 1px solid var(--s3-line);&lt;br /&gt;
  padding: 1.1rem 1.5rem 1.2rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-search-loading {&lt;br /&gt;
  color: var(--s3-muted);&lt;br /&gt;
  font-size: 0.82rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-intro {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  gap: 0.75rem;&lt;br /&gt;
  margin-bottom: 0.8rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-icon {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  background: #ececf5;&lt;br /&gt;
  border: 1px solid var(--s3-line);&lt;br /&gt;
  color: var(--s3-indigo);&lt;br /&gt;
  display: flex;&lt;br /&gt;
  flex: 0 0 2.35rem;&lt;br /&gt;
  font-size: 1.35rem;&lt;br /&gt;
  height: 2.35rem;&lt;br /&gt;
  justify-content: center;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-intro strong {&lt;br /&gt;
  color: var(--s3-navy);&lt;br /&gt;
  display: block;&lt;br /&gt;
  font-size: 0.92rem;&lt;br /&gt;
  margin-bottom: 0.1rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-intro p {&lt;br /&gt;
  color: var(--s3-muted);&lt;br /&gt;
  font-size: 0.75rem;&lt;br /&gt;
  line-height: 1.45;&lt;br /&gt;
  margin: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-form {&lt;br /&gt;
  display: flex;&lt;br /&gt;
  gap: 0.55rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-input {&lt;br /&gt;
  background: #fff;&lt;br /&gt;
  border: 1px solid #a7a9ba;&lt;br /&gt;
  border-radius: 0;&lt;br /&gt;
  box-sizing: border-box;&lt;br /&gt;
  color: var(--s3-text);&lt;br /&gt;
  flex: 1 1 auto;&lt;br /&gt;
  font-family: var(--s3-font-sans);&lt;br /&gt;
  font-size: 0.93rem;&lt;br /&gt;
  height: 3rem;&lt;br /&gt;
  min-width: 0;&lt;br /&gt;
  padding: 0 0.95rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-input::placeholder {&lt;br /&gt;
  color: #777b8f;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-submit {&lt;br /&gt;
  background: var(--s3-indigo);&lt;br /&gt;
  border: 1px solid var(--s3-indigo);&lt;br /&gt;
  border-radius: 0;&lt;br /&gt;
  color: #fff;&lt;br /&gt;
  cursor: pointer;&lt;br /&gt;
  flex: 0 0 auto;&lt;br /&gt;
  font-family: var(--s3-font-sans);&lt;br /&gt;
  font-size: 0.84rem;&lt;br /&gt;
  font-weight: 800;&lt;br /&gt;
  min-width: 7rem;&lt;br /&gt;
  padding: 0 1rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-submit:hover {&lt;br /&gt;
  background: var(--s3-indigo-dark);&lt;br /&gt;
  border-color: var(--s3-indigo-dark);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-submit:disabled {&lt;br /&gt;
  cursor: wait;&lt;br /&gt;
  opacity: 0.7;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-examples {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  flex-wrap: wrap;&lt;br /&gt;
  gap: 0.4rem;&lt;br /&gt;
  margin-top: 0.55rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-examples &amp;gt; span {&lt;br /&gt;
  color: var(--s3-muted);&lt;br /&gt;
  font-size: 0.67rem;&lt;br /&gt;
  margin-right: 0.1rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-examples button {&lt;br /&gt;
  background: #fff;&lt;br /&gt;
  border: 1px solid var(--s3-line);&lt;br /&gt;
  border-radius: 1rem;&lt;br /&gt;
  color: var(--s3-indigo-dark);&lt;br /&gt;
  cursor: pointer;&lt;br /&gt;
  font-family: var(--s3-font-sans);&lt;br /&gt;
  font-size: 0.67rem;&lt;br /&gt;
  padding: 0.25rem 0.55rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-examples button:hover {&lt;br /&gt;
  background: #ececf5;&lt;br /&gt;
  border-color: #b9bcce;&lt;br /&gt;
  color: var(--s3-navy);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-feedback {&lt;br /&gt;
  border-top: 1px solid var(--s3-line);&lt;br /&gt;
  margin-top: 0.9rem;&lt;br /&gt;
  padding-top: 0.9rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-result-header {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  gap: 1rem;&lt;br /&gt;
  justify-content: space-between;&lt;br /&gt;
  margin-bottom: 0.6rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-result-header &amp;gt; strong {&lt;br /&gt;
  color: var(--s3-navy);&lt;br /&gt;
  font-size: 0.78rem;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-mode {&lt;br /&gt;
  color: var(--s3-indigo-dark);&lt;br /&gt;
  font-size: 0.67rem;&lt;br /&gt;
  white-space: nowrap;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-results {&lt;br /&gt;
  display: grid;&lt;br /&gt;
  gap: 0.5rem;&lt;br /&gt;
  grid-template-columns: repeat(3, minmax(0, 1fr));&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-result {&lt;br /&gt;
  background: #fff;&lt;br /&gt;
  border: 1px solid var(--s3-line);&lt;br /&gt;
  box-sizing: border-box;&lt;br /&gt;
  color: var(--s3-navy) !important;&lt;br /&gt;
  min-width: 0;&lt;br /&gt;
  padding: 0.75rem;&lt;br /&gt;
  text-decoration: none;&lt;br /&gt;
  transition: background var(--s3-motion), border-color var(--s3-motion);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-result:hover {&lt;br /&gt;
  background: #f1f2f7;&lt;br /&gt;
  border-color: var(--s3-indigo);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-result-top {&lt;br /&gt;
  align-items: flex-start;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  gap: 0.55rem;&lt;br /&gt;
  justify-content: space-between;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-result-top &amp;gt; strong {&lt;br /&gt;
  font-size: 0.76rem;&lt;br /&gt;
  line-height: 1.4;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-result .s3rm-confidence {&lt;br /&gt;
  flex: 0 0 auto;&lt;br /&gt;
  font-size: 0.58rem;&lt;br /&gt;
  padding: 0.12rem 0.35rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-result p {&lt;br /&gt;
  color: var(--s3-muted);&lt;br /&gt;
  display: -webkit-box;&lt;br /&gt;
  font-size: 0.68rem;&lt;br /&gt;
  -webkit-box-orient: vertical;&lt;br /&gt;
  -webkit-line-clamp: 2;&lt;br /&gt;
  line-height: 1.45;&lt;br /&gt;
  margin: 0.45rem 0 0;&lt;br /&gt;
  overflow: hidden;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-searching,&lt;br /&gt;
.s3rm-semantic-empty,&lt;br /&gt;
.s3rm-semantic-error {&lt;br /&gt;
  color: var(--s3-muted);&lt;br /&gt;
  font-size: 0.76rem;&lt;br /&gt;
  margin: 0;&lt;br /&gt;
  padding: 0.45rem 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-error {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  gap: 0.7rem;&lt;br /&gt;
  justify-content: space-between;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-error a {&lt;br /&gt;
  color: var(--s3-indigo-dark) !important;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-knowledge-graph {&lt;br /&gt;
  min-height: 590px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-loading,&lt;br /&gt;
.s3rm-graph-empty {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  flex-direction: column;&lt;br /&gt;
  justify-content: center;&lt;br /&gt;
  min-height: 590px;&lt;br /&gt;
  padding: 2rem;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-loading {&lt;br /&gt;
  color: #d7dcf2;&lt;br /&gt;
  gap: 0.25rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-loading &amp;gt; span:last-child {&lt;br /&gt;
  color: #8e96b4;&lt;br /&gt;
  font-size: 0.82rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-loading-mark {&lt;br /&gt;
  animation: s3rm-graph-pulse 1.4s ease-in-out infinite;&lt;br /&gt;
  background: #8ca8ff;&lt;br /&gt;
  border-radius: 50%;&lt;br /&gt;
  box-shadow: 18px 8px 0 -2px #53c18b, -18px 11px 0 -3px #a17bd7;&lt;br /&gt;
  display: block;&lt;br /&gt;
  height: 12px;&lt;br /&gt;
  margin: 0 0 1.25rem;&lt;br /&gt;
  width: 12px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
@keyframes s3rm-graph-pulse {&lt;br /&gt;
  0%, 100% { opacity: 0.45; transform: scale(0.92); }&lt;br /&gt;
  50% { opacity: 1; transform: scale(1.08); }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-toolbar {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  background: #151a2f;&lt;br /&gt;
  border-bottom: 1px solid #292f4b;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  gap: 0.6rem;&lt;br /&gt;
  padding: 0.7rem 0.85rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-search {&lt;br /&gt;
  flex: 1 1 19rem;&lt;br /&gt;
  max-width: 30rem;&lt;br /&gt;
  position: relative;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-search-input,&lt;br /&gt;
.s3rm-graph-filter,&lt;br /&gt;
.s3rm-graph-reset {&lt;br /&gt;
  background: #0f1324;&lt;br /&gt;
  border: 1px solid #353c5d;&lt;br /&gt;
  border-radius: 0;&lt;br /&gt;
  box-sizing: border-box;&lt;br /&gt;
  color: #edf0ff;&lt;br /&gt;
  font-family: var(--s3-font-sans);&lt;br /&gt;
  font-size: 0.82rem;&lt;br /&gt;
  height: 2.35rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-search-input {&lt;br /&gt;
  padding: 0 0.8rem 0 2.25rem;&lt;br /&gt;
  width: 100%;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-search::before {&lt;br /&gt;
  color: #7d87aa;&lt;br /&gt;
  content: &amp;quot;⌕&amp;quot;;&lt;br /&gt;
  font-size: 1.2rem;&lt;br /&gt;
  left: 0.75rem;&lt;br /&gt;
  line-height: 1;&lt;br /&gt;
  pointer-events: none;&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  top: 0.55rem;&lt;br /&gt;
  z-index: 2;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-search-input::placeholder {&lt;br /&gt;
  color: #7d87aa;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-filter,&lt;br /&gt;
.s3rm-graph-reset {&lt;br /&gt;
  padding: 0 0.75rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-reset {&lt;br /&gt;
  cursor: pointer;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-reset:hover {&lt;br /&gt;
  background: #252c4a;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-search-results {&lt;br /&gt;
  background: #181e35;&lt;br /&gt;
  border: 1px solid #3d456a;&lt;br /&gt;
  box-shadow: 0 12px 28px rgba(0, 0, 0, 0.34);&lt;br /&gt;
  left: 0;&lt;br /&gt;
  max-height: 17rem;&lt;br /&gt;
  overflow-y: auto;&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  right: 0;&lt;br /&gt;
  top: calc(100% + 0.35rem);&lt;br /&gt;
  z-index: 5;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-search-results button,&lt;br /&gt;
.s3rm-graph-search-results &amp;gt; span {&lt;br /&gt;
  background: transparent;&lt;br /&gt;
  border: 0;&lt;br /&gt;
  border-bottom: 1px solid #2d3452;&lt;br /&gt;
  box-sizing: border-box;&lt;br /&gt;
  color: #edf0ff;&lt;br /&gt;
  display: block;&lt;br /&gt;
  font-family: var(--s3-font-sans);&lt;br /&gt;
  font-size: 0.82rem;&lt;br /&gt;
  padding: 0.7rem 0.8rem;&lt;br /&gt;
  text-align: left;&lt;br /&gt;
  width: 100%;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-search-results button {&lt;br /&gt;
  cursor: pointer;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-search-results button:hover,&lt;br /&gt;
.s3rm-graph-search-results button:focus-visible {&lt;br /&gt;
  background: #252d50;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-body {&lt;br /&gt;
  display: grid;&lt;br /&gt;
  grid-template-columns: minmax(0, 1fr) 260px;&lt;br /&gt;
  min-height: 472px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-viewport {&lt;br /&gt;
  background-color: #0d1120;&lt;br /&gt;
  background-image: radial-gradient(circle, rgba(140, 168, 255, 0.13) 1px, transparent 1px);&lt;br /&gt;
  background-size: 24px 24px;&lt;br /&gt;
  min-width: 0;&lt;br /&gt;
  overflow: hidden;&lt;br /&gt;
  position: relative;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-canvas {&lt;br /&gt;
  display: block;&lt;br /&gt;
  height: 100%;&lt;br /&gt;
  min-height: 472px;&lt;br /&gt;
  touch-action: none;&lt;br /&gt;
  width: 100%;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-canvas.is-dragging {&lt;br /&gt;
  cursor: grabbing !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-hint {&lt;br /&gt;
  background: rgba(16, 20, 38, 0.86);&lt;br /&gt;
  bottom: 0.75rem;&lt;br /&gt;
  color: #8992b2;&lt;br /&gt;
  font-size: 0.68rem;&lt;br /&gt;
  left: 0.75rem;&lt;br /&gt;
  padding: 0.32rem 0.5rem;&lt;br /&gt;
  pointer-events: none;&lt;br /&gt;
  position: absolute;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-inspector {&lt;br /&gt;
  background: #151a2f;&lt;br /&gt;
  border-left: 1px solid #292f4b;&lt;br /&gt;
  box-sizing: border-box;&lt;br /&gt;
  min-width: 0;&lt;br /&gt;
  padding: 1.2rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-inspector-welcome {&lt;br /&gt;
  align-items: flex-start;&lt;br /&gt;
  color: #aeb5cf;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  flex-direction: column;&lt;br /&gt;
  height: 100%;&lt;br /&gt;
  justify-content: center;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-inspector-welcome strong {&lt;br /&gt;
  color: #f5f7ff;&lt;br /&gt;
  margin-bottom: 0.3rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-inspector-welcome p {&lt;br /&gt;
  font-size: 0.8rem;&lt;br /&gt;
  line-height: 1.55;&lt;br /&gt;
  margin: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-inspector-symbol {&lt;br /&gt;
  color: #8ca8ff;&lt;br /&gt;
  font-size: 2.2rem;&lt;br /&gt;
  line-height: 1;&lt;br /&gt;
  margin-bottom: 0.9rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-inspector-kicker {&lt;br /&gt;
  color: #8ca8ff;&lt;br /&gt;
  font-size: 0.68rem;&lt;br /&gt;
  font-weight: 800;&lt;br /&gt;
  letter-spacing: 0.08em;&lt;br /&gt;
  margin-bottom: 0.5rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-inspector h3 {&lt;br /&gt;
  color: #fff;&lt;br /&gt;
  font-size: 1.05rem;&lt;br /&gt;
  line-height: 1.45;&lt;br /&gt;
  margin: 0 0 0.85rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-inspector-meta {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  color: #9fa8c8;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  font-size: 0.76rem;&lt;br /&gt;
  gap: 0.6rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-inspector-relations {&lt;br /&gt;
  border-top: 1px solid #303754;&lt;br /&gt;
  list-style: none;&lt;br /&gt;
  margin: 1.1rem 0;&lt;br /&gt;
  padding: 0.6rem 0 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-inspector-relations li {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  color: #aeb5cf;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  font-size: 0.78rem;&lt;br /&gt;
  justify-content: space-between;&lt;br /&gt;
  padding: 0.25rem 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-inspector-relations strong {&lt;br /&gt;
  color: #fff;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-inspector-muted {&lt;br /&gt;
  color: #929ab8;&lt;br /&gt;
  font-size: 0.78rem;&lt;br /&gt;
  line-height: 1.5;&lt;br /&gt;
  margin: 1.1rem 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-primary-link {&lt;br /&gt;
  background: #687ee9;&lt;br /&gt;
  color: #fff !important;&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  font-size: 0.8rem;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  margin-top: 0.75rem;&lt;br /&gt;
  padding: 0.52rem 0.78rem;&lt;br /&gt;
  text-decoration: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-footer {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  background: #151a2f;&lt;br /&gt;
  border-top: 1px solid #292f4b;&lt;br /&gt;
  color: #8f98b8;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  font-size: 0.7rem;&lt;br /&gt;
  gap: 1rem;&lt;br /&gt;
  justify-content: space-between;&lt;br /&gt;
  min-height: 2.45rem;&lt;br /&gt;
  padding: 0 0.9rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-legend {&lt;br /&gt;
  display: flex;&lt;br /&gt;
  flex-wrap: wrap;&lt;br /&gt;
  gap: 0.8rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-legend-item {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  display: inline-flex;&lt;br /&gt;
  gap: 0.3rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-legend-item i {&lt;br /&gt;
  border-radius: 50%;&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  height: 0.4rem;&lt;br /&gt;
  width: 0.4rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-empty {&lt;br /&gt;
  background: #0d1120;&lt;br /&gt;
  color: #aeb5cf;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-empty strong {&lt;br /&gt;
  color: #fff;&lt;br /&gt;
  font-size: 1.05rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-empty p {&lt;br /&gt;
  font-size: 0.83rem;&lt;br /&gt;
  margin: 0.35rem 0 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-fallback {&lt;br /&gt;
  background: #0d1120;&lt;br /&gt;
  color: #c8cee5;&lt;br /&gt;
  min-height: 12rem;&lt;br /&gt;
  padding: 4rem 1rem;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-home-lower {&lt;br /&gt;
  display: grid;&lt;br /&gt;
  gap: clamp(2.5rem, 5vw, 5rem);&lt;br /&gt;
  grid-template-columns: minmax(0, 1.45fr) minmax(300px, 0.72fr);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-quick-start-header {&lt;br /&gt;
  align-items: flex-end;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-quick-start-header h2,&lt;br /&gt;
.s3rm-recent-section .s3rm-section-header h2 {&lt;br /&gt;
  padding-bottom: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-quick-start-header h2::before,&lt;br /&gt;
.s3rm-recent-section .s3rm-section-header h2::before {&lt;br /&gt;
  display: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-onboarding-steps {&lt;br /&gt;
  display: grid;&lt;br /&gt;
  gap: 0;&lt;br /&gt;
  grid-template-columns: repeat(3, minmax(0, 1fr));&lt;br /&gt;
  list-style: none;&lt;br /&gt;
  margin: 0;&lt;br /&gt;
  padding: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-onboarding-steps li {&lt;br /&gt;
  border-bottom: 1px solid var(--s3-line);&lt;br /&gt;
  border-right: 1px solid var(--s3-line);&lt;br /&gt;
  display: flex;&lt;br /&gt;
  gap: 0.75rem;&lt;br /&gt;
  min-width: 0;&lt;br /&gt;
  padding: 1rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-onboarding-steps li:first-child {&lt;br /&gt;
  padding-left: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-onboarding-steps li:last-child {&lt;br /&gt;
  border-right: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-onboarding-steps li &amp;gt; span {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  background: var(--s3-navy);&lt;br /&gt;
  border-radius: 50%;&lt;br /&gt;
  color: #fff;&lt;br /&gt;
  display: inline-flex;&lt;br /&gt;
  flex: 0 0 1.65rem;&lt;br /&gt;
  font-size: 0.72rem;&lt;br /&gt;
  font-weight: 800;&lt;br /&gt;
  height: 1.65rem;&lt;br /&gt;
  justify-content: center;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-onboarding-steps strong {&lt;br /&gt;
  color: var(--s3-navy);&lt;br /&gt;
  display: block;&lt;br /&gt;
  font-size: 0.86rem;&lt;br /&gt;
  margin: 0.1rem 0 0.22rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-onboarding-steps p {&lt;br /&gt;
  color: var(--s3-muted);&lt;br /&gt;
  font-size: 0.76rem;&lt;br /&gt;
  line-height: 1.48;&lt;br /&gt;
  margin: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-quick-start .s3rm-create-panel {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  border-top: 0;&lt;br /&gt;
  gap: 1.5rem;&lt;br /&gt;
  grid-template-columns: minmax(0, 1fr) minmax(290px, 0.8fr);&lt;br /&gt;
  padding: 1.2rem 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-quick-start .s3rm-create-panel h3 {&lt;br /&gt;
  font-size: 1rem;&lt;br /&gt;
  margin: 0 0 0.35rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-quick-start .s3rm-create-panel p {&lt;br /&gt;
  color: var(--s3-muted);&lt;br /&gt;
  font-size: 0.79rem;&lt;br /&gt;
  line-height: 1.55;&lt;br /&gt;
  margin: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-quick-start .s3rm-create-panel .s3rm-create-hint {&lt;br /&gt;
  margin-top: 0.35rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-quick-start .s3rm-form-launcher {&lt;br /&gt;
  background: #f1f2f7;&lt;br /&gt;
  border: 1px solid var(--s3-line);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-recent-section .s3rm-section-header {&lt;br /&gt;
  align-items: flex-end;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-recent-section .s3rm-recent-list {&lt;br /&gt;
  border-bottom: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-recent-section .s3rm-lesson-summary {&lt;br /&gt;
  margin-left: 0;&lt;br /&gt;
  margin-right: 0;&lt;br /&gt;
  padding-left: 0;&lt;br /&gt;
  padding-right: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
@media screen and (max-width: 960px) {&lt;br /&gt;
  .s3rm-home-lower {&lt;br /&gt;
    gap: 2.5rem;&lt;br /&gt;
    grid-template-columns: 1fr;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-create-panel {&lt;br /&gt;
    grid-template-columns: 1fr;&lt;br /&gt;
    gap: 1.1rem;&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
@media screen and (max-width: 720px) {&lt;br /&gt;
  .mw-body-header {&lt;br /&gt;
    padding: 1.35rem 1.15rem;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .page-Main_Page .mw-body-header,&lt;br /&gt;
  .page-연구_메모리 .mw-body-header {&lt;br /&gt;
    padding: 1rem 0 0.75rem;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-home-actions {&lt;br /&gt;
    grid-template-columns: 1fr;&lt;br /&gt;
    margin-bottom: 2.5rem;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-graph-section {&lt;br /&gt;
    margin-left: -0.75rem;&lt;br /&gt;
    margin-right: -0.75rem;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-graph-heading {&lt;br /&gt;
    align-items: flex-start;&lt;br /&gt;
    flex-direction: column;&lt;br /&gt;
    gap: 0.85rem;&lt;br /&gt;
    padding: 1.1rem;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-semantic-search {&lt;br /&gt;
    padding: 1rem;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-semantic-form {&lt;br /&gt;
    align-items: stretch;&lt;br /&gt;
    flex-direction: column;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-semantic-submit {&lt;br /&gt;
    min-height: 2.75rem;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-semantic-examples {&lt;br /&gt;
    display: none;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-semantic-results {&lt;br /&gt;
    grid-template-columns: 1fr;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-graph-toolbar {&lt;br /&gt;
    align-items: stretch;&lt;br /&gt;
    flex-wrap: wrap;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-graph-search {&lt;br /&gt;
    flex-basis: 100%;&lt;br /&gt;
    max-width: none;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-graph-filter {&lt;br /&gt;
    flex: 1;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-graph-body {&lt;br /&gt;
    grid-template-columns: 1fr;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-graph-canvas,&lt;br /&gt;
  .s3rm-graph-viewport {&lt;br /&gt;
    min-height: 390px;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-graph-inspector {&lt;br /&gt;
    border-left: 0;&lt;br /&gt;
    border-top: 1px solid #292f4b;&lt;br /&gt;
    min-height: 10rem;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-inspector-welcome {&lt;br /&gt;
    min-height: 8rem;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-graph-footer {&lt;br /&gt;
    align-items: flex-start;&lt;br /&gt;
    flex-direction: column;&lt;br /&gt;
    padding-bottom: 0.6rem;&lt;br /&gt;
    padding-top: 0.6rem;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-graph-legend {&lt;br /&gt;
    gap: 0.55rem;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-onboarding-steps {&lt;br /&gt;
    grid-template-columns: 1fr;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-onboarding-steps li,&lt;br /&gt;
  .s3rm-onboarding-steps li:first-child,&lt;br /&gt;
  .s3rm-onboarding-steps li:last-child {&lt;br /&gt;
    border-right: 0;&lt;br /&gt;
    padding: 0.8rem 0;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-action-card,&lt;br /&gt;
  .s3rm-action-card:first-child,&lt;br /&gt;
  .s3rm-action-card:last-child {&lt;br /&gt;
    border-bottom: 1px solid var(--s3-line);&lt;br /&gt;
    border-right: 0;&lt;br /&gt;
    padding: 0.85rem 0;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-action-card:last-child {&lt;br /&gt;
    border-bottom: 0;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-content-section {&lt;br /&gt;
    margin-bottom: 2.5rem;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-lesson-table th,&lt;br /&gt;
  .s3rm-lesson-table td,&lt;br /&gt;
  .formtable th,&lt;br /&gt;
  .formtable td {&lt;br /&gt;
    display: block;&lt;br /&gt;
    min-width: 0;&lt;br /&gt;
    width: auto;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-lesson-table th,&lt;br /&gt;
  .formtable th {&lt;br /&gt;
    border-bottom: 0;&lt;br /&gt;
    padding-bottom: 0.15rem;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-lesson-table td,&lt;br /&gt;
  .formtable td {&lt;br /&gt;
    padding-top: 0.15rem;&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
@media (prefers-reduced-motion: reduce) {&lt;br /&gt;
  html { scroll-behavior: auto; }&lt;br /&gt;
&lt;br /&gt;
  *,&lt;br /&gt;
  *::before,&lt;br /&gt;
  *::after {&lt;br /&gt;
    transition-duration: 0.01ms !important;&lt;br /&gt;
  }&lt;br /&gt;
}&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC/%EB%A9%94%EB%AA%A8_%EC%82%AC%EC%9A%A9%EB%B2%95&amp;diff=2706</id>
		<title>도움말:S3 연구 메모리/메모 사용법</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC/%EB%A9%94%EB%AA%A8_%EC%82%AC%EC%9A%A9%EB%B2%95&amp;diff=2706"/>
		<updated>2026-07-19T01:37:10Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 저장소 문서 동기화&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;!-- S3RM managed repository document; edit the Markdown source, not this page. --&amp;gt;&lt;br /&gt;
&#039;&#039;이 페이지는 저장소 문서에서 자동으로 동기화됩니다. 위키에서 직접 편집하지 마세요.&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
S3 연구 메모리는 프로젝트 일지나 실험 원본을 모으는 곳이 아닙니다. 다른 작업에서도 다시 참고할 만한 시행착오를 짧은 메모로 남깁니다.&lt;br /&gt;
&lt;br /&gt;
=== 먼저 검색하기 ===&lt;br /&gt;
&lt;br /&gt;
브라우저 메인 화면에서는 궁금한 점을 자연어 검색창에 문장으로 입력할 수 있습니다. 서버에서 의미 검색을 사용할 수 있으면 표현이 달라도 가까운 메모를 찾습니다. 그 아래 지식 그래프에서 점을 누르면 연결 수와 관계 유형을 볼 수 있고, 두 번 누르면 메모가 열립니다. 제목 검색, 관계 필터, 확대와 이동도 사용할 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
본문까지 찾으려면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Special:Search&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 에이전트에서는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;search_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 사용합니다. 프로젝트 이름보다 현상과 원인을 나타내는 말로 찾는 편이 잘 맞습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
메모리 단편화, retry storm, lock contention, cold start&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
같은 내용이 이미 있으면 새 메모를 만들지 않습니다. 기존 메모에 자료를 더하거나, 내용이 다른 이유를 관계로 연결하면 됩니다.&lt;br /&gt;
&lt;br /&gt;
=== 브라우저에서 메모 쓰기 ===&lt;br /&gt;
&lt;br /&gt;
로그인한 뒤 다음 화면을 엽니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
/index.php/연구_메모리#create-a-lesson&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
문서 ID에는 영문 소문자, 숫자, 밑줄, 하이픈만 씁니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Lesson:&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 입력하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
gpu_allocator_fragmentation&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
문서 ID는 에이전트가 메모를 인용할 때도 쓰므로 날짜, 사람 이름, 프로젝트에서만 통하는 줄임말은 피하세요. 화면에 보이는 제목은 나중에 바꿀 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
입력란에는 다음 내용을 적습니다.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 화면 !! 필드 !! 적을 내용&lt;br /&gt;
|-&lt;br /&gt;
| 제목 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;title&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 메모의 결론이 드러나는 짧은 제목&lt;br /&gt;
|-&lt;br /&gt;
| 궁금했던 점 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;question&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 시도를 시작한 이유&lt;br /&gt;
|-&lt;br /&gt;
| 해본 것 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;attempt&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 실제로 바꿨거나 비교한 것&lt;br /&gt;
|-&lt;br /&gt;
| 당시 조건 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;context&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 장비, 버전, 설정, 워크로드 등 결과에 영향을 준 조건&lt;br /&gt;
|-&lt;br /&gt;
| 실제 결과 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;observation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 직접 확인한 수치나 동작&lt;br /&gt;
|-&lt;br /&gt;
| 왜 그랬는지 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;interpretation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 가능한 원인과 아직 확실하지 않은 부분&lt;br /&gt;
|-&lt;br /&gt;
| 다음에 기억할 것 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;reusable_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 다른 작업에서도 써먹을 한 문장&lt;br /&gt;
|-&lt;br /&gt;
| 언제 맞는지 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;applicability&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 맞는 조건과 예외&lt;br /&gt;
|-&lt;br /&gt;
| 신뢰도 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;confidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 자료의 질에 따라 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;low&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;medium&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;high&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 중 선택&lt;br /&gt;
|-&lt;br /&gt;
| 관련 자료 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;evidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아래에 연결할 자료를 짧게 설명&lt;br /&gt;
|-&lt;br /&gt;
| 자료 출처 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;record_origin&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 직접 얻은 내용은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;lab&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 외부 자료를 정리했으면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;imported&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
관찰과 해석은 나눠 쓰세요. 예를 들어 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;동시 compaction 16개에서 p99가 18 ms에서 71 ms로 늘었다&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 관찰입니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;공유 writeback 제한 때문에 queue가 묶였다&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 해석이며, 새 자료가 나오면 달라질 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
저장한 메모는 바로 검색되고 메인 화면의 지식 그래프와 최근 메모에도 나타납니다.&lt;br /&gt;
&lt;br /&gt;
=== 자료 연결하기 ===&lt;br /&gt;
&lt;br /&gt;
메모마다 자료를 하나 이상 연결합니다. 원본 전체를 위키에 복사하지 말고 나중에 다시 찾을 수 있는 위치를 적으세요.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 입력란 !! 내용&lt;br /&gt;
|-&lt;br /&gt;
| 자료 ID || 메모 안에서 겹치지 않는 짧은 ID&lt;br /&gt;
|-&lt;br /&gt;
| 출처 || 저장소, 커밋, 실행 ID, 문서 절, 시간 구간 등&lt;br /&gt;
|-&lt;br /&gt;
| URL || 바로 열 수 있는 주소가 있을 때만 입력&lt;br /&gt;
|-&lt;br /&gt;
| 자료 종류 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;paper&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;code&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;dataset&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;log&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;benchmark&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;other&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 중 선택&lt;br /&gt;
|-&lt;br /&gt;
| 확인 범위 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;full_text&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;official_abstract&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;partial_source&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;metadata_only&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 중 선택&lt;br /&gt;
|-&lt;br /&gt;
| 메모 || 이 자료에서 확인할 부분과 한계&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
git yonsei-s3/kernel-probes@4f19a2e, results/lockstat/run-031/summary.json&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
확인 범위는 자료에서 실제로 읽은 범위이며 Lesson의 종합 신뢰도인 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;confidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 다릅니다. 서지정보만 확인했다면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;metadata_only&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 표시하고 기술 내용을 추측하지 않습니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;not_recorded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 이전 근거에 값이 없을 때 표시되는 호환값이므로 새 근거에는 선택하지 않습니다. 브라우저 양식에도 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;not_recorded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 기본값이 없고 새 근거에 선택할 수 없습니다. MCP로 새 근거를 붙일 때는 나머지 네 값 중 하나를 명시해야 합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;latest&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;대시보드&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 작성자만 아는 로컬 경로처럼 시간이 지나면 찾기 어려운 표현은 피합니다. 비밀번호, 토큰, 개인 정보, 공개할 수 없는 대화, 대용량 원시 데이터도 본문에 넣지 않습니다.&lt;br /&gt;
&lt;br /&gt;
=== 근거를 검증한 기록 남기기 ===&lt;br /&gt;
&lt;br /&gt;
자료를 실제로 읽어 Lesson 주장을 확인했다면 기존 evidence를 고치지 말고 새 검증 기록을 붙입니다. 같은 evidence를 나중에 다시 확인했다면 이전 기록을 수정·삭제하지 않고 새 기록을 append-only로 붙입니다. evidence별 마지막 기록이 현재 판정입니다.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 입력란 !! 내용&lt;br /&gt;
|-&lt;br /&gt;
| evidence ID || 검사한 기존 근거 ID&lt;br /&gt;
|-&lt;br /&gt;
| evidence digest || 읽기 시점의 evidence 내용 digest&lt;br /&gt;
|-&lt;br /&gt;
| 확인 범위 || 이번에 실제로 검사한 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;verification_basis&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| 자료 식별자 || 파일, 커밋, 데이터 버전, 문서 버전 등 고정 식별자&lt;br /&gt;
|-&lt;br /&gt;
| 자료 SHA-256 || 실제로 검사한 정확한 바이트의 SHA-256&lt;br /&gt;
|-&lt;br /&gt;
| 위치 || 페이지, 절, 라인, 실험 구간 등 주장을 확인한 위치&lt;br /&gt;
|-&lt;br /&gt;
| 확인 범위와 한계 || 읽은 범위와 제외한 부분&lt;br /&gt;
|-&lt;br /&gt;
| 판정 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supports&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;contradicts&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;inconclusive&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 중 하나&lt;br /&gt;
|-&lt;br /&gt;
| 연결 주장 || 자료가 실제로 확인한 Lesson 필드&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
원본 파일을 검사했다면 그 파일 바이트를 해시합니다. 원본 대신 저장·정규화한 텍스트를 검사했다면 그 정확한 텍스트의 UTF-8 바이트를 해시합니다. 이 경우 변환 방법과 보존한 산출물을 자료 식별자와 확인 범위에 명시합니다. MediaWiki revision의 저장 계정과 시각이 권위 출처입니다. 검증 기록의 출처는 현재 저장 계정과 정확히 같아야 하고, 기록 시각은 revision 저장 시각 전후 5분 안이며 이전 기록보다 늦어야 하는 보조 필드입니다. 기존 출처와 시각은 수정할 수 없습니다.&lt;br /&gt;
&lt;br /&gt;
high confidence는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;full_text&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 또는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;official_abstract&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 기반으로 한 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supports&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 검증이 observation, interpretation, reusable_lesson 세 필드를 모두 뒷받침할 때만 사용합니다. observation, interpretation, reusable_lesson을 수정한 후 결과 confidence가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;medium&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 또는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;high&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이면 실제로 바꾸는 주장과 연결한 같은 강도의 검증이 필요합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;contradicts&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;나 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;inconclusive&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 지지 근거로 세지 않습니다. 결과 confidence가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;low&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;인 교정은 기존 confidence를 낮추는 경우를 포함해 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;not_recorded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 아닌 basis evidence로 할 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
=== 다른 메모와 연결하기 ===&lt;br /&gt;
&lt;br /&gt;
관계는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;출발 메모 관계값 대상 메모&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 순서로 읽습니다.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 관계 !! 뜻&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supports&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 대상 메모와 맞는 자료나 원인을 보탬&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;contradicts&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 대상 메모의 주요 주장과 관찰이 충돌함&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;analogous_to&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 분야는 달라도 같은 원인이나 실패 형태를 보임&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;failed_because&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 대상 메모에 적힌 이유로 시도가 실패함&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;works_when&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 대상 메모의 조건에서 방법이 잘 작동함&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;suggests&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 다음 가설이나 시도를 제안함&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supersedes&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 새 메모가 이전 안내를 대신함. 상태는 자동으로 바뀌지 않음&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
연결 이유에는 두 메모가 닮거나 충돌하는 지점을 한두 문장으로 씁니다. 관련성이 애매하면 억지로 연결하지 않아도 됩니다.&lt;br /&gt;
&lt;br /&gt;
=== 메모 고치기 ===&lt;br /&gt;
&lt;br /&gt;
메모 화면에서 &#039;&#039;&#039;메모 편집&#039;&#039;&#039;을 누릅니다. 잘못 적은 수치나 해석을 고치고, 수정 요약에 이유를 짧게 남기세요. 이전 내용은 MediaWiki 이력에서 확인할 수 있습니다. 에이전트가 고칠 때는 현재 revision과 근거를 다시 읽은 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;revise_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 사용합니다. 같은 논문의 보강 내용을 evidence note나 새 Technical Review 메모에 숨기지 않습니다. 새 근거를 먼저 붙였다면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;attach_evidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 응답의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;current_revision_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 다음 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;revise_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;expected_revision_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 사용합니다. 그 사이 다른 변경이 있었다면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;get_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 다시 읽고 변경안을 재계산합니다.&lt;br /&gt;
&lt;br /&gt;
=== MCP로 찾기 ===&lt;br /&gt;
&lt;br /&gt;
처음에는 결과를 3~5개만 받고 필요한 필드만 요청하는 편이 빠릅니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
S3 Research Memory에서 tail latency와 관련된 메모를 최대 5개 찾아 줘.&lt;br /&gt;
id, title, reusable_lesson, applicability, confidence, citations만 반환해.&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
후보를 고른 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;get_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 자세히 읽고, 관계는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;find_related_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 다른 분야의 비슷한 사례는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;find_analogies&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 찾습니다. 근거 note와 추가 정보를 확인하려면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;citation_detail=full&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 지정합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;get_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 revision ID는 이어지는 조건부 수정과 감사에 사용합니다. 답변에 메모를 사용했다면 반환된 자료 인용도 같이 보관하세요.&lt;br /&gt;
&lt;br /&gt;
서버에서 의미 검색을 켠 경우에는 표현이 다른 문제도 찾도록 다음처럼 요청할 수 있습니다. 기본 검색은 계속 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;legacy&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이므로 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;hybrid_v1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 직접 지정합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
search_lessons를 retrieval_mode=hybrid_v1로 실행해,&lt;br /&gt;
문제의 제약과 해결 원리가 비슷한 메모를 최대 5개 찾아 줘.&lt;br /&gt;
effective_mode와 semantic_status도 알려 줘.&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
의미 유사도는 저장된 관계나 사실이 아닙니다. 후보의 적용 조건과 근거를 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;get_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 확인한 뒤 실제 관계를 남길지 판단하세요.&lt;br /&gt;
&lt;br /&gt;
=== 에이전트에게 작성을 맡길 때 ===&lt;br /&gt;
&lt;br /&gt;
쓰기 토큰이 있는 에이전트는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;create_lesson_draft&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;link_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;attach_evidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;record_evidence_verification&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;revise_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 쓸 수 있습니다. 에이전트가 만든 메모와 revision은 저장 즉시 검색됩니다. 상태 변경, 삭제, 이동, 관리자 작업은 MCP에서 할 수 없습니다. 다섯 쓰기 도구는 모두 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;operation_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 필요합니다. 같은 Lesson 안에서 일반 쓰기, revision, 검증 기록은 ID 충돌 범위를 공유합니다. 같은 종류·요청의 정확한 재시도일 때만 같은 ID와 같은 요청을 보내고, 다른 작업에는 새 ID를 사용합니다. Lesson 생성에는 구조화된 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;initial_evidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 필수이며 Lesson 본문과 같은 revision에 저장됩니다.&lt;br /&gt;
&lt;br /&gt;
자료 위치와 확인한 사실을 먼저 제공하고 다음을 요청하세요.&lt;br /&gt;
&lt;br /&gt;
* 같은 메모가 있는지 먼저 검색&lt;br /&gt;
* 관찰과 해석을 분리&lt;br /&gt;
* 맞는 조건과 예외를 명시&lt;br /&gt;
* 자료를 추측하지 않기&lt;br /&gt;
* 검증 기록이 있다면 자료 digest·위치·범위·판정·연결 주장을 함께 확인&lt;br /&gt;
* 작업이 끝나면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Lesson:&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; ID 반환&lt;br /&gt;
* 여러 건을 바꿨다면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;audit_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 revision·내용·근거·관계를 확인&lt;br /&gt;
반환된 ID를 브라우저에서 열어 내용과 자료 위치를 확인합니다. 틀린 부분은 같은 ID의 새 revision으로 고칩니다. 출처만 추가할 때는 evidence, 독립된 새 주장일 때만 새 Lesson과 관계를 사용합니다. 감사는 저장된 구조, digest, 검증 수령 기록의 일관성을 확인하며 외부 자료의 사실 진위를 자동으로 판정하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
도구 입력과 응답 형식은 [[Help:S3 연구 메모리/MCP 클라이언트|MCP 클라이언트 안내]], 필드와 관계의 기계 계약은 [[Help:S3 연구 메모리/아키텍처|아키텍처]]에 있습니다.&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC&amp;diff=2705</id>
		<title>연구 메모리</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC&amp;diff=2705"/>
		<updated>2026-07-19T01:37:10Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 원본 스키마 동기화&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;__NOTOC__&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-home-intro&amp;quot;&amp;gt;&lt;br /&gt;
{{:Help:S3 연구 메모리}}&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-graph-section&amp;quot; id=&amp;quot;knowledge-graph&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-graph-heading&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;div&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-eyebrow&amp;quot;&amp;gt;KNOWLEDGE GRAPH&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;h2&amp;gt;메모 사이의 연결을 탐색하세요&amp;lt;/h2&amp;gt;&lt;br /&gt;
&amp;lt;p&amp;gt;점은 메모, 선은 메모 사이의 관계입니다. 메모를 선택하면 연결된 기록을 함께 볼 수 있습니다.&amp;lt;/p&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-graph-shortcuts&amp;quot;&amp;gt;&lt;br /&gt;
[[Special:Search|전체 검색]]&lt;br /&gt;
[[연구 메모리#quick-start|새 메모]]&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;div id=&amp;quot;s3rm-semantic-search&amp;quot; class=&amp;quot;s3rm-semantic-search&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-semantic-search-loading&amp;quot;&amp;gt;자연어 검색을 준비하는 중입니다.&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;div id=&amp;quot;s3rm-knowledge-graph&amp;quot; class=&amp;quot;s3rm-knowledge-graph&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-graph-loading&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;span class=&amp;quot;s3rm-graph-loading-mark&amp;quot; aria-hidden=&amp;quot;true&amp;quot;&amp;gt;&amp;lt;/span&amp;gt;&lt;br /&gt;
&amp;lt;strong&amp;gt;메모 연결을 불러오는 중입니다&amp;lt;/strong&amp;gt;&lt;br /&gt;
&amp;lt;span&amp;gt;잠시만 기다려 주세요.&amp;lt;/span&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;noscript&amp;gt;&amp;lt;div class=&amp;quot;s3rm-graph-fallback&amp;quot;&amp;gt;그래프를 보려면 JavaScript를 켜세요. 아래 최근 메모와 전체 목록은 그대로 사용할 수 있습니다.&amp;lt;/div&amp;gt;&amp;lt;/noscript&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-home-lower&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-content-section s3rm-quick-start&amp;quot; id=&amp;quot;quick-start&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-section-header s3rm-quick-start-header&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;div&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-eyebrow&amp;quot;&amp;gt;QUICK START&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;h2&amp;gt;처음이라면 이렇게 시작하세요&amp;lt;/h2&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;span class=&amp;quot;s3rm-section-link&amp;quot;&amp;gt;[[Help:S3 연구 메모리|자세한 사용법]]&amp;lt;/span&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;ol class=&amp;quot;s3rm-onboarding-steps&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;li&amp;gt;&amp;lt;span&amp;gt;1&amp;lt;/span&amp;gt;&amp;lt;div&amp;gt;&amp;lt;strong&amp;gt;찾기&amp;lt;/strong&amp;gt;&amp;lt;p&amp;gt;검색창에 현상, 오류 메시지 또는 도구 이름을 입력하세요.&amp;lt;/p&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/li&amp;gt;&lt;br /&gt;
&amp;lt;li&amp;gt;&amp;lt;span&amp;gt;2&amp;lt;/span&amp;gt;&amp;lt;div&amp;gt;&amp;lt;strong&amp;gt;연결 보기&amp;lt;/strong&amp;gt;&amp;lt;p&amp;gt;그래프에서 점을 선택해 앞뒤 메모를 함께 살펴보세요.&amp;lt;/p&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/li&amp;gt;&lt;br /&gt;
&amp;lt;li&amp;gt;&amp;lt;span&amp;gt;3&amp;lt;/span&amp;gt;&amp;lt;div&amp;gt;&amp;lt;strong&amp;gt;남기기&amp;lt;/strong&amp;gt;&amp;lt;p&amp;gt;새로 알게 된 내용은 같은 저장소에 바로 기록하세요.&amp;lt;/p&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/li&amp;gt;&lt;br /&gt;
&amp;lt;/ol&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-create-panel&amp;quot; id=&amp;quot;create-a-lesson&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;div&amp;gt;&lt;br /&gt;
&amp;lt;h3&amp;gt;새 메모 남기기&amp;lt;/h3&amp;gt;&lt;br /&gt;
&amp;lt;p&amp;gt;누구나 읽을 수 있습니다. 새 메모를 쓰거나 고치려면 [[Special:UserLogin|로그인]]하세요. 저장한 메모는 바로 검색됩니다.&amp;lt;/p&amp;gt;&lt;br /&gt;
&amp;lt;p class=&amp;quot;s3rm-create-hint&amp;quot;&amp;gt;짧은 영문 문서 ID를 입력하세요. 소문자, 숫자, 밑줄(_)과 하이픈(-)을 쓸 수 있습니다.&amp;lt;/p&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-form-launcher&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-form-label&amp;quot;&amp;gt;문서 ID&amp;lt;/div&amp;gt;&lt;br /&gt;
{{#forminput:&lt;br /&gt;
 |form=Lesson&lt;br /&gt;
 |size=42&lt;br /&gt;
 |placeholder=gpu_memory_reuse&lt;br /&gt;
 |button text=작성 시작&lt;br /&gt;
 |query string=namespace=Lesson&lt;br /&gt;
}}&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-content-section s3rm-recent-section&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-section-header&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;div&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-eyebrow&amp;quot;&amp;gt;RECENT&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;h2&amp;gt;최근 메모&amp;lt;/h2&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;span class=&amp;quot;s3rm-section-link&amp;quot;&amp;gt;[[Special:AllPages/Lesson:|모든 메모]]&amp;lt;/span&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-recent-list&amp;quot;&amp;gt;&lt;br /&gt;
{{#ask:&lt;br /&gt;
 [[Category:Lessons]]&lt;br /&gt;
 |?Lesson title&lt;br /&gt;
 |?Confidence&lt;br /&gt;
 |?Updated at#ISO&lt;br /&gt;
 |sort=Updated at&lt;br /&gt;
 |order=descending&lt;br /&gt;
 |limit=6&lt;br /&gt;
 |format=plainlist&lt;br /&gt;
 |template=S3RM lesson summary&lt;br /&gt;
 |link=none&lt;br /&gt;
 |sep=&lt;br /&gt;
 |searchlabel=&lt;br /&gt;
 |default=&amp;lt;div class=&amp;quot;s3rm-empty-state&amp;quot;&amp;gt;아직 작성된 메모가 없습니다.&amp;lt;/div&amp;gt;&lt;br /&gt;
}}&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%EB%AF%B8%EB%94%94%EC%96%B4%EC%9C%84%ED%82%A4:Common.js&amp;diff=2704</id>
		<title>미디어위키:Common.js</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%EB%AF%B8%EB%94%94%EC%96%B4%EC%9C%84%ED%82%A4:Common.js&amp;diff=2704"/>
		<updated>2026-07-19T01:37:09Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 원본 스키마 설치&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;(function () {&lt;br /&gt;
  &#039;use strict&#039;;&lt;br /&gt;
&lt;br /&gt;
  var root = document.getElementById(&#039;s3rm-knowledge-graph&#039;);&lt;br /&gt;
  var semanticRoot = document.getElementById(&#039;s3rm-semantic-search&#039;);&lt;br /&gt;
  if (!root || !window.mw) {&lt;br /&gt;
    return;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  var RELATIONS = [&lt;br /&gt;
    { property: &#039;Supports&#039;, key: &#039;supports&#039;, label: &#039;뒷받침함&#039;, color: &#039;#5f7ae6&#039; },&lt;br /&gt;
    { property: &#039;Contradicts&#039;, key: &#039;contradicts&#039;, label: &#039;상충함&#039;, color: &#039;#d0605b&#039; },&lt;br /&gt;
    { property: &#039;Analogous to&#039;, key: &#039;analogous_to&#039;, label: &#039;유사함&#039;, color: &#039;#a17bd7&#039; },&lt;br /&gt;
    { property: &#039;Failed because&#039;, key: &#039;failed_because&#039;, label: &#039;실패 원인&#039;, color: &#039;#dc8b45&#039; },&lt;br /&gt;
    { property: &#039;Works when&#039;, key: &#039;works_when&#039;, label: &#039;작동 조건&#039;, color: &#039;#43a27b&#039; },&lt;br /&gt;
    { property: &#039;Suggests&#039;, key: &#039;suggests&#039;, label: &#039;후속 제안&#039;, color: &#039;#3f9db7&#039; },&lt;br /&gt;
    { property: &#039;Supersedes&#039;, key: &#039;supersedes&#039;, label: &#039;대체함&#039;, color: &#039;#7d849e&#039; }&lt;br /&gt;
  ];&lt;br /&gt;
  var NODE_LIMIT = 160;&lt;br /&gt;
  var EDGE_LIMIT = 480;&lt;br /&gt;
  var mcpReady = null;&lt;br /&gt;
&lt;br /&gt;
  function mcpRequest(payload, expectBody) {&lt;br /&gt;
    return window.fetch(&#039;/mcp&#039;, {&lt;br /&gt;
      method: &#039;POST&#039;,&lt;br /&gt;
      credentials: &#039;same-origin&#039;,&lt;br /&gt;
      headers: {&lt;br /&gt;
        &#039;Content-Type&#039;: &#039;application/json&#039;,&lt;br /&gt;
        &#039;Accept&#039;: &#039;application/json, text/event-stream&#039;&lt;br /&gt;
      },&lt;br /&gt;
      body: JSON.stringify(payload)&lt;br /&gt;
    }).then(function (response) {&lt;br /&gt;
      if (!response.ok) {&lt;br /&gt;
        throw new Error(&#039;MCP HTTP &#039; + response.status);&lt;br /&gt;
      }&lt;br /&gt;
      if (!expectBody) {&lt;br /&gt;
        return null;&lt;br /&gt;
      }&lt;br /&gt;
      return response.json();&lt;br /&gt;
    });&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function initializeMcp() {&lt;br /&gt;
    if (!mcpReady) {&lt;br /&gt;
      mcpReady = mcpRequest({&lt;br /&gt;
        jsonrpc: &#039;2.0&#039;,&lt;br /&gt;
        id: 1,&lt;br /&gt;
        method: &#039;initialize&#039;,&lt;br /&gt;
        params: {&lt;br /&gt;
          protocolVersion: &#039;2025-06-18&#039;,&lt;br /&gt;
          capabilities: {},&lt;br /&gt;
          clientInfo: { name: &#039;s3rm-wiki-search&#039;, version: &#039;1&#039; }&lt;br /&gt;
        }&lt;br /&gt;
      }, true).then(function (response) {&lt;br /&gt;
        if (!response || response.error) {&lt;br /&gt;
          throw new Error(&#039;MCP initialization failed&#039;);&lt;br /&gt;
        }&lt;br /&gt;
        return mcpRequest({&lt;br /&gt;
          jsonrpc: &#039;2.0&#039;,&lt;br /&gt;
          method: &#039;notifications/initialized&#039;,&lt;br /&gt;
          params: {}&lt;br /&gt;
        }, false);&lt;br /&gt;
      });&lt;br /&gt;
    }&lt;br /&gt;
    return mcpReady;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function searchSemantically(query) {&lt;br /&gt;
    return initializeMcp().then(function () {&lt;br /&gt;
      return mcpRequest({&lt;br /&gt;
        jsonrpc: &#039;2.0&#039;,&lt;br /&gt;
        id: 2,&lt;br /&gt;
        method: &#039;tools/call&#039;,&lt;br /&gt;
        params: {&lt;br /&gt;
          name: &#039;search_lessons&#039;,&lt;br /&gt;
          arguments: {&lt;br /&gt;
            query: query,&lt;br /&gt;
            retrieval_mode: &#039;hybrid_v1&#039;,&lt;br /&gt;
            fields: [&#039;title&#039;, &#039;question&#039;, &#039;reusable_lesson&#039;, &#039;confidence&#039;],&lt;br /&gt;
            limit: 6&lt;br /&gt;
          }&lt;br /&gt;
        }&lt;br /&gt;
      }, true);&lt;br /&gt;
    }).then(function (response) {&lt;br /&gt;
      if (!response || response.error || !response.result || response.result.isError) {&lt;br /&gt;
        throw new Error(&#039;Semantic search failed&#039;);&lt;br /&gt;
      }&lt;br /&gt;
      return response.result.structuredContent || {};&lt;br /&gt;
    });&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function textValue(value) {&lt;br /&gt;
    if (value === null || value === undefined) {&lt;br /&gt;
      return &#039;&#039;;&lt;br /&gt;
    }&lt;br /&gt;
    if (typeof value === &#039;string&#039; || typeof value === &#039;number&#039;) {&lt;br /&gt;
      return String(value);&lt;br /&gt;
    }&lt;br /&gt;
    return String(value.fulltext || value.displaytitle || value.raw || value.timestamp || &#039;&#039;);&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function firstValue(values, fallback) {&lt;br /&gt;
    return values &amp;amp;&amp;amp; values.length ? textValue(values[0]) : fallback;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function parseGraph(data) {&lt;br /&gt;
    var rawResults = data &amp;amp;&amp;amp; data.query &amp;amp;&amp;amp; data.query.results ? data.query.results : {};&lt;br /&gt;
    var entries = Array.isArray(rawResults)&lt;br /&gt;
      ? rawResults.map(function (item) { return [item.fulltext, item]; })&lt;br /&gt;
      : Object.keys(rawResults).map(function (key) { return [key, rawResults[key]]; });&lt;br /&gt;
    var nodes = [];&lt;br /&gt;
    var byId = Object.create(null);&lt;br /&gt;
&lt;br /&gt;
    entries.slice(0, NODE_LIMIT).forEach(function (entry, index) {&lt;br /&gt;
      var id = entry[0];&lt;br /&gt;
      var result = entry[1] || {};&lt;br /&gt;
      var printouts = result.printouts || {};&lt;br /&gt;
      var angle = index * 2.399963229728653;&lt;br /&gt;
      var radius = 38 * Math.sqrt(index + 1);&lt;br /&gt;
      var node = {&lt;br /&gt;
        id: id,&lt;br /&gt;
        title: firstValue(printouts[&#039;Lesson title&#039;], id.replace(/^Lesson:/, &#039;&#039;)),&lt;br /&gt;
        confidence: firstValue(printouts.Confidence, &#039;low&#039;).toLowerCase(),&lt;br /&gt;
        updatedAt: firstValue(printouts[&#039;Updated at&#039;], &#039;&#039;),&lt;br /&gt;
        x: Math.cos(angle) * radius,&lt;br /&gt;
        y: Math.sin(angle) * radius,&lt;br /&gt;
        vx: 0,&lt;br /&gt;
        vy: 0,&lt;br /&gt;
        degree: 0,&lt;br /&gt;
        printouts: printouts&lt;br /&gt;
      };&lt;br /&gt;
      nodes.push(node);&lt;br /&gt;
      byId[id] = node;&lt;br /&gt;
    });&lt;br /&gt;
&lt;br /&gt;
    var edges = [];&lt;br /&gt;
    nodes.forEach(function (node) {&lt;br /&gt;
      RELATIONS.forEach(function (relation) {&lt;br /&gt;
        var targets = node.printouts[relation.property] || [];&lt;br /&gt;
        targets.forEach(function (target) {&lt;br /&gt;
          var targetId = textValue(target);&lt;br /&gt;
          if (edges.length &amp;lt; EDGE_LIMIT &amp;amp;&amp;amp; byId[targetId] &amp;amp;&amp;amp; targetId !== node.id) {&lt;br /&gt;
            edges.push({ source: node, target: byId[targetId], relation: relation });&lt;br /&gt;
            node.degree += 1;&lt;br /&gt;
            byId[targetId].degree += 1;&lt;br /&gt;
          }&lt;br /&gt;
        });&lt;br /&gt;
      });&lt;br /&gt;
      delete node.printouts;&lt;br /&gt;
    });&lt;br /&gt;
    return { nodes: nodes, edges: edges };&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function makeElement(tag, className, text) {&lt;br /&gt;
    var element = document.createElement(tag);&lt;br /&gt;
    if (className) {&lt;br /&gt;
      element.className = className;&lt;br /&gt;
    }&lt;br /&gt;
    if (text !== undefined) {&lt;br /&gt;
      element.textContent = text;&lt;br /&gt;
    }&lt;br /&gt;
    return element;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function createSemanticSearch() {&lt;br /&gt;
    if (!semanticRoot) {&lt;br /&gt;
      return;&lt;br /&gt;
    }&lt;br /&gt;
    semanticRoot.replaceChildren();&lt;br /&gt;
    var intro = makeElement(&#039;div&#039;, &#039;s3rm-semantic-intro&#039;);&lt;br /&gt;
    intro.appendChild(makeElement(&#039;div&#039;, &#039;s3rm-semantic-icon&#039;, &#039;⌕&#039;));&lt;br /&gt;
    var introCopy = makeElement(&#039;div&#039;, &#039;&#039;);&lt;br /&gt;
    introCopy.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;자연어로 메모 찾기&#039;));&lt;br /&gt;
    introCopy.appendChild(makeElement(&#039;p&#039;, &#039;&#039;, &#039;현상이나 궁금한 점을 문장으로 입력하세요. 표현이 달라도 의미가 가까운 메모를 찾습니다.&#039;));&lt;br /&gt;
    intro.appendChild(introCopy);&lt;br /&gt;
&lt;br /&gt;
    var form = makeElement(&#039;form&#039;, &#039;s3rm-semantic-form&#039;);&lt;br /&gt;
    var input = makeElement(&#039;input&#039;, &#039;s3rm-semantic-input&#039;);&lt;br /&gt;
    input.type = &#039;search&#039;;&lt;br /&gt;
    input.name = &#039;query&#039;;&lt;br /&gt;
    input.maxLength = 500;&lt;br /&gt;
    input.placeholder = &#039;예: GPU 메모리는 남아 있는데 할당이 실패하는 이유는?&#039;;&lt;br /&gt;
    input.setAttribute(&#039;aria-label&#039;, &#039;자연어로 메모 검색&#039;);&lt;br /&gt;
    var submit = makeElement(&#039;button&#039;, &#039;s3rm-semantic-submit&#039;, &#039;의미로 찾기&#039;);&lt;br /&gt;
    submit.type = &#039;submit&#039;;&lt;br /&gt;
    form.append(input, submit);&lt;br /&gt;
&lt;br /&gt;
    var examples = makeElement(&#039;div&#039;, &#039;s3rm-semantic-examples&#039;);&lt;br /&gt;
    examples.appendChild(makeElement(&#039;span&#039;, &#039;&#039;, &#039;이렇게 물어보세요&#039;));&lt;br /&gt;
    [&lt;br /&gt;
      &#039;재시도가 한꺼번에 몰리는 이유&#039;,&lt;br /&gt;
      &#039;GPU와 SSD 사이 병목을 줄이는 방법&#039;,&lt;br /&gt;
      &#039;긴 작업에서 지연이 갑자기 커지는 원인&#039;&lt;br /&gt;
    ].forEach(function (example) {&lt;br /&gt;
      var button = makeElement(&#039;button&#039;, &#039;&#039;, example);&lt;br /&gt;
      button.type = &#039;button&#039;;&lt;br /&gt;
      button.addEventListener(&#039;click&#039;, function () {&lt;br /&gt;
        input.value = example;&lt;br /&gt;
        form.requestSubmit();&lt;br /&gt;
      });&lt;br /&gt;
      examples.appendChild(button);&lt;br /&gt;
    });&lt;br /&gt;
&lt;br /&gt;
    var feedback = makeElement(&#039;div&#039;, &#039;s3rm-semantic-feedback&#039;);&lt;br /&gt;
    feedback.setAttribute(&#039;aria-live&#039;, &#039;polite&#039;);&lt;br /&gt;
    feedback.hidden = true;&lt;br /&gt;
    semanticRoot.append(intro, form, examples, feedback);&lt;br /&gt;
&lt;br /&gt;
    function renderSearchResults(payload, query) {&lt;br /&gt;
      var items = Array.isArray(payload.items) ? payload.items : [];&lt;br /&gt;
      feedback.replaceChildren();&lt;br /&gt;
      feedback.hidden = false;&lt;br /&gt;
      var resultHeader = makeElement(&#039;div&#039;, &#039;s3rm-semantic-result-header&#039;);&lt;br /&gt;
      resultHeader.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;“&#039; + query + &#039;” 검색 결과&#039;));&lt;br /&gt;
      var modeLabel = payload.effective_mode === &#039;hybrid_v1&#039; ? &#039;의미 검색&#039; : &#039;일반 검색&#039;;&lt;br /&gt;
      resultHeader.appendChild(makeElement(&#039;span&#039;, &#039;s3rm-semantic-mode&#039;, modeLabel + &#039; · &#039; + items.length + &#039;개&#039;));&lt;br /&gt;
      feedback.appendChild(resultHeader);&lt;br /&gt;
&lt;br /&gt;
      if (!items.length) {&lt;br /&gt;
        feedback.appendChild(makeElement(&#039;p&#039;, &#039;s3rm-semantic-empty&#039;, &#039;가까운 메모를 찾지 못했습니다. 표현을 조금 바꿔 다시 검색해 보세요.&#039;));&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      var list = makeElement(&#039;div&#039;, &#039;s3rm-semantic-results&#039;);&lt;br /&gt;
      items.forEach(function (item) {&lt;br /&gt;
        var card = makeElement(&#039;a&#039;, &#039;s3rm-semantic-result&#039;);&lt;br /&gt;
        card.href = mw.util.getUrl(item.id);&lt;br /&gt;
        var top = makeElement(&#039;div&#039;, &#039;s3rm-semantic-result-top&#039;);&lt;br /&gt;
        top.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, item.title || String(item.id).replace(/^Lesson:/, &#039;&#039;)));&lt;br /&gt;
        if (item.confidence) {&lt;br /&gt;
          top.appendChild(makeElement(&#039;span&#039;, &#039;s3rm-confidence s3rm-confidence-&#039; + item.confidence, item.confidence));&lt;br /&gt;
        }&lt;br /&gt;
        card.appendChild(top);&lt;br /&gt;
        var summary = item.reusable_lesson || item.question || &#039;&#039;;&lt;br /&gt;
        if (summary) {&lt;br /&gt;
          card.appendChild(makeElement(&#039;p&#039;, &#039;&#039;, summary));&lt;br /&gt;
        }&lt;br /&gt;
        list.appendChild(card);&lt;br /&gt;
      });&lt;br /&gt;
      feedback.appendChild(list);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    form.addEventListener(&#039;submit&#039;, function (event) {&lt;br /&gt;
      event.preventDefault();&lt;br /&gt;
      var query = input.value.trim();&lt;br /&gt;
      if (query.length &amp;lt; 2) {&lt;br /&gt;
        feedback.replaceChildren(makeElement(&#039;p&#039;, &#039;s3rm-semantic-error&#039;, &#039;두 글자 이상 입력해 주세요.&#039;));&lt;br /&gt;
        feedback.hidden = false;&lt;br /&gt;
        input.focus();&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      submit.disabled = true;&lt;br /&gt;
      submit.textContent = &#039;찾는 중…&#039;;&lt;br /&gt;
      feedback.replaceChildren(makeElement(&#039;div&#039;, &#039;s3rm-semantic-searching&#039;, &#039;메모의 의미를 비교하고 있습니다.&#039;));&lt;br /&gt;
      feedback.hidden = false;&lt;br /&gt;
      searchSemantically(query).then(function (payload) {&lt;br /&gt;
        renderSearchResults(payload, query);&lt;br /&gt;
      }).catch(function () {&lt;br /&gt;
        feedback.replaceChildren();&lt;br /&gt;
        var error = makeElement(&#039;div&#039;, &#039;s3rm-semantic-error&#039;);&lt;br /&gt;
        error.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;지금은 자연어 검색을 사용할 수 없습니다.&#039;));&lt;br /&gt;
        var fallback = makeElement(&#039;a&#039;, &#039;&#039;, &#039;전체 검색 열기&#039;);&lt;br /&gt;
        fallback.href = mw.util.getUrl(&#039;Special:Search&#039;, { search: query });&lt;br /&gt;
        error.appendChild(fallback);&lt;br /&gt;
        feedback.appendChild(error);&lt;br /&gt;
      }).finally(function () {&lt;br /&gt;
        submit.disabled = false;&lt;br /&gt;
        submit.textContent = &#039;의미로 찾기&#039;;&lt;br /&gt;
      });&lt;br /&gt;
    });&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function renderEmpty() {&lt;br /&gt;
    root.replaceChildren();&lt;br /&gt;
    var empty = makeElement(&#039;div&#039;, &#039;s3rm-graph-empty&#039;);&lt;br /&gt;
    empty.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;아직 연결할 메모가 없습니다&#039;));&lt;br /&gt;
    empty.appendChild(makeElement(&#039;p&#039;, &#039;&#039;, &#039;첫 메모를 남기면 이곳에서 지식의 연결을 볼 수 있습니다.&#039;));&lt;br /&gt;
    var link = makeElement(&#039;a&#039;, &#039;s3rm-graph-primary-link&#039;, &#039;첫 메모 작성하기&#039;);&lt;br /&gt;
    link.href = &#039;#quick-start&#039;;&lt;br /&gt;
    empty.appendChild(link);&lt;br /&gt;
    root.appendChild(empty);&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function renderError() {&lt;br /&gt;
    root.replaceChildren();&lt;br /&gt;
    var fallback = makeElement(&#039;div&#039;, &#039;s3rm-graph-empty&#039;);&lt;br /&gt;
    fallback.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;그래프를 불러오지 못했습니다&#039;));&lt;br /&gt;
    fallback.appendChild(makeElement(&#039;p&#039;, &#039;&#039;, &#039;최근 메모와 검색은 계속 사용할 수 있습니다. 잠시 뒤 다시 시도해 주세요.&#039;));&lt;br /&gt;
    var link = makeElement(&#039;a&#039;, &#039;s3rm-graph-primary-link&#039;, &#039;전체 메모 보기&#039;);&lt;br /&gt;
    link.href = mw.util.getUrl(&#039;Special:AllPages/Lesson:&#039;);&lt;br /&gt;
    fallback.appendChild(link);&lt;br /&gt;
    root.appendChild(fallback);&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  function createGraph(graph) {&lt;br /&gt;
    var nodes = graph.nodes;&lt;br /&gt;
    var edges = graph.edges;&lt;br /&gt;
    var selected = null;&lt;br /&gt;
    var hovered = null;&lt;br /&gt;
    var activeRelation = &#039;all&#039;;&lt;br /&gt;
    var transform = { x: 0, y: 0, scale: 1 };&lt;br /&gt;
    var dragging = null;&lt;br /&gt;
    var panning = false;&lt;br /&gt;
    var pointerStart = null;&lt;br /&gt;
    var animationFrame = 0;&lt;br /&gt;
    var simulationTicks = 0;&lt;br /&gt;
&lt;br /&gt;
    root.replaceChildren();&lt;br /&gt;
    var toolbar = makeElement(&#039;div&#039;, &#039;s3rm-graph-toolbar&#039;);&lt;br /&gt;
    var searchWrap = makeElement(&#039;div&#039;, &#039;s3rm-graph-search&#039;);&lt;br /&gt;
    var search = makeElement(&#039;input&#039;, &#039;s3rm-graph-search-input&#039;);&lt;br /&gt;
    search.type = &#039;search&#039;;&lt;br /&gt;
    search.placeholder = &#039;메모 제목 검색&#039;;&lt;br /&gt;
    search.setAttribute(&#039;aria-label&#039;, &#039;그래프에서 메모 검색&#039;);&lt;br /&gt;
    search.autocomplete = &#039;off&#039;;&lt;br /&gt;
    var searchResults = makeElement(&#039;div&#039;, &#039;s3rm-graph-search-results&#039;);&lt;br /&gt;
    searchResults.hidden = true;&lt;br /&gt;
    searchWrap.append(search, searchResults);&lt;br /&gt;
&lt;br /&gt;
    var filter = makeElement(&#039;select&#039;, &#039;s3rm-graph-filter&#039;);&lt;br /&gt;
    filter.setAttribute(&#039;aria-label&#039;, &#039;관계 유형 필터&#039;);&lt;br /&gt;
    var allOption = makeElement(&#039;option&#039;, &#039;&#039;, &#039;모든 관계&#039;);&lt;br /&gt;
    allOption.value = &#039;all&#039;;&lt;br /&gt;
    filter.appendChild(allOption);&lt;br /&gt;
    RELATIONS.forEach(function (relation) {&lt;br /&gt;
      var option = makeElement(&#039;option&#039;, &#039;&#039;, relation.label);&lt;br /&gt;
      option.value = relation.key;&lt;br /&gt;
      filter.appendChild(option);&lt;br /&gt;
    });&lt;br /&gt;
    var reset = makeElement(&#039;button&#039;, &#039;s3rm-graph-reset&#039;, &#039;화면 맞춤&#039;);&lt;br /&gt;
    reset.type = &#039;button&#039;;&lt;br /&gt;
    toolbar.append(searchWrap, filter, reset);&lt;br /&gt;
&lt;br /&gt;
    var body = makeElement(&#039;div&#039;, &#039;s3rm-graph-body&#039;);&lt;br /&gt;
    var viewport = makeElement(&#039;div&#039;, &#039;s3rm-graph-viewport&#039;);&lt;br /&gt;
    var canvas = document.createElement(&#039;canvas&#039;);&lt;br /&gt;
    canvas.className = &#039;s3rm-graph-canvas&#039;;&lt;br /&gt;
    canvas.setAttribute(&#039;aria-hidden&#039;, &#039;true&#039;);&lt;br /&gt;
    viewport.appendChild(canvas);&lt;br /&gt;
    var hint = makeElement(&#039;div&#039;, &#039;s3rm-graph-hint&#039;, &#039;드래그하여 이동 · 스크롤하여 확대 · 두 번 눌러 메모 열기&#039;);&lt;br /&gt;
    viewport.appendChild(hint);&lt;br /&gt;
&lt;br /&gt;
    var inspector = makeElement(&#039;aside&#039;, &#039;s3rm-graph-inspector&#039;);&lt;br /&gt;
    inspector.setAttribute(&#039;aria-live&#039;, &#039;polite&#039;);&lt;br /&gt;
    body.append(viewport, inspector);&lt;br /&gt;
&lt;br /&gt;
    var footer = makeElement(&#039;div&#039;, &#039;s3rm-graph-footer&#039;);&lt;br /&gt;
    var counts = makeElement(&#039;span&#039;, &#039;s3rm-graph-counts&#039;, nodes.length + &#039;개 메모 · &#039; + edges.length + &#039;개 연결&#039;);&lt;br /&gt;
    var legend = makeElement(&#039;div&#039;, &#039;s3rm-graph-legend&#039;);&lt;br /&gt;
    RELATIONS.slice(0, 4).forEach(function (relation) {&lt;br /&gt;
      var item = makeElement(&#039;span&#039;, &#039;s3rm-graph-legend-item&#039;);&lt;br /&gt;
      var swatch = makeElement(&#039;i&#039;, &#039;&#039;);&lt;br /&gt;
      swatch.style.backgroundColor = relation.color;&lt;br /&gt;
      item.append(swatch, document.createTextNode(relation.label));&lt;br /&gt;
      legend.appendChild(item);&lt;br /&gt;
    });&lt;br /&gt;
    footer.append(counts, legend);&lt;br /&gt;
    root.append(toolbar, body, footer);&lt;br /&gt;
&lt;br /&gt;
    var context = canvas.getContext(&#039;2d&#039;);&lt;br /&gt;
    var size = { width: 0, height: 0, ratio: 1 };&lt;br /&gt;
&lt;br /&gt;
    function visibleEdges() {&lt;br /&gt;
      return activeRelation === &#039;all&#039;&lt;br /&gt;
        ? edges&lt;br /&gt;
        : edges.filter(function (edge) { return edge.relation.key === activeRelation; });&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function nodeRadius(node) {&lt;br /&gt;
      return 6 + Math.min(8, Math.sqrt(node.degree) * 2.2);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function confidenceColor(confidence) {&lt;br /&gt;
      if (confidence === &#039;high&#039;) { return &#039;#53c18b&#039;; }&lt;br /&gt;
      if (confidence === &#039;medium&#039;) { return &#039;#8ca8ff&#039;; }&lt;br /&gt;
      return &#039;#c0c5d8&#039;;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function resize() {&lt;br /&gt;
      var rect = viewport.getBoundingClientRect();&lt;br /&gt;
      size.width = Math.max(320, rect.width);&lt;br /&gt;
      size.height = Math.max(360, rect.height);&lt;br /&gt;
      size.ratio = Math.min(window.devicePixelRatio || 1, 2);&lt;br /&gt;
      canvas.width = Math.round(size.width * size.ratio);&lt;br /&gt;
      canvas.height = Math.round(size.height * size.ratio);&lt;br /&gt;
      canvas.style.width = size.width + &#039;px&#039;;&lt;br /&gt;
      canvas.style.height = size.height + &#039;px&#039;;&lt;br /&gt;
      draw();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function fitGraph() {&lt;br /&gt;
      if (!nodes.length) { return; }&lt;br /&gt;
      var xs = nodes.map(function (node) { return node.x; });&lt;br /&gt;
      var ys = nodes.map(function (node) { return node.y; });&lt;br /&gt;
      var minX = Math.min.apply(null, xs) - 50;&lt;br /&gt;
      var maxX = Math.max.apply(null, xs) + 50;&lt;br /&gt;
      var minY = Math.min.apply(null, ys) - 50;&lt;br /&gt;
      var maxY = Math.max.apply(null, ys) + 50;&lt;br /&gt;
      transform.scale = Math.max(0.25, Math.min(1.35, Math.min(&lt;br /&gt;
        size.width / Math.max(1, maxX - minX),&lt;br /&gt;
        size.height / Math.max(1, maxY - minY)&lt;br /&gt;
      )));&lt;br /&gt;
      transform.x = size.width / 2 - ((minX + maxX) / 2) * transform.scale;&lt;br /&gt;
      transform.y = size.height / 2 - ((minY + maxY) / 2) * transform.scale;&lt;br /&gt;
      draw();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function draw() {&lt;br /&gt;
      context.setTransform(size.ratio, 0, 0, size.ratio, 0, 0);&lt;br /&gt;
      context.clearRect(0, 0, size.width, size.height);&lt;br /&gt;
      context.save();&lt;br /&gt;
      context.translate(transform.x, transform.y);&lt;br /&gt;
      context.scale(transform.scale, transform.scale);&lt;br /&gt;
&lt;br /&gt;
      visibleEdges().forEach(function (edge) {&lt;br /&gt;
        var emphasized = selected &amp;amp;&amp;amp; (edge.source === selected || edge.target === selected);&lt;br /&gt;
        context.beginPath();&lt;br /&gt;
        context.moveTo(edge.source.x, edge.source.y);&lt;br /&gt;
        context.lineTo(edge.target.x, edge.target.y);&lt;br /&gt;
        context.strokeStyle = emphasized ? edge.relation.color : edge.relation.color + &#039;55&#039;;&lt;br /&gt;
        context.lineWidth = (emphasized ? 2.2 : 1) / transform.scale;&lt;br /&gt;
        context.stroke();&lt;br /&gt;
      });&lt;br /&gt;
&lt;br /&gt;
      nodes.forEach(function (node) {&lt;br /&gt;
        var isSelected = node === selected;&lt;br /&gt;
        var isHovered = node === hovered;&lt;br /&gt;
        var radius = nodeRadius(node);&lt;br /&gt;
        context.beginPath();&lt;br /&gt;
        context.arc(node.x, node.y, radius + (isSelected ? 4 : 0), 0, Math.PI * 2);&lt;br /&gt;
        context.fillStyle = isSelected ? &#039;#ffffff&#039; : confidenceColor(node.confidence);&lt;br /&gt;
        context.fill();&lt;br /&gt;
        context.lineWidth = (isSelected ? 3 : 1.5) / transform.scale;&lt;br /&gt;
        context.strokeStyle = isSelected ? &#039;#8ca8ff&#039; : &#039;#11182e&#039;;&lt;br /&gt;
        context.stroke();&lt;br /&gt;
&lt;br /&gt;
        if (isSelected || isHovered || (transform.scale &amp;gt; 1.05 &amp;amp;&amp;amp; node.degree &amp;gt; 1)) {&lt;br /&gt;
          context.font = (isSelected ? &#039;600 &#039; : &#039;&#039;) + (12 / transform.scale) + &#039;px sans-serif&#039;;&lt;br /&gt;
          context.textAlign = &#039;center&#039;;&lt;br /&gt;
          context.textBaseline = &#039;top&#039;;&lt;br /&gt;
          context.fillStyle = &#039;#f5f7ff&#039;;&lt;br /&gt;
          context.shadowColor = &#039;#080b16&#039;;&lt;br /&gt;
          context.shadowBlur = 5 / transform.scale;&lt;br /&gt;
          var label = node.title.length &amp;gt; 26 ? node.title.slice(0, 25) + &#039;…&#039; : node.title;&lt;br /&gt;
          context.fillText(label, node.x, node.y + radius + 7 / transform.scale);&lt;br /&gt;
          context.shadowBlur = 0;&lt;br /&gt;
        }&lt;br /&gt;
      });&lt;br /&gt;
      context.restore();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function simulate() {&lt;br /&gt;
      if (simulationTicks &amp;gt; 220) {&lt;br /&gt;
        fitGraph();&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      for (var pass = 0; pass &amp;lt; 3; pass += 1) {&lt;br /&gt;
        for (var i = 0; i &amp;lt; nodes.length; i += 1) {&lt;br /&gt;
          for (var j = i + 1; j &amp;lt; nodes.length; j += 1) {&lt;br /&gt;
            var first = nodes[i];&lt;br /&gt;
            var second = nodes[j];&lt;br /&gt;
            var dx = second.x - first.x || 0.01;&lt;br /&gt;
            var dy = second.y - first.y || 0.01;&lt;br /&gt;
            var distanceSquared = dx * dx + dy * dy + 80;&lt;br /&gt;
            var force = 62 / distanceSquared;&lt;br /&gt;
            first.vx -= dx * force;&lt;br /&gt;
            first.vy -= dy * force;&lt;br /&gt;
            second.vx += dx * force;&lt;br /&gt;
            second.vy += dy * force;&lt;br /&gt;
          }&lt;br /&gt;
        }&lt;br /&gt;
        edges.forEach(function (edge) {&lt;br /&gt;
          var dx = edge.target.x - edge.source.x;&lt;br /&gt;
          var dy = edge.target.y - edge.source.y;&lt;br /&gt;
          var distance = Math.sqrt(dx * dx + dy * dy) || 1;&lt;br /&gt;
          var force = (distance - 105) * 0.00075;&lt;br /&gt;
          edge.source.vx += dx * force;&lt;br /&gt;
          edge.source.vy += dy * force;&lt;br /&gt;
          edge.target.vx -= dx * force;&lt;br /&gt;
          edge.target.vy -= dy * force;&lt;br /&gt;
        });&lt;br /&gt;
        nodes.forEach(function (node) {&lt;br /&gt;
          node.vx += -node.x * 0.00035;&lt;br /&gt;
          node.vy += -node.y * 0.00035;&lt;br /&gt;
          node.vx *= 0.84;&lt;br /&gt;
          node.vy *= 0.84;&lt;br /&gt;
          node.x += node.vx;&lt;br /&gt;
          node.y += node.vy;&lt;br /&gt;
        });&lt;br /&gt;
        simulationTicks += 1;&lt;br /&gt;
      }&lt;br /&gt;
      draw();&lt;br /&gt;
      animationFrame = window.requestAnimationFrame(simulate);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function connectionSummary(node) {&lt;br /&gt;
      var connected = edges.filter(function (edge) {&lt;br /&gt;
        return edge.source === node || edge.target === node;&lt;br /&gt;
      });&lt;br /&gt;
      var summary = Object.create(null);&lt;br /&gt;
      connected.forEach(function (edge) {&lt;br /&gt;
        summary[edge.relation.label] = (summary[edge.relation.label] || 0) + 1;&lt;br /&gt;
      });&lt;br /&gt;
      return { edges: connected, counts: summary };&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function showInspector(node) {&lt;br /&gt;
      inspector.replaceChildren();&lt;br /&gt;
      if (!node) {&lt;br /&gt;
        var welcome = makeElement(&#039;div&#039;, &#039;s3rm-inspector-welcome&#039;);&lt;br /&gt;
        welcome.appendChild(makeElement(&#039;span&#039;, &#039;s3rm-inspector-symbol&#039;, &#039;⌁&#039;));&lt;br /&gt;
        welcome.appendChild(makeElement(&#039;strong&#039;, &#039;&#039;, &#039;메모를 선택하세요&#039;));&lt;br /&gt;
        welcome.appendChild(makeElement(&#039;p&#039;, &#039;&#039;, &#039;점 하나를 누르면 메모 정보와 연결된 기록을 보여 드립니다.&#039;));&lt;br /&gt;
        inspector.appendChild(welcome);&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      var summary = connectionSummary(node);&lt;br /&gt;
      inspector.appendChild(makeElement(&#039;div&#039;, &#039;s3rm-inspector-kicker&#039;, &#039;선택한 메모&#039;));&lt;br /&gt;
      inspector.appendChild(makeElement(&#039;h3&#039;, &#039;&#039;, node.title));&lt;br /&gt;
      var meta = makeElement(&#039;div&#039;, &#039;s3rm-inspector-meta&#039;);&lt;br /&gt;
      meta.appendChild(makeElement(&#039;span&#039;, &#039;s3rm-confidence s3rm-confidence-&#039; + node.confidence, node.confidence));&lt;br /&gt;
      meta.appendChild(makeElement(&#039;span&#039;, &#039;&#039;, summary.edges.length + &#039;개 연결&#039;));&lt;br /&gt;
      inspector.appendChild(meta);&lt;br /&gt;
      if (Object.keys(summary.counts).length) {&lt;br /&gt;
        var relationList = makeElement(&#039;ul&#039;, &#039;s3rm-inspector-relations&#039;);&lt;br /&gt;
        Object.keys(summary.counts).forEach(function (label) {&lt;br /&gt;
          var item = makeElement(&#039;li&#039;, &#039;&#039;);&lt;br /&gt;
          item.append(makeElement(&#039;span&#039;, &#039;&#039;, label), makeElement(&#039;strong&#039;, &#039;&#039;, String(summary.counts[label])));&lt;br /&gt;
          relationList.appendChild(item);&lt;br /&gt;
        });&lt;br /&gt;
        inspector.appendChild(relationList);&lt;br /&gt;
      } else {&lt;br /&gt;
        inspector.appendChild(makeElement(&#039;p&#039;, &#039;s3rm-inspector-muted&#039;, &#039;아직 다른 메모와 연결되지 않았습니다.&#039;));&lt;br /&gt;
      }&lt;br /&gt;
      var open = makeElement(&#039;a&#039;, &#039;s3rm-graph-primary-link&#039;, &#039;메모 열기&#039;);&lt;br /&gt;
      open.href = mw.util.getUrl(node.id);&lt;br /&gt;
      inspector.appendChild(open);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function canvasPoint(event) {&lt;br /&gt;
      var rect = canvas.getBoundingClientRect();&lt;br /&gt;
      return { x: event.clientX - rect.left, y: event.clientY - rect.top };&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function worldPoint(event) {&lt;br /&gt;
      var point = canvasPoint(event);&lt;br /&gt;
      return {&lt;br /&gt;
        x: (point.x - transform.x) / transform.scale,&lt;br /&gt;
        y: (point.y - transform.y) / transform.scale&lt;br /&gt;
      };&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function hitNode(event) {&lt;br /&gt;
      var point = worldPoint(event);&lt;br /&gt;
      for (var i = nodes.length - 1; i &amp;gt;= 0; i -= 1) {&lt;br /&gt;
        var node = nodes[i];&lt;br /&gt;
        var dx = point.x - node.x;&lt;br /&gt;
        var dy = point.y - node.y;&lt;br /&gt;
        if (dx * dx + dy * dy &amp;lt;= Math.pow(nodeRadius(node) + 6 / transform.scale, 2)) {&lt;br /&gt;
          return node;&lt;br /&gt;
        }&lt;br /&gt;
      }&lt;br /&gt;
      return null;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function selectNode(node) {&lt;br /&gt;
      selected = node;&lt;br /&gt;
      showInspector(node);&lt;br /&gt;
      draw();&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    canvas.addEventListener(&#039;pointerdown&#039;, function (event) {&lt;br /&gt;
      dragging = hitNode(event);&lt;br /&gt;
      panning = !dragging;&lt;br /&gt;
      pointerStart = canvasPoint(event);&lt;br /&gt;
      canvas.setPointerCapture(event.pointerId);&lt;br /&gt;
      canvas.classList.add(&#039;is-dragging&#039;);&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;pointermove&#039;, function (event) {&lt;br /&gt;
      if (dragging) {&lt;br /&gt;
        var world = worldPoint(event);&lt;br /&gt;
        dragging.x = world.x;&lt;br /&gt;
        dragging.y = world.y;&lt;br /&gt;
        dragging.vx = 0;&lt;br /&gt;
        dragging.vy = 0;&lt;br /&gt;
      } else if (panning &amp;amp;&amp;amp; pointerStart) {&lt;br /&gt;
        var point = canvasPoint(event);&lt;br /&gt;
        transform.x += point.x - pointerStart.x;&lt;br /&gt;
        transform.y += point.y - pointerStart.y;&lt;br /&gt;
        pointerStart = point;&lt;br /&gt;
      } else {&lt;br /&gt;
        hovered = hitNode(event);&lt;br /&gt;
        canvas.style.cursor = hovered ? &#039;pointer&#039; : &#039;grab&#039;;&lt;br /&gt;
      }&lt;br /&gt;
      draw();&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;pointerup&#039;, function (event) {&lt;br /&gt;
      var hit = hitNode(event);&lt;br /&gt;
      if (dragging &amp;amp;&amp;amp; hit === dragging) {&lt;br /&gt;
        selectNode(dragging);&lt;br /&gt;
      }&lt;br /&gt;
      dragging = null;&lt;br /&gt;
      panning = false;&lt;br /&gt;
      pointerStart = null;&lt;br /&gt;
      canvas.classList.remove(&#039;is-dragging&#039;);&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;dblclick&#039;, function (event) {&lt;br /&gt;
      var node = hitNode(event);&lt;br /&gt;
      if (node) {&lt;br /&gt;
        window.location.href = mw.util.getUrl(node.id);&lt;br /&gt;
      }&lt;br /&gt;
    });&lt;br /&gt;
    canvas.addEventListener(&#039;wheel&#039;, function (event) {&lt;br /&gt;
      event.preventDefault();&lt;br /&gt;
      var point = canvasPoint(event);&lt;br /&gt;
      var oldScale = transform.scale;&lt;br /&gt;
      var nextScale = Math.max(0.25, Math.min(3.5, oldScale * Math.exp(-event.deltaY * 0.0012)));&lt;br /&gt;
      transform.x = point.x - ((point.x - transform.x) / oldScale) * nextScale;&lt;br /&gt;
      transform.y = point.y - ((point.y - transform.y) / oldScale) * nextScale;&lt;br /&gt;
      transform.scale = nextScale;&lt;br /&gt;
      draw();&lt;br /&gt;
    }, { passive: false });&lt;br /&gt;
&lt;br /&gt;
    search.addEventListener(&#039;input&#039;, function () {&lt;br /&gt;
      var query = search.value.trim().toLocaleLowerCase(&#039;ko&#039;);&lt;br /&gt;
      searchResults.replaceChildren();&lt;br /&gt;
      if (!query) {&lt;br /&gt;
        searchResults.hidden = true;&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      var matches = nodes.filter(function (node) {&lt;br /&gt;
        return node.title.toLocaleLowerCase(&#039;ko&#039;).includes(query) || node.id.toLowerCase().includes(query);&lt;br /&gt;
      }).slice(0, 6);&lt;br /&gt;
      matches.forEach(function (node) {&lt;br /&gt;
        var button = makeElement(&#039;button&#039;, &#039;&#039;, node.title);&lt;br /&gt;
        button.type = &#039;button&#039;;&lt;br /&gt;
        button.addEventListener(&#039;click&#039;, function () {&lt;br /&gt;
          selectNode(node);&lt;br /&gt;
          transform.x = size.width / 2 - node.x * transform.scale;&lt;br /&gt;
          transform.y = size.height / 2 - node.y * transform.scale;&lt;br /&gt;
          search.value = node.title;&lt;br /&gt;
          searchResults.hidden = true;&lt;br /&gt;
          draw();&lt;br /&gt;
        });&lt;br /&gt;
        searchResults.appendChild(button);&lt;br /&gt;
      });&lt;br /&gt;
      if (!matches.length) {&lt;br /&gt;
        searchResults.appendChild(makeElement(&#039;span&#039;, &#039;&#039;, &#039;일치하는 메모가 없습니다.&#039;));&lt;br /&gt;
      }&lt;br /&gt;
      searchResults.hidden = false;&lt;br /&gt;
    });&lt;br /&gt;
    search.addEventListener(&#039;keydown&#039;, function (event) {&lt;br /&gt;
      if (event.key === &#039;Escape&#039;) {&lt;br /&gt;
        searchResults.hidden = true;&lt;br /&gt;
      } else if (event.key === &#039;Enter&#039;) {&lt;br /&gt;
        var first = searchResults.querySelector(&#039;button&#039;);&lt;br /&gt;
        if (first) { first.click(); }&lt;br /&gt;
      }&lt;br /&gt;
    });&lt;br /&gt;
    filter.addEventListener(&#039;change&#039;, function () {&lt;br /&gt;
      activeRelation = filter.value;&lt;br /&gt;
      counts.textContent = nodes.length + &#039;개 메모 · &#039; + visibleEdges().length + &#039;개 연결&#039;;&lt;br /&gt;
      draw();&lt;br /&gt;
    });&lt;br /&gt;
    reset.addEventListener(&#039;click&#039;, fitGraph);&lt;br /&gt;
&lt;br /&gt;
    showInspector(null);&lt;br /&gt;
    resize();&lt;br /&gt;
    var resizeObserver = window.ResizeObserver ? new ResizeObserver(resize) : null;&lt;br /&gt;
    if (resizeObserver) {&lt;br /&gt;
      resizeObserver.observe(viewport);&lt;br /&gt;
    } else {&lt;br /&gt;
      window.addEventListener(&#039;resize&#039;, resize);&lt;br /&gt;
    }&lt;br /&gt;
    animationFrame = window.requestAnimationFrame(simulate);&lt;br /&gt;
    window.addEventListener(&#039;pagehide&#039;, function () {&lt;br /&gt;
      window.cancelAnimationFrame(animationFrame);&lt;br /&gt;
      if (resizeObserver) { resizeObserver.disconnect(); }&lt;br /&gt;
    }, { once: true });&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  var askQuery = &#039;[[Category:Lessons]]&#039; +&lt;br /&gt;
    &#039;|?Lesson title|?Confidence|?Updated at&#039; +&lt;br /&gt;
    RELATIONS.map(function (relation) { return &#039;|?&#039; + relation.property; }).join(&#039;&#039;) +&lt;br /&gt;
    &#039;|sort=Updated at|order=descending|limit=&#039; + NODE_LIMIT;&lt;br /&gt;
&lt;br /&gt;
  createSemanticSearch();&lt;br /&gt;
&lt;br /&gt;
  mw.loader.using([&#039;mediawiki.api&#039;, &#039;mediawiki.util&#039;]).then(function () {&lt;br /&gt;
    return new mw.Api().get({ action: &#039;ask&#039;, query: askQuery, formatversion: 2 });&lt;br /&gt;
  }).then(function (data) {&lt;br /&gt;
    var graph = parseGraph(data);&lt;br /&gt;
    if (!graph.nodes.length) {&lt;br /&gt;
      renderEmpty();&lt;br /&gt;
      return;&lt;br /&gt;
    }&lt;br /&gt;
    createGraph(graph);&lt;br /&gt;
  }).catch(renderError);&lt;br /&gt;
}());&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%EB%AF%B8%EB%94%94%EC%96%B4%EC%9C%84%ED%82%A4:Common.css&amp;diff=2703</id>
		<title>미디어위키:Common.css</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%EB%AF%B8%EB%94%94%EC%96%B4%EC%9C%84%ED%82%A4:Common.css&amp;diff=2703"/>
		<updated>2026-07-19T01:37:09Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 원본 스키마 동기화&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;:root {&lt;br /&gt;
  --s3-navy: #1d2040;&lt;br /&gt;
  --s3-navy-deep: #171933;&lt;br /&gt;
  --s3-indigo: #3e42d1;&lt;br /&gt;
  --s3-indigo-dark: #393c9f;&lt;br /&gt;
  --s3-sky: #8eb2ee;&lt;br /&gt;
  --s3-paper: #f0f0f0;&lt;br /&gt;
  --s3-canvas: #f7f7fa;&lt;br /&gt;
  --s3-text: #212121;&lt;br /&gt;
  --s3-muted: #626579;&lt;br /&gt;
  --s3-green: #2f6b4f;&lt;br /&gt;
  --s3-amber: #8a6a1f;&lt;br /&gt;
  --s3-amber-bg: #f6eed6;&lt;br /&gt;
  --s3-line: #d9dbe7;&lt;br /&gt;
  --s3-motion: 150ms ease;&lt;br /&gt;
  --s3-font-sans: &amp;quot;Noto Sans KR&amp;quot;, &amp;quot;Apple SD Gothic Neo&amp;quot;, &amp;quot;Malgun Gothic&amp;quot;, Montserrat, Arial, sans-serif;&lt;br /&gt;
  --s3-font-serif: &amp;quot;Noto Serif KR&amp;quot;, &amp;quot;Nanum Myeongjo&amp;quot;, Bitter, Georgia, serif;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
::selection {&lt;br /&gt;
  background: var(--s3-sky);&lt;br /&gt;
  color: var(--s3-navy-deep);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
html {&lt;br /&gt;
  scroll-behavior: smooth;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
body,&lt;br /&gt;
.mw-body-content {&lt;br /&gt;
  color: var(--s3-text);&lt;br /&gt;
  font-family: var(--s3-font-sans);&lt;br /&gt;
  font-size: 17px;&lt;br /&gt;
  line-height: 1.72;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
body {&lt;br /&gt;
  background: var(--s3-canvas);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
a,&lt;br /&gt;
.mw-body-content a:visited {&lt;br /&gt;
  color: var(--s3-indigo-dark);&lt;br /&gt;
  transition: color var(--s3-motion);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
a:hover {&lt;br /&gt;
  color: var(--s3-indigo);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
a:focus-visible,&lt;br /&gt;
button:focus-visible,&lt;br /&gt;
input:focus-visible,&lt;br /&gt;
textarea:focus-visible,&lt;br /&gt;
select:focus-visible {&lt;br /&gt;
  outline: 3px solid var(--s3-indigo);&lt;br /&gt;
  outline-offset: 2px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.vector-header-container,&lt;br /&gt;
.skin-vector-2022 .mw-header {&lt;br /&gt;
  background: var(--s3-navy);&lt;br /&gt;
  color: #fff;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.vector-header-container a,&lt;br /&gt;
.vector-header-container .vector-menu-heading,&lt;br /&gt;
.vector-header-container .vector-dropdown-label {&lt;br /&gt;
  color: #fff;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.vector-header-container .mw-logo-wordmark,&lt;br /&gt;
.vector-header-container .mw-logo-tagline,&lt;br /&gt;
.vector-header-container .mw-ui-icon::before {&lt;br /&gt;
  filter: brightness(0) invert(1);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.vector-header-container .mw-logo-icon {&lt;br /&gt;
  filter: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.vector-header-container .mw-logo-wordmark {&lt;br /&gt;
  color: #fff;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.vector-header-container .cdx-text-input__input,&lt;br /&gt;
.vector-header-container input {&lt;br /&gt;
  background: #fff;&lt;br /&gt;
  color: var(--s3-text);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-page-container {&lt;br /&gt;
  background: #fff;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-body-header {&lt;br /&gt;
  background: var(--s3-paper);&lt;br /&gt;
  border: 0;&lt;br /&gt;
  padding: 1.6rem clamp(1.25rem, 4vw, 4.4rem);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-body h1,&lt;br /&gt;
.mw-body h2,&lt;br /&gt;
.mw-body h3,&lt;br /&gt;
.mw-body h4,&lt;br /&gt;
.mw-body-header .firstHeading {&lt;br /&gt;
  color: var(--s3-text);&lt;br /&gt;
  font-family: var(--s3-font-sans);&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-body h2 {&lt;br /&gt;
  border-bottom: 1px solid var(--s3-line);&lt;br /&gt;
  font-size: clamp(1.4rem, 2.2vw, 1.75rem);&lt;br /&gt;
  padding-bottom: 0.45rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-body h3 {&lt;br /&gt;
  font-size: 1.18rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.page-Main_Page .mw-body-content,&lt;br /&gt;
.page-연구_메모리 .mw-body-content {&lt;br /&gt;
  font-size: 16px;&lt;br /&gt;
  line-height: 1.65;&lt;br /&gt;
  margin-top: 1.2rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.page-Main_Page .mw-body-header,&lt;br /&gt;
.page-연구_메모리 .mw-body-header {&lt;br /&gt;
  background: #fff;&lt;br /&gt;
  border-bottom: 1px solid var(--s3-line);&lt;br /&gt;
  margin: 0 auto;&lt;br /&gt;
  max-width: 960px;&lt;br /&gt;
  padding: 1.1rem 0 0.85rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.page-Main_Page .mw-body-header .firstHeading,&lt;br /&gt;
.page-연구_메모리 .mw-body-header .firstHeading {&lt;br /&gt;
  font-size: clamp(1.8rem, 3vw, 2.15rem);&lt;br /&gt;
  line-height: 1.3;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.page-Main_Page .mw-body-content &amp;gt; .mw-parser-output,&lt;br /&gt;
.page-연구_메모리 .mw-body-content &amp;gt; .mw-parser-output {&lt;br /&gt;
  margin: 0 auto;&lt;br /&gt;
  max-width: 960px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-home-intro {&lt;br /&gt;
  border-bottom: 1px solid var(--s3-line);&lt;br /&gt;
  border-top: 3px solid var(--s3-navy);&lt;br /&gt;
  padding: 1.15rem 0 1.25rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-home-intro p {&lt;br /&gt;
  color: var(--s3-navy-deep);&lt;br /&gt;
  font-family: var(--s3-font-serif);&lt;br /&gt;
  font-size: clamp(1.12rem, 1.8vw, 1.32rem);&lt;br /&gt;
  line-height: 1.6;&lt;br /&gt;
  margin: 0;&lt;br /&gt;
  max-width: 760px;&lt;br /&gt;
  text-wrap: pretty;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-content-section {&lt;br /&gt;
  margin: 0 auto 3.25rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-home-actions {&lt;br /&gt;
  display: grid;&lt;br /&gt;
  grid-template-columns: repeat(3, minmax(0, 1fr));&lt;br /&gt;
  border-bottom: 1px solid var(--s3-line);&lt;br /&gt;
  margin-bottom: 3.25rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-action-card {&lt;br /&gt;
  border-right: 1px solid var(--s3-line);&lt;br /&gt;
  box-shadow: inset 0 0 0 0 transparent;&lt;br /&gt;
  min-width: 0;&lt;br /&gt;
  padding: 1.05rem 1.35rem 1.15rem;&lt;br /&gt;
  transition: box-shadow var(--s3-motion);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-action-card:hover {&lt;br /&gt;
  box-shadow: inset 0 3px 0 -1px var(--s3-indigo);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-action-card:first-child {&lt;br /&gt;
  padding-left: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-action-card:last-child {&lt;br /&gt;
  border-right: 0;&lt;br /&gt;
  padding-right: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-action-title {&lt;br /&gt;
  font-family: var(--s3-font-sans);&lt;br /&gt;
  font-size: 1rem;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  margin-bottom: 0.3rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-action-title a {&lt;br /&gt;
  color: var(--s3-indigo-dark);&lt;br /&gt;
  text-decoration-thickness: 1px;&lt;br /&gt;
  text-underline-offset: 0.2em;&lt;br /&gt;
  transition: text-decoration-thickness var(--s3-motion),&lt;br /&gt;
    text-underline-offset var(--s3-motion);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-action-card:hover .s3rm-action-title a {&lt;br /&gt;
  text-decoration-thickness: 2px;&lt;br /&gt;
  text-underline-offset: 0.24em;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-action-card p {&lt;br /&gt;
  color: var(--s3-muted);&lt;br /&gt;
  font-size: 0.9rem;&lt;br /&gt;
  line-height: 1.55;&lt;br /&gt;
  margin: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-section-header {&lt;br /&gt;
  align-items: baseline;&lt;br /&gt;
  border-bottom: 1px solid var(--s3-line);&lt;br /&gt;
  display: flex;&lt;br /&gt;
  gap: 1rem;&lt;br /&gt;
  justify-content: space-between;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-section-header h2 {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  border: 0;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  font-size: 1.35rem;&lt;br /&gt;
  gap: 0.55rem;&lt;br /&gt;
  margin: 0;&lt;br /&gt;
  padding: 0 0 0.65rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-section-header h2::before {&lt;br /&gt;
  background: var(--s3-indigo);&lt;br /&gt;
  content: &amp;quot;&amp;quot;;&lt;br /&gt;
  height: 1.1em;&lt;br /&gt;
  width: 4px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-section-link {&lt;br /&gt;
  font-size: 0.88rem;&lt;br /&gt;
  white-space: nowrap;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-recent-list {&lt;br /&gt;
  border-bottom: 1px solid var(--s3-line);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-lesson-summary {&lt;br /&gt;
  background: #fff;&lt;br /&gt;
  border-bottom: 1px solid var(--s3-line);&lt;br /&gt;
  box-shadow: inset 0 0 0 0 transparent;&lt;br /&gt;
  margin: 0 -0.9rem;&lt;br /&gt;
  padding: 0.85rem 0.9rem;&lt;br /&gt;
  transition: background var(--s3-motion), box-shadow var(--s3-motion);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-lesson-summary:hover {&lt;br /&gt;
  background: var(--s3-canvas);&lt;br /&gt;
  box-shadow: inset 3px 0 0 0 var(--s3-indigo);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-lesson-summary:last-child {&lt;br /&gt;
  border-bottom: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-lesson-summary-title {&lt;br /&gt;
  font-family: var(--s3-font-sans);&lt;br /&gt;
  font-size: 1rem;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  line-height: 1.45;&lt;br /&gt;
  margin: 0 0 0.45rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-lesson-summary-title a {&lt;br /&gt;
  color: var(--s3-navy);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-lesson-summary-meta {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  color: var(--s3-muted);&lt;br /&gt;
  display: flex;&lt;br /&gt;
  flex-wrap: wrap;&lt;br /&gt;
  font-size: 0.82rem;&lt;br /&gt;
  gap: 0.45rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-empty-state {&lt;br /&gt;
  background: var(--s3-canvas);&lt;br /&gt;
  border: 1px dashed var(--s3-line);&lt;br /&gt;
  color: var(--s3-muted);&lt;br /&gt;
  font-size: 0.92rem;&lt;br /&gt;
  padding: 1.4rem 1rem;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-create-panel {&lt;br /&gt;
  border-bottom: 1px solid var(--s3-line);&lt;br /&gt;
  border-top: 1px solid var(--s3-line);&lt;br /&gt;
  display: grid;&lt;br /&gt;
  gap: 2.25rem;&lt;br /&gt;
  grid-template-columns: minmax(0, 1fr) minmax(300px, 0.9fr);&lt;br /&gt;
  padding: 1.4rem 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-create-panel h2 {&lt;br /&gt;
  border: 0;&lt;br /&gt;
  font-size: 1.35rem;&lt;br /&gt;
  margin-top: 0;&lt;br /&gt;
  padding: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-form-launcher {&lt;br /&gt;
  background: var(--s3-paper);&lt;br /&gt;
  min-width: 0;&lt;br /&gt;
  padding: 1rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-form-label {&lt;br /&gt;
  color: var(--s3-navy);&lt;br /&gt;
  font-family: var(--s3-font-sans);&lt;br /&gt;
  font-size: 0.86rem;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  margin-bottom: 0.45rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-form-launcher .pfFormInputWrapper {&lt;br /&gt;
  max-width: 100%;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-form-launcher .oo-ui-horizontalLayout {&lt;br /&gt;
  display: flex;&lt;br /&gt;
  flex-wrap: wrap;&lt;br /&gt;
  gap: 0.5rem;&lt;br /&gt;
  max-width: 100%;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-form-launcher .oo-ui-horizontalLayout &amp;gt; .oo-ui-widget {&lt;br /&gt;
  margin-right: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-form-launcher .pfPageNameWithoutNamespace {&lt;br /&gt;
  flex: 1 1 14rem;&lt;br /&gt;
  min-width: 0;&lt;br /&gt;
  width: auto !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-form-launcher input[type=&amp;quot;text&amp;quot;] {&lt;br /&gt;
  box-sizing: border-box;&lt;br /&gt;
  max-width: 100%;&lt;br /&gt;
  min-width: 0;&lt;br /&gt;
  width: 100% !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-form-note {&lt;br /&gt;
  background: #f5f5f8;&lt;br /&gt;
  border-left: 4px solid var(--s3-indigo);&lt;br /&gt;
  margin: 1.2rem 0 1.8rem;&lt;br /&gt;
  padding: 1rem 1.2rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-confidence,&lt;br /&gt;
.s3rm-kind,&lt;br /&gt;
.s3rm-relation-label {&lt;br /&gt;
  background: #ececf5;&lt;br /&gt;
  color: var(--s3-navy);&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  font-family: var(--s3-font-sans);&lt;br /&gt;
  font-size: 0.78rem;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  line-height: 1.4;&lt;br /&gt;
  padding: 0.2rem 0.55rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-confidence-high { background: #dceee5; color: var(--s3-green); }&lt;br /&gt;
.s3rm-confidence-low { background: #f4e6e3; color: #8a3f34; }&lt;br /&gt;
&lt;br /&gt;
.s3rm-lesson-meta {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  border-bottom: 1px solid var(--s3-line);&lt;br /&gt;
  display: flex;&lt;br /&gt;
  flex-wrap: wrap;&lt;br /&gt;
  gap: 0.55rem;&lt;br /&gt;
  margin-bottom: 1.5rem;&lt;br /&gt;
  padding-bottom: 1rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-lesson-actions {&lt;br /&gt;
  display: flex;&lt;br /&gt;
  flex-wrap: wrap;&lt;br /&gt;
  gap: 0.55rem;&lt;br /&gt;
  margin: -0.55rem 0 1.4rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-lesson-actions a {&lt;br /&gt;
  border: 1px solid var(--s3-indigo-dark);&lt;br /&gt;
  color: var(--s3-indigo-dark);&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  font-size: 0.86rem;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  padding: 0.42rem 0.7rem;&lt;br /&gt;
  text-decoration: none;&lt;br /&gt;
  transition: background var(--s3-motion), color var(--s3-motion);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-lesson-actions a:hover {&lt;br /&gt;
  background: var(--s3-indigo-dark);&lt;br /&gt;
  color: #fff;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-lesson-table,&lt;br /&gt;
.formtable,&lt;br /&gt;
.smwtable {&lt;br /&gt;
  border-collapse: collapse;&lt;br /&gt;
  width: 100%;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.formtable {&lt;br /&gt;
  table-layout: fixed;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-lesson-table th,&lt;br /&gt;
.s3rm-lesson-table td,&lt;br /&gt;
.formtable th,&lt;br /&gt;
.formtable td {&lt;br /&gt;
  border: 0;&lt;br /&gt;
  border-bottom: 1px solid var(--s3-line);&lt;br /&gt;
  padding: 0.85rem 0.75rem;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-lesson-table th,&lt;br /&gt;
.formtable th {&lt;br /&gt;
  color: var(--s3-navy);&lt;br /&gt;
  font-family: var(--s3-font-sans);&lt;br /&gt;
  font-size: 0.86rem;&lt;br /&gt;
  min-width: 12rem;&lt;br /&gt;
  text-align: left;&lt;br /&gt;
  width: 12rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-evidence {&lt;br /&gt;
  background: #f7f7fa;&lt;br /&gt;
  border-left: 4px solid var(--s3-indigo);&lt;br /&gt;
  margin: 0.75rem 0;&lt;br /&gt;
  padding: 0.85rem 1rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-relation {&lt;br /&gt;
  border-bottom: 1px solid var(--s3-line);&lt;br /&gt;
  display: block;&lt;br /&gt;
  margin: 0.35rem 0;&lt;br /&gt;
  padding: 0.55rem 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.pfFormInputWrapper input,&lt;br /&gt;
.createboxInput,&lt;br /&gt;
.formtable input[type=&amp;quot;text&amp;quot;],&lt;br /&gt;
.formtable textarea,&lt;br /&gt;
.formtable select {&lt;br /&gt;
  border: 1px solid #a7a9ba;&lt;br /&gt;
  box-sizing: border-box;&lt;br /&gt;
  font-family: var(--s3-font-sans);&lt;br /&gt;
  max-width: 100%;&lt;br /&gt;
  padding: 0.55rem 0.65rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.formtable input[type=&amp;quot;text&amp;quot;],&lt;br /&gt;
.formtable textarea {&lt;br /&gt;
  width: 100%;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.oo-ui-buttonElement-framed.oo-ui-widget-enabled &amp;gt; .oo-ui-buttonElement-button,&lt;br /&gt;
.cdx-button--action-progressive.cdx-button--weight-primary {&lt;br /&gt;
  background: var(--s3-indigo);&lt;br /&gt;
  border-color: var(--s3-indigo);&lt;br /&gt;
  color: #fff;&lt;br /&gt;
  min-height: 44px;&lt;br /&gt;
  transition: background var(--s3-motion);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.oo-ui-buttonElement-framed.oo-ui-widget-enabled &amp;gt; .oo-ui-buttonElement-button:hover,&lt;br /&gt;
.cdx-button--action-progressive.cdx-button--weight-primary:hover {&lt;br /&gt;
  background: var(--s3-indigo-dark);&lt;br /&gt;
  border-color: var(--s3-indigo-dark);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Knowledge graph home */&lt;br /&gt;
.page-Main_Page .mw-body-header,&lt;br /&gt;
.page-연구_메모리 .mw-body-header,&lt;br /&gt;
.page-Main_Page .mw-body-content &amp;gt; .mw-parser-output,&lt;br /&gt;
.page-연구_메모리 .mw-body-content &amp;gt; .mw-parser-output {&lt;br /&gt;
  max-width: 1440px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.page-Main_Page .mw-body-content,&lt;br /&gt;
.page-연구_메모리 .mw-body-content {&lt;br /&gt;
  margin-top: 0.75rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-home-intro {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  border: 0;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  min-height: 2.7rem;&lt;br /&gt;
  padding: 0 0 0.75rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-home-intro p {&lt;br /&gt;
  color: var(--s3-muted);&lt;br /&gt;
  font-family: var(--s3-font-sans);&lt;br /&gt;
  font-size: 0.94rem;&lt;br /&gt;
  line-height: 1.55;&lt;br /&gt;
  max-width: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-eyebrow {&lt;br /&gt;
  color: var(--s3-indigo);&lt;br /&gt;
  font-size: 0.7rem;&lt;br /&gt;
  font-weight: 800;&lt;br /&gt;
  letter-spacing: 0.14em;&lt;br /&gt;
  line-height: 1.4;&lt;br /&gt;
  margin-bottom: 0.35rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-section {&lt;br /&gt;
  background: #101426;&lt;br /&gt;
  border: 1px solid #292f4b;&lt;br /&gt;
  box-shadow: 0 24px 60px rgba(23, 25, 51, 0.14);&lt;br /&gt;
  color: #f5f7ff;&lt;br /&gt;
  margin: 0 0 3.2rem;&lt;br /&gt;
  overflow: hidden;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-heading {&lt;br /&gt;
  align-items: flex-end;&lt;br /&gt;
  border-bottom: 1px solid #292f4b;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  gap: 2rem;&lt;br /&gt;
  justify-content: space-between;&lt;br /&gt;
  padding: 1.35rem 1.5rem 1.2rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-heading .s3rm-eyebrow {&lt;br /&gt;
  color: #8ca8ff;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-heading h2 {&lt;br /&gt;
  border: 0;&lt;br /&gt;
  color: #fff;&lt;br /&gt;
  font-size: clamp(1.25rem, 2vw, 1.65rem);&lt;br /&gt;
  line-height: 1.35;&lt;br /&gt;
  margin: 0 0 0.35rem;&lt;br /&gt;
  padding: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-heading p {&lt;br /&gt;
  color: #aeb5cf;&lt;br /&gt;
  font-size: 0.88rem;&lt;br /&gt;
  line-height: 1.5;&lt;br /&gt;
  margin: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-shortcuts {&lt;br /&gt;
  display: flex;&lt;br /&gt;
  flex: 0 0 auto;&lt;br /&gt;
  gap: 0.55rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-shortcuts a {&lt;br /&gt;
  border: 1px solid #475071;&lt;br /&gt;
  color: #e7ebff !important;&lt;br /&gt;
  font-size: 0.8rem;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  padding: 0.42rem 0.72rem;&lt;br /&gt;
  text-decoration: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-shortcuts a:last-child {&lt;br /&gt;
  background: #687ee9;&lt;br /&gt;
  border-color: #687ee9;&lt;br /&gt;
  color: #fff !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-search {&lt;br /&gt;
  background: #151a2f;&lt;br /&gt;
  border-bottom: 1px solid #292f4b;&lt;br /&gt;
  padding: 1.1rem 1.5rem 1.2rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-search-loading {&lt;br /&gt;
  color: #aeb5cf;&lt;br /&gt;
  font-size: 0.82rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-intro {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  gap: 0.75rem;&lt;br /&gt;
  margin-bottom: 0.8rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-icon {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  background: #242c4d;&lt;br /&gt;
  border: 1px solid #3c4774;&lt;br /&gt;
  color: #9bb2ff;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  flex: 0 0 2.35rem;&lt;br /&gt;
  font-size: 1.35rem;&lt;br /&gt;
  height: 2.35rem;&lt;br /&gt;
  justify-content: center;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-intro strong {&lt;br /&gt;
  color: #fff;&lt;br /&gt;
  display: block;&lt;br /&gt;
  font-size: 0.92rem;&lt;br /&gt;
  margin-bottom: 0.1rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-intro p {&lt;br /&gt;
  color: #9da6c5;&lt;br /&gt;
  font-size: 0.75rem;&lt;br /&gt;
  line-height: 1.45;&lt;br /&gt;
  margin: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-form {&lt;br /&gt;
  display: flex;&lt;br /&gt;
  gap: 0.55rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-input {&lt;br /&gt;
  background: #0d1120;&lt;br /&gt;
  border: 1px solid #414b75;&lt;br /&gt;
  border-radius: 0;&lt;br /&gt;
  box-sizing: border-box;&lt;br /&gt;
  color: #f5f7ff;&lt;br /&gt;
  flex: 1 1 auto;&lt;br /&gt;
  font-family: var(--s3-font-sans);&lt;br /&gt;
  font-size: 0.93rem;&lt;br /&gt;
  height: 3rem;&lt;br /&gt;
  min-width: 0;&lt;br /&gt;
  padding: 0 0.95rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-input::placeholder {&lt;br /&gt;
  color: #737d9f;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-submit {&lt;br /&gt;
  background: #687ee9;&lt;br /&gt;
  border: 1px solid #8296f3;&lt;br /&gt;
  border-radius: 0;&lt;br /&gt;
  color: #fff;&lt;br /&gt;
  cursor: pointer;&lt;br /&gt;
  flex: 0 0 auto;&lt;br /&gt;
  font-family: var(--s3-font-sans);&lt;br /&gt;
  font-size: 0.84rem;&lt;br /&gt;
  font-weight: 800;&lt;br /&gt;
  min-width: 7rem;&lt;br /&gt;
  padding: 0 1rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-submit:hover {&lt;br /&gt;
  background: #798df0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-submit:disabled {&lt;br /&gt;
  cursor: wait;&lt;br /&gt;
  opacity: 0.7;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-examples {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  flex-wrap: wrap;&lt;br /&gt;
  gap: 0.4rem;&lt;br /&gt;
  margin-top: 0.55rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-examples &amp;gt; span {&lt;br /&gt;
  color: #737d9f;&lt;br /&gt;
  font-size: 0.67rem;&lt;br /&gt;
  margin-right: 0.1rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-examples button {&lt;br /&gt;
  background: transparent;&lt;br /&gt;
  border: 1px solid #343d62;&lt;br /&gt;
  border-radius: 1rem;&lt;br /&gt;
  color: #aeb8da;&lt;br /&gt;
  cursor: pointer;&lt;br /&gt;
  font-family: var(--s3-font-sans);&lt;br /&gt;
  font-size: 0.67rem;&lt;br /&gt;
  padding: 0.25rem 0.55rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-examples button:hover {&lt;br /&gt;
  background: #242c4d;&lt;br /&gt;
  color: #fff;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-feedback {&lt;br /&gt;
  border-top: 1px solid #303754;&lt;br /&gt;
  margin-top: 0.9rem;&lt;br /&gt;
  padding-top: 0.9rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-result-header {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  gap: 1rem;&lt;br /&gt;
  justify-content: space-between;&lt;br /&gt;
  margin-bottom: 0.6rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-result-header &amp;gt; strong {&lt;br /&gt;
  color: #edf0ff;&lt;br /&gt;
  font-size: 0.78rem;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-mode {&lt;br /&gt;
  color: #8ca8ff;&lt;br /&gt;
  font-size: 0.67rem;&lt;br /&gt;
  white-space: nowrap;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-results {&lt;br /&gt;
  display: grid;&lt;br /&gt;
  gap: 0.5rem;&lt;br /&gt;
  grid-template-columns: repeat(3, minmax(0, 1fr));&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-result {&lt;br /&gt;
  background: #0f1426;&lt;br /&gt;
  border: 1px solid #303858;&lt;br /&gt;
  box-sizing: border-box;&lt;br /&gt;
  color: #eef1ff !important;&lt;br /&gt;
  min-width: 0;&lt;br /&gt;
  padding: 0.75rem;&lt;br /&gt;
  text-decoration: none;&lt;br /&gt;
  transition: background var(--s3-motion), border-color var(--s3-motion);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-result:hover {&lt;br /&gt;
  background: #202846;&lt;br /&gt;
  border-color: #687ee9;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-result-top {&lt;br /&gt;
  align-items: flex-start;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  gap: 0.55rem;&lt;br /&gt;
  justify-content: space-between;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-result-top &amp;gt; strong {&lt;br /&gt;
  font-size: 0.76rem;&lt;br /&gt;
  line-height: 1.4;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-result .s3rm-confidence {&lt;br /&gt;
  flex: 0 0 auto;&lt;br /&gt;
  font-size: 0.58rem;&lt;br /&gt;
  padding: 0.12rem 0.35rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-result p {&lt;br /&gt;
  color: #929cbe;&lt;br /&gt;
  display: -webkit-box;&lt;br /&gt;
  font-size: 0.68rem;&lt;br /&gt;
  -webkit-box-orient: vertical;&lt;br /&gt;
  -webkit-line-clamp: 2;&lt;br /&gt;
  line-height: 1.45;&lt;br /&gt;
  margin: 0.45rem 0 0;&lt;br /&gt;
  overflow: hidden;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-searching,&lt;br /&gt;
.s3rm-semantic-empty,&lt;br /&gt;
.s3rm-semantic-error {&lt;br /&gt;
  color: #aeb5cf;&lt;br /&gt;
  font-size: 0.76rem;&lt;br /&gt;
  margin: 0;&lt;br /&gt;
  padding: 0.45rem 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-error {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  gap: 0.7rem;&lt;br /&gt;
  justify-content: space-between;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-semantic-error a {&lt;br /&gt;
  color: #9bb2ff !important;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-knowledge-graph {&lt;br /&gt;
  min-height: 590px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-loading,&lt;br /&gt;
.s3rm-graph-empty {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  flex-direction: column;&lt;br /&gt;
  justify-content: center;&lt;br /&gt;
  min-height: 590px;&lt;br /&gt;
  padding: 2rem;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-loading {&lt;br /&gt;
  color: #d7dcf2;&lt;br /&gt;
  gap: 0.25rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-loading &amp;gt; span:last-child {&lt;br /&gt;
  color: #8e96b4;&lt;br /&gt;
  font-size: 0.82rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-loading-mark {&lt;br /&gt;
  animation: s3rm-graph-pulse 1.4s ease-in-out infinite;&lt;br /&gt;
  background: #8ca8ff;&lt;br /&gt;
  border-radius: 50%;&lt;br /&gt;
  box-shadow: 18px 8px 0 -2px #53c18b, -18px 11px 0 -3px #a17bd7;&lt;br /&gt;
  display: block;&lt;br /&gt;
  height: 12px;&lt;br /&gt;
  margin: 0 0 1.25rem;&lt;br /&gt;
  width: 12px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
@keyframes s3rm-graph-pulse {&lt;br /&gt;
  0%, 100% { opacity: 0.45; transform: scale(0.92); }&lt;br /&gt;
  50% { opacity: 1; transform: scale(1.08); }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-toolbar {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  background: #151a2f;&lt;br /&gt;
  border-bottom: 1px solid #292f4b;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  gap: 0.6rem;&lt;br /&gt;
  padding: 0.7rem 0.85rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-search {&lt;br /&gt;
  flex: 1 1 19rem;&lt;br /&gt;
  max-width: 30rem;&lt;br /&gt;
  position: relative;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-search-input,&lt;br /&gt;
.s3rm-graph-filter,&lt;br /&gt;
.s3rm-graph-reset {&lt;br /&gt;
  background: #0f1324;&lt;br /&gt;
  border: 1px solid #353c5d;&lt;br /&gt;
  border-radius: 0;&lt;br /&gt;
  box-sizing: border-box;&lt;br /&gt;
  color: #edf0ff;&lt;br /&gt;
  font-family: var(--s3-font-sans);&lt;br /&gt;
  font-size: 0.82rem;&lt;br /&gt;
  height: 2.35rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-search-input {&lt;br /&gt;
  padding: 0 0.8rem 0 2.25rem;&lt;br /&gt;
  width: 100%;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-search::before {&lt;br /&gt;
  color: #7d87aa;&lt;br /&gt;
  content: &amp;quot;⌕&amp;quot;;&lt;br /&gt;
  font-size: 1.2rem;&lt;br /&gt;
  left: 0.75rem;&lt;br /&gt;
  line-height: 1;&lt;br /&gt;
  pointer-events: none;&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  top: 0.55rem;&lt;br /&gt;
  z-index: 2;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-search-input::placeholder {&lt;br /&gt;
  color: #7d87aa;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-filter,&lt;br /&gt;
.s3rm-graph-reset {&lt;br /&gt;
  padding: 0 0.75rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-reset {&lt;br /&gt;
  cursor: pointer;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-reset:hover {&lt;br /&gt;
  background: #252c4a;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-search-results {&lt;br /&gt;
  background: #181e35;&lt;br /&gt;
  border: 1px solid #3d456a;&lt;br /&gt;
  box-shadow: 0 12px 28px rgba(0, 0, 0, 0.34);&lt;br /&gt;
  left: 0;&lt;br /&gt;
  max-height: 17rem;&lt;br /&gt;
  overflow-y: auto;&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  right: 0;&lt;br /&gt;
  top: calc(100% + 0.35rem);&lt;br /&gt;
  z-index: 5;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-search-results button,&lt;br /&gt;
.s3rm-graph-search-results &amp;gt; span {&lt;br /&gt;
  background: transparent;&lt;br /&gt;
  border: 0;&lt;br /&gt;
  border-bottom: 1px solid #2d3452;&lt;br /&gt;
  box-sizing: border-box;&lt;br /&gt;
  color: #edf0ff;&lt;br /&gt;
  display: block;&lt;br /&gt;
  font-family: var(--s3-font-sans);&lt;br /&gt;
  font-size: 0.82rem;&lt;br /&gt;
  padding: 0.7rem 0.8rem;&lt;br /&gt;
  text-align: left;&lt;br /&gt;
  width: 100%;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-search-results button {&lt;br /&gt;
  cursor: pointer;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-search-results button:hover,&lt;br /&gt;
.s3rm-graph-search-results button:focus-visible {&lt;br /&gt;
  background: #252d50;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-body {&lt;br /&gt;
  display: grid;&lt;br /&gt;
  grid-template-columns: minmax(0, 1fr) 260px;&lt;br /&gt;
  min-height: 472px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-viewport {&lt;br /&gt;
  background-color: #0d1120;&lt;br /&gt;
  background-image: radial-gradient(circle, rgba(140, 168, 255, 0.13) 1px, transparent 1px);&lt;br /&gt;
  background-size: 24px 24px;&lt;br /&gt;
  min-width: 0;&lt;br /&gt;
  overflow: hidden;&lt;br /&gt;
  position: relative;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-canvas {&lt;br /&gt;
  display: block;&lt;br /&gt;
  height: 100%;&lt;br /&gt;
  min-height: 472px;&lt;br /&gt;
  touch-action: none;&lt;br /&gt;
  width: 100%;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-canvas.is-dragging {&lt;br /&gt;
  cursor: grabbing !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-hint {&lt;br /&gt;
  background: rgba(16, 20, 38, 0.86);&lt;br /&gt;
  bottom: 0.75rem;&lt;br /&gt;
  color: #8992b2;&lt;br /&gt;
  font-size: 0.68rem;&lt;br /&gt;
  left: 0.75rem;&lt;br /&gt;
  padding: 0.32rem 0.5rem;&lt;br /&gt;
  pointer-events: none;&lt;br /&gt;
  position: absolute;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-inspector {&lt;br /&gt;
  background: #151a2f;&lt;br /&gt;
  border-left: 1px solid #292f4b;&lt;br /&gt;
  box-sizing: border-box;&lt;br /&gt;
  min-width: 0;&lt;br /&gt;
  padding: 1.2rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-inspector-welcome {&lt;br /&gt;
  align-items: flex-start;&lt;br /&gt;
  color: #aeb5cf;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  flex-direction: column;&lt;br /&gt;
  height: 100%;&lt;br /&gt;
  justify-content: center;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-inspector-welcome strong {&lt;br /&gt;
  color: #f5f7ff;&lt;br /&gt;
  margin-bottom: 0.3rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-inspector-welcome p {&lt;br /&gt;
  font-size: 0.8rem;&lt;br /&gt;
  line-height: 1.55;&lt;br /&gt;
  margin: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-inspector-symbol {&lt;br /&gt;
  color: #8ca8ff;&lt;br /&gt;
  font-size: 2.2rem;&lt;br /&gt;
  line-height: 1;&lt;br /&gt;
  margin-bottom: 0.9rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-inspector-kicker {&lt;br /&gt;
  color: #8ca8ff;&lt;br /&gt;
  font-size: 0.68rem;&lt;br /&gt;
  font-weight: 800;&lt;br /&gt;
  letter-spacing: 0.08em;&lt;br /&gt;
  margin-bottom: 0.5rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-inspector h3 {&lt;br /&gt;
  color: #fff;&lt;br /&gt;
  font-size: 1.05rem;&lt;br /&gt;
  line-height: 1.45;&lt;br /&gt;
  margin: 0 0 0.85rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-inspector-meta {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  color: #9fa8c8;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  font-size: 0.76rem;&lt;br /&gt;
  gap: 0.6rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-inspector-relations {&lt;br /&gt;
  border-top: 1px solid #303754;&lt;br /&gt;
  list-style: none;&lt;br /&gt;
  margin: 1.1rem 0;&lt;br /&gt;
  padding: 0.6rem 0 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-inspector-relations li {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  color: #aeb5cf;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  font-size: 0.78rem;&lt;br /&gt;
  justify-content: space-between;&lt;br /&gt;
  padding: 0.25rem 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-inspector-relations strong {&lt;br /&gt;
  color: #fff;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-inspector-muted {&lt;br /&gt;
  color: #929ab8;&lt;br /&gt;
  font-size: 0.78rem;&lt;br /&gt;
  line-height: 1.5;&lt;br /&gt;
  margin: 1.1rem 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-primary-link {&lt;br /&gt;
  background: #687ee9;&lt;br /&gt;
  color: #fff !important;&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  font-size: 0.8rem;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  margin-top: 0.75rem;&lt;br /&gt;
  padding: 0.52rem 0.78rem;&lt;br /&gt;
  text-decoration: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-footer {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  background: #151a2f;&lt;br /&gt;
  border-top: 1px solid #292f4b;&lt;br /&gt;
  color: #8f98b8;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  font-size: 0.7rem;&lt;br /&gt;
  gap: 1rem;&lt;br /&gt;
  justify-content: space-between;&lt;br /&gt;
  min-height: 2.45rem;&lt;br /&gt;
  padding: 0 0.9rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-legend {&lt;br /&gt;
  display: flex;&lt;br /&gt;
  flex-wrap: wrap;&lt;br /&gt;
  gap: 0.8rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-legend-item {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  display: inline-flex;&lt;br /&gt;
  gap: 0.3rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-legend-item i {&lt;br /&gt;
  border-radius: 50%;&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  height: 0.4rem;&lt;br /&gt;
  width: 0.4rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-empty {&lt;br /&gt;
  background: #0d1120;&lt;br /&gt;
  color: #aeb5cf;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-empty strong {&lt;br /&gt;
  color: #fff;&lt;br /&gt;
  font-size: 1.05rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-empty p {&lt;br /&gt;
  font-size: 0.83rem;&lt;br /&gt;
  margin: 0.35rem 0 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-graph-fallback {&lt;br /&gt;
  background: #0d1120;&lt;br /&gt;
  color: #c8cee5;&lt;br /&gt;
  min-height: 12rem;&lt;br /&gt;
  padding: 4rem 1rem;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-home-lower {&lt;br /&gt;
  display: grid;&lt;br /&gt;
  gap: clamp(2.5rem, 5vw, 5rem);&lt;br /&gt;
  grid-template-columns: minmax(0, 1.45fr) minmax(300px, 0.72fr);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-quick-start-header {&lt;br /&gt;
  align-items: flex-end;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-quick-start-header h2,&lt;br /&gt;
.s3rm-recent-section .s3rm-section-header h2 {&lt;br /&gt;
  padding-bottom: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-quick-start-header h2::before,&lt;br /&gt;
.s3rm-recent-section .s3rm-section-header h2::before {&lt;br /&gt;
  display: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-onboarding-steps {&lt;br /&gt;
  display: grid;&lt;br /&gt;
  gap: 0;&lt;br /&gt;
  grid-template-columns: repeat(3, minmax(0, 1fr));&lt;br /&gt;
  list-style: none;&lt;br /&gt;
  margin: 0;&lt;br /&gt;
  padding: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-onboarding-steps li {&lt;br /&gt;
  border-bottom: 1px solid var(--s3-line);&lt;br /&gt;
  border-right: 1px solid var(--s3-line);&lt;br /&gt;
  display: flex;&lt;br /&gt;
  gap: 0.75rem;&lt;br /&gt;
  min-width: 0;&lt;br /&gt;
  padding: 1rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-onboarding-steps li:first-child {&lt;br /&gt;
  padding-left: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-onboarding-steps li:last-child {&lt;br /&gt;
  border-right: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-onboarding-steps li &amp;gt; span {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  background: var(--s3-navy);&lt;br /&gt;
  border-radius: 50%;&lt;br /&gt;
  color: #fff;&lt;br /&gt;
  display: inline-flex;&lt;br /&gt;
  flex: 0 0 1.65rem;&lt;br /&gt;
  font-size: 0.72rem;&lt;br /&gt;
  font-weight: 800;&lt;br /&gt;
  height: 1.65rem;&lt;br /&gt;
  justify-content: center;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-onboarding-steps strong {&lt;br /&gt;
  color: var(--s3-navy);&lt;br /&gt;
  display: block;&lt;br /&gt;
  font-size: 0.86rem;&lt;br /&gt;
  margin: 0.1rem 0 0.22rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-onboarding-steps p {&lt;br /&gt;
  color: var(--s3-muted);&lt;br /&gt;
  font-size: 0.76rem;&lt;br /&gt;
  line-height: 1.48;&lt;br /&gt;
  margin: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-quick-start .s3rm-create-panel {&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  border-top: 0;&lt;br /&gt;
  gap: 1.5rem;&lt;br /&gt;
  grid-template-columns: minmax(0, 1fr) minmax(290px, 0.8fr);&lt;br /&gt;
  padding: 1.2rem 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-quick-start .s3rm-create-panel h3 {&lt;br /&gt;
  font-size: 1rem;&lt;br /&gt;
  margin: 0 0 0.35rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-quick-start .s3rm-create-panel p {&lt;br /&gt;
  color: var(--s3-muted);&lt;br /&gt;
  font-size: 0.79rem;&lt;br /&gt;
  line-height: 1.55;&lt;br /&gt;
  margin: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-quick-start .s3rm-create-panel .s3rm-create-hint {&lt;br /&gt;
  margin-top: 0.35rem;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-quick-start .s3rm-form-launcher {&lt;br /&gt;
  background: #f1f2f7;&lt;br /&gt;
  border: 1px solid var(--s3-line);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-recent-section .s3rm-section-header {&lt;br /&gt;
  align-items: flex-end;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-recent-section .s3rm-recent-list {&lt;br /&gt;
  border-bottom: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.s3rm-recent-section .s3rm-lesson-summary {&lt;br /&gt;
  margin-left: 0;&lt;br /&gt;
  margin-right: 0;&lt;br /&gt;
  padding-left: 0;&lt;br /&gt;
  padding-right: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
@media screen and (max-width: 960px) {&lt;br /&gt;
  .s3rm-home-lower {&lt;br /&gt;
    gap: 2.5rem;&lt;br /&gt;
    grid-template-columns: 1fr;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-create-panel {&lt;br /&gt;
    grid-template-columns: 1fr;&lt;br /&gt;
    gap: 1.1rem;&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
@media screen and (max-width: 720px) {&lt;br /&gt;
  .mw-body-header {&lt;br /&gt;
    padding: 1.35rem 1.15rem;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .page-Main_Page .mw-body-header,&lt;br /&gt;
  .page-연구_메모리 .mw-body-header {&lt;br /&gt;
    padding: 1rem 0 0.75rem;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-home-actions {&lt;br /&gt;
    grid-template-columns: 1fr;&lt;br /&gt;
    margin-bottom: 2.5rem;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-graph-section {&lt;br /&gt;
    margin-left: -0.75rem;&lt;br /&gt;
    margin-right: -0.75rem;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-graph-heading {&lt;br /&gt;
    align-items: flex-start;&lt;br /&gt;
    flex-direction: column;&lt;br /&gt;
    gap: 0.85rem;&lt;br /&gt;
    padding: 1.1rem;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-semantic-search {&lt;br /&gt;
    padding: 1rem;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-semantic-form {&lt;br /&gt;
    align-items: stretch;&lt;br /&gt;
    flex-direction: column;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-semantic-submit {&lt;br /&gt;
    min-height: 2.75rem;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-semantic-examples {&lt;br /&gt;
    display: none;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-semantic-results {&lt;br /&gt;
    grid-template-columns: 1fr;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-graph-toolbar {&lt;br /&gt;
    align-items: stretch;&lt;br /&gt;
    flex-wrap: wrap;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-graph-search {&lt;br /&gt;
    flex-basis: 100%;&lt;br /&gt;
    max-width: none;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-graph-filter {&lt;br /&gt;
    flex: 1;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-graph-body {&lt;br /&gt;
    grid-template-columns: 1fr;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-graph-canvas,&lt;br /&gt;
  .s3rm-graph-viewport {&lt;br /&gt;
    min-height: 390px;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-graph-inspector {&lt;br /&gt;
    border-left: 0;&lt;br /&gt;
    border-top: 1px solid #292f4b;&lt;br /&gt;
    min-height: 10rem;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-inspector-welcome {&lt;br /&gt;
    min-height: 8rem;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-graph-footer {&lt;br /&gt;
    align-items: flex-start;&lt;br /&gt;
    flex-direction: column;&lt;br /&gt;
    padding-bottom: 0.6rem;&lt;br /&gt;
    padding-top: 0.6rem;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-graph-legend {&lt;br /&gt;
    gap: 0.55rem;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-onboarding-steps {&lt;br /&gt;
    grid-template-columns: 1fr;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-onboarding-steps li,&lt;br /&gt;
  .s3rm-onboarding-steps li:first-child,&lt;br /&gt;
  .s3rm-onboarding-steps li:last-child {&lt;br /&gt;
    border-right: 0;&lt;br /&gt;
    padding: 0.8rem 0;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-action-card,&lt;br /&gt;
  .s3rm-action-card:first-child,&lt;br /&gt;
  .s3rm-action-card:last-child {&lt;br /&gt;
    border-bottom: 1px solid var(--s3-line);&lt;br /&gt;
    border-right: 0;&lt;br /&gt;
    padding: 0.85rem 0;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-action-card:last-child {&lt;br /&gt;
    border-bottom: 0;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-content-section {&lt;br /&gt;
    margin-bottom: 2.5rem;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-lesson-table th,&lt;br /&gt;
  .s3rm-lesson-table td,&lt;br /&gt;
  .formtable th,&lt;br /&gt;
  .formtable td {&lt;br /&gt;
    display: block;&lt;br /&gt;
    min-width: 0;&lt;br /&gt;
    width: auto;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-lesson-table th,&lt;br /&gt;
  .formtable th {&lt;br /&gt;
    border-bottom: 0;&lt;br /&gt;
    padding-bottom: 0.15rem;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .s3rm-lesson-table td,&lt;br /&gt;
  .formtable td {&lt;br /&gt;
    padding-top: 0.15rem;&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
@media (prefers-reduced-motion: reduce) {&lt;br /&gt;
  html { scroll-behavior: auto; }&lt;br /&gt;
&lt;br /&gt;
  *,&lt;br /&gt;
  *::before,&lt;br /&gt;
  *::after {&lt;br /&gt;
    transition-duration: 0.01ms !important;&lt;br /&gt;
  }&lt;br /&gt;
}&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC/%EB%B0%B1%EC%97%85%EA%B3%BC_%EB%B3%B5%EC%9B%90&amp;diff=1732</id>
		<title>도움말:S3 연구 메모리/백업과 복원</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC/%EB%B0%B1%EC%97%85%EA%B3%BC_%EB%B3%B5%EC%9B%90&amp;diff=1732"/>
		<updated>2026-07-18T11:01:15Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 저장소 문서 동기화&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;!-- S3RM managed repository document; edit the Markdown source, not this page. --&amp;gt;&lt;br /&gt;
&#039;&#039;이 페이지는 저장소 문서에서 자동으로 동기화됩니다. 위키에서 직접 편집하지 마세요.&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
Semantic MediaWiki가 유일한 Lesson 저장소입니다. 복구 지점에는 같은 시점의 MariaDB 덤프, 업로드 파일, 생성된 위키 설정이 모두 있어야 합니다. 공용 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 상태를 저장하지 않는 전송·검증 어댑터입니다. Lesson 볼륨, 토큰 DB, 별도 기준 데이터가 없으므로 백업하지 않습니다. 이미지와 설정은 저장소와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 다시 만듭니다.&lt;br /&gt;
&lt;br /&gt;
Caddy는 [https://s3wiki.yonsei.ac.kr/]와 [https://s3wiki.yonsei.ac.kr/mcp]의 HTTPS 진입점이며 지식을 저장하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
복구 스크립트는 Bash 4 이상, GNU coreutils(&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;stat&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;sha256sum&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;), GNU tar, gzip이 있는 GNU/Linux Docker 호스트에서 지원합니다. 필요한 명령이 없으면 바로 실패합니다. macOS와 Windows Docker Desktop에서는 이 Compose 프로젝트를 소유한 Linux VM 또는 Linux 호스트에서 실행하세요. BSD 명령 옵션이 같다고 가정하지 마세요.&lt;br /&gt;
&lt;br /&gt;
=== 영구 데이터 ===&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Compose 볼륨 !! 내용 !! 복구 때 역할&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db_data&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || MediaWiki 페이지, 의미 데이터, 사용자, 변경 이력, 로그, 호환 필드 || 기준 데이터베이스&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db_binlogs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || MariaDB ROW binary log와 index || 기준 덤프 이후 시간점 복구(PITR). 기본 14일 보존&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki_images&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 업로드한 근거 파일과 MediaWiki 업로드 메타데이터 || DB 덤프와 같은 시점이어야 함&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki_config&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 생성된 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;LocalSettings.php&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 MediaWiki 비밀 키 || 위키 식별·설정 보존에 필요&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;caddy_data&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 발급된 TLS 인증서, 개인 키, Caddy 실행 상태 || 평소 보존하되 Lesson 기준 데이터는 아님&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;caddy_config&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || Caddy 실행 설정 || 저장소와 공개 hostname으로 다시 생성 가능&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;embedding_models&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || SHA-256으로 확인한 번들 GGUF || 다시 받을 수 있는 파생 캐시. Lesson 복구 세트가 아님&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
일반 백업 스크립트는 DB 덤프, 설정, 업로드와 함께 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db_binlogs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 캡처 시점 구간과 덤프에 내장된 replay 시작 file·position을 저장합니다. 이 항목이 Lesson 복구 세트입니다. 새 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db_binlogs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 볼륨은 one-shot &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db-binlog-init&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 MariaDB 계정만 쓸 수 있도록 소유권과 mode를 맞춘 뒤 DB가 시작합니다. 이 서비스가 실패하면 DB를 시작하지 않으며, 볼륨 권한을 넓혀 우회하지 않습니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;caddy_data&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;caddy_config&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 평소 재시작 때 남겨 두되, 버전이 있는 위키 백업에 섞지 않습니다. 이 볼륨이 없어지면 DNS와 외부 80/443이 정상일 때 Caddy가 인증서를 다시 받을 수 있습니다. 일반 운영에서는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;docker compose down --volumes&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 쓰지 마세요.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;embedding_models&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에는 Lesson, 검색 query나 벡터가 없습니다. 백업·복원 스크립트는 이 볼륨을 포함하지 않으며, 의미 검색을 다시 켤 때 고정된 원본과 SHA-256으로 재구성합니다. 평소에는 재다운로드와 서비스 중단을 피하도록 볼륨을 남겨 둡니다.&lt;br /&gt;
&lt;br /&gt;
저장소에는 다시 만들 수 있는 코드, 템플릿, 양식이 있습니다. 자격 증명이 든 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 추적하지 않으며 일반 백업 아카이브에도 넣지 않습니다. 서버 담당자가 복구용 사본을 따로 보관하되 Git이나 공개 아카이브에는 넣지 마세요.&lt;br /&gt;
&lt;br /&gt;
복구 세트의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;runtime-images.txt&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 Compose에 설정한 image reference와 실제 컨테이너 image ID를 함께 기록합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;repository-tracked.patch.gz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 현재 HEAD 기준의 &#039;&#039;&#039;추적된 파일&#039;&#039;&#039; 차이만 담습니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, untracked dump, upload, 자격 증명은 구조적으로 제외됩니다. untracked 파일이 있었다면 metadata에 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;present_not_archived&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;만 남고 내용과 이름은 보관하지 않습니다. root systemd timer도 이 checkout의 정규화된 경로만 Git 명령별 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;safe.directory&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 허용하므로 전역 Git 신뢰 설정을 바꾸지 않으면서 같은 source metadata와 patch를 만듭니다.&lt;br /&gt;
&lt;br /&gt;
서버 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에는 MCP 쓰기 토큰의 해시만 있습니다. 전달용 원문 토큰이 든 mode &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;0600&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;~/.config/s3-research-memory/client.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;도 최소 한 곳에 보관하세요. 원문 토큰을 모두 잃었다면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;./scripts/configure-mcp-token.sh --rotate&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 새 토큰을 만들고 쓰기 클라이언트에 다시 전달합니다. 이 작업 중에도 공개 조회는 계속됩니다.&lt;br /&gt;
&lt;br /&gt;
=== 백업 ===&lt;br /&gt;
&lt;br /&gt;
저장소 밖의 절대 경로를 사용합니다. 가능하면 Docker 호스트와 다른 디스크를 선택하고, 서버 담당자만 읽고 쓸 수 있게 만듭니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
install -d -m 0700 /srv/s3rm-backups&lt;br /&gt;
docker compose stop mcp-http&lt;br /&gt;
./scripts/backup.sh /srv/s3rm-backups&lt;br /&gt;
docker compose up -d mcp-http&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
먼저 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 멈추면 에이전트 변경 경로가 닫힙니다. 백업 스크립트는 DB를 기다린 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-jobs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 실행 중인 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 멈춰 브라우저와 job 변경도 막습니다. MariaDB binary log를 교체해 file·position을 고정한 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--master-data=2&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 논리 덤프, binary-log 구간, 업로드·설정 볼륨을 묶습니다. 백업과 복구 스크립트는 같은 checkout 디렉터리에 nonblocking maintenance lock을 겁니다. 예약 백업, 수동 백업, 복구 중 둘 이상을 겹쳐 실행하면 나중 시작한 작업이 즉시 실패합니다. 다른 checkout으로 같은 운영 Compose project를 동시에 관리하지 마세요. 백업 종료 때는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 실행 중이던 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-jobs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 순서로 복원하고 job worker health를 확인한 뒤에만 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 다시 엽니다. worker가 준비되지 않으면 백업 파일 생성 여부와 관계없이 실패로 끝나며 MCP는 중지 상태를 유지합니다. 실패해도 exit trap이 스크립트가 관리한 서비스를 백업 전 상태로 돌립니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 명시적으로 멈췄으므로 성공하거나 원인을 확인한 실패 뒤에도 마지막 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;up&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 명령을 실행해야 합니다.&lt;br /&gt;
&lt;br /&gt;
모든 파일의 형식과 checksum을 확인한 뒤에만 시각이 붙은 최종 디렉터리를 공개합니다. 덤프나 근거 아카이브를 저장소에 넣지 않습니다. 끝에 출력되는 복구 디렉터리의 정확한 경로를 기록하세요.&lt;br /&gt;
&lt;br /&gt;
==== binary log를 처음 켜는 서버 ====&lt;br /&gt;
&lt;br /&gt;
이 설정을 배포하기 전부터 실행 중인 DB는 컨테이너를 다시 만들기 전까지 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;log_bin=OFF&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;일 수 있습니다. 이 상태에서 일반 백업은 중단되며, 최초 이전 전에만 다음 명시적 기준 백업을 사용합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
./scripts/backup.sh --legacy-base /srv/s3rm-backups&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--legacy-base&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;log_bin=OFF&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;인 DB에서만 실행됩니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;database.sql.gz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 두 볼륨 아카이브, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;metadata.txt&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;SHA256SUMS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 이루어진 복원 가능한 format 1 기준 백업이며 PITR 보조 파일은 &#039;&#039;&#039;하나도&#039;&#039;&#039; 생성하지 않습니다. 나중에 별도 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;database-binlogs.tar.gz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;나 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;AUXILIARY-SHA256SUMS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 더해 일반 백업처럼 만들지 마세요. 보조 파일이 일부만 있는 세트는 복원과 격리 검증이 거부합니다.&lt;br /&gt;
&lt;br /&gt;
체크섬을 확인하고 완성된 디렉터리를 원격 저장소에 복사한 뒤에만 DB를 재생성합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db_data&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 지우지 않고 새 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db_binlogs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 볼륨을 연결합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose stop mediawiki-jobs mcp-http bootstrap mediawiki&lt;br /&gt;
docker compose up -d --force-recreate --wait db&lt;br /&gt;
docker compose exec -T db sh -eu -c &#039;&lt;br /&gt;
  MYSQL_PWD=&amp;quot;$MARIADB_ROOT_PASSWORD&amp;quot; mariadb \&lt;br /&gt;
    --host=127.0.0.1 --user=root --batch \&lt;br /&gt;
    --execute=&amp;quot;SHOW VARIABLES LIKE &#039;\&#039;&#039;log_bin&#039;\&#039;&#039;; SHOW MASTER STATUS;&amp;quot;&lt;br /&gt;
&#039;&lt;br /&gt;
make domain-up&lt;br /&gt;
./scripts/backup.sh /srv/s3rm-backups&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
출력에 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;log_bin\tON&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;과 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mariadb-bin.NNNNNN&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 행이 모두 있어야 합니다. 마지막 일반 백업은 PITR 보조 체인까지 갖춘 새 기준입니다. 이후에는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--legacy-base&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 사용하지 마세요.&lt;br /&gt;
&lt;br /&gt;
복구 디렉터리는 다음 파일을 한 세트로 가집니다.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 파일 !! 내용&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;database.sql.gz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 설정된 MediaWiki DB 전체 논리 덤프&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;database-binlogs.tar.gz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 일반 백업만: 캡처 시점까지 보존된 MariaDB ROW binary log·index 보조 아카이브&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;pitr-manifest.txt&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 일반 백업만: 기준 dump 뒤 replay를 시작할 binary-log file·position·GTID, archive 목록·digest&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-images.tar.gz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 업로드·근거 볼륨의 numeric-owner 아카이브&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-config.tar.gz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 생성된 설정과 위키 키의 numeric-owner 아카이브&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;runtime-images.txt&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 일반 백업만: 설정 reference와 실제 실행 컨테이너 image ID&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;repository-tracked.patch.gz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 일반 백업만: HEAD에 적용할 추적 소스 binary patch. untracked·ignored 파일 제외&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;repository-tracked-manifest.txt&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 일반 백업만: 추적 파일별 SHA-256과 소스 상태&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;metadata.txt&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 형식 버전, DB 이름, 페이지·revision 수, 소스 revision, 이미지 참조, Compose 해시. 일반 백업은 PITR position도 포함&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;SHA256SUMS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 앞의 네 payload checksum&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;AUXILIARY-SHA256SUMS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 일반 백업만: PITR·image·tracked-source 보조 파일 checksum. 이 파일 digest는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;metadata.txt&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 통해 기본 manifest와 결합&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
기본 대상, 각 복구 디렉터리, 모든 파일의 권한은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;0700&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 또는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;0600&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;입니다. 기존 대상에 group/other 접근 비트가 하나라도 있으면 스크립트가 거부하며 권한을 임의로 바꾸지 않습니다. 백업할 때 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 실행 중이어야 합니다. 위키가 원래 정지 상태였다면 그대로 두고, 실행 중이었다면 API가 다시 healthy가 될 때까지 기다린 뒤 성공을 알립니다. 복원도 소스 디렉터리에 같은 권한 검사를 합니다.&lt;br /&gt;
&lt;br /&gt;
기본 로컬 대상에 저장하려면 경로를 생략합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose stop mcp-http&lt;br /&gt;
./scripts/backup.sh&lt;br /&gt;
docker compose up -d mcp-http&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
기본 로컬 경로만 장기 보관에 사용하지 마세요. 완료된 디렉터리 전체를 접근이 제한된 외부 저장소에 복사합니다. 공개 메모가 주 내용이더라도 백업에는 사용자 정보, 삭제·이전 revision, 생성된 비밀 키가 있으므로 공개하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
==== 백업 확인 ====&lt;br /&gt;
&lt;br /&gt;
스크립트가 실패하면 그 백업은 사용할 수 없습니다. 성공한 뒤에도 다음을 확인합니다.&lt;br /&gt;
&lt;br /&gt;
# DB 덤프, 설정·이미지 아카이브, 메타데이터, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;SHA256SUMS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 있는지 확인합니다. 일반 백업은 binary-log·PITR 보조 파일 전체와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;AUXILIARY-SHA256SUMS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;도 있어야 합니다. 최초 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--legacy-base&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 보조 파일이 하나도 없어야 합니다.&lt;br /&gt;
# 스크립트가 출력한 checksum 명령을 실행합니다. ```bash (cd /ABS/PATH/TO/BACKUP-DIRECTORY &amp;amp;amp;&amp;amp;amp; \ sha256sum --check --strict SHA256SUMS) # 일반 PITR 백업에서만 추가 확인 (cd /ABS/PATH/TO/BACKUP-DIRECTORY &amp;amp;amp;&amp;amp;amp; \ sha256sum --check --strict AUXILIARY-SHA256SUMS) ```&lt;br /&gt;
# 빈 파일이 없는지, 시각과 소스 버전이 맞는지 확인합니다.&lt;br /&gt;
# 디렉터리 전체를 한 단위로 복사합니다. 서로 다른 시각의 파일을 섞지 마세요.&lt;br /&gt;
# 아래의 격리 복원 시험을 정기적으로 실행합니다. checksum은 전송 무결성만 확인하며 실제 복원 가능성을 보장하지 않습니다.&lt;br /&gt;
MediaWiki [https://www.mediawiki.org/wiki/Manual:Backing_up_a_wiki 백업 안내]도 DB와 파일을 함께 보관하도록 안내합니다. XML 페이지 내보내기는 보조 이동본으로 쓸 수 있지만 사용자 계정, 모든 로그, 업로드 메타데이터, 사이트 전체 상태를 담지 않으므로 이 복구 세트를 대신할 수 없습니다.&lt;br /&gt;
&lt;br /&gt;
==== MariaDB PITR 경계 ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;database.sql.gz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 완전 기준 백업입니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;pitr-manifest.txt&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;binlog_file&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;·&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;binlog_position&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 그 덤프가 이미 포함한 마지막 경계이자 replay 시작 좌표입니다. 이 좌표 이전 event를 다시 적용하면 중복 변경이 생깁니다. 이후 시각으로 이동하려면 기준 좌표 &#039;&#039;&#039;이후&#039;&#039;&#039;의 연속된 binary log가 전부 있어야 합니다. 백업 당시 archive에는 아직 생기지 않은 미래 event가 들어 있을 수 없으므로, 더 늦게 만든 복구 세트의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;database-binlogs.tar.gz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;나 별도 연속 보관본이 필요합니다. 로컬 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db_binlogs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 기본 14일 후 자동 만료되므로 원격 백업과 복원 시험이 이 기간 안에 성공해야 합니다.&lt;br /&gt;
&lt;br /&gt;
PITR은 운영 DB에 바로 재생하지 마세요. 격리 복원을 먼저 완료한 뒤, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mariadb-binlog --start-position&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에는 manifest의 좌표 다음 event부터 적용되도록 경계를 확인하고, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--stop-datetime&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 또는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--stop-position&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 명시하여 SQL을 만듭니다. 재생 전에 시작·종료 file, position, target UTC, 예상 event 수를 작업 기록에 남기고, Lesson·revision 개수와 대표 페이지를 확인합니다. binary log가 하나라도 빠졌거나 기준 position보다 이전 백업과 섞였다면 PITR를 계속하지 말고 다음 완전 백업으로 복원합니다.&lt;br /&gt;
&lt;br /&gt;
==== 권장 주기 ====&lt;br /&gt;
&lt;br /&gt;
파일럿에서는 다음 주기로 시작합니다.&lt;br /&gt;
&lt;br /&gt;
* 매일 백업, 14일 보관&lt;br /&gt;
* 매주 백업, 8주 보관&lt;br /&gt;
* 이미지·의존성 업데이트나 자격 증명 변경 전 백업&lt;br /&gt;
* 분기마다 격리 복원 시험&lt;br /&gt;
실제 복구 시점 목표는 운영 상황에 맞게 정합니다. 스케줄러 종료 상태와 디스크 여유 공간을 확인하세요. 오류 알림이 없는 예약 명령만으로는 충분하지 않습니다. 새 복원을 확인하기 전에 가장 최근의 정상 백업을 지우지 마세요.&lt;br /&gt;
&lt;br /&gt;
==== Cloudflare R2 자동 백업 ====&lt;br /&gt;
&lt;br /&gt;
이 서버는 다음 비공개 R2 bucket의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;restic&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; prefix를 원격 백업 저장소로 사용합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
https://96f01030a087d07d906bff299d260155.r2.cloudflarestorage.com/s3rm-backups&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
백업 데이터는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;restic&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 서버에서 먼저 암호화한 뒤 전송합니다. R2 Access Key, Secret Key와 restic 암호는 Git이나 프로젝트 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 넣지 않습니다.&lt;br /&gt;
&lt;br /&gt;
서버에 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;restic&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 설치한 뒤 root 권한으로 설정 파일을 준비합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
sudo install -d -m 0700 /etc/s3-research-memory /srv/s3rm-backups&lt;br /&gt;
sudo install -m 0600 config/remote-backup.env.example \&lt;br /&gt;
  /etc/s3-research-memory/remote-backup.env&lt;br /&gt;
sudo sh -c &#039;umask 077; openssl rand -base64 48 &amp;gt; /etc/s3-research-memory/restic-password&#039;&lt;br /&gt;
sudoedit /etc/s3-research-memory/remote-backup.env&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
R2에서 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;s3rm-backups&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; bucket에만 목록 조회·읽기·쓰기·삭제가 가능한 S3 API 자격 증명을 만들고 설정 파일의 두 placeholder를 바꿉니다. 삭제 권한은 오래된 restic snapshot과 pack을 보존 정책에 맞게 정리할 때 필요합니다. restic 암호 파일은 서버 장애 때도 찾을 수 있도록 서버 밖의 비밀번호 관리자에 따로 보관합니다. 이 암호를 잃으면 R2의 백업을 복구할 수 없습니다.&lt;br /&gt;
&lt;br /&gt;
현재 live restic repository의 자격 증명은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;forget&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;·&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;prune&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 위해 삭제 권한도 가집니다. 따라서 &#039;&#039;&#039;R2 자격 증명 탈취와 오작동으로부터의 불변 백업은 현재 저장소 하나만으로 달성되지 않습니다.&#039;&#039;&#039; 이 제한은 복원 시험 기록에 항상 남깁니다.&lt;br /&gt;
&lt;br /&gt;
R2의 lock·retention을 live restic prefix에 바로 적용하면 임시 lock 제거와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;forget&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;·&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;prune&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 실패할 수 있습니다. 삭제 방어는 다음과 같이 &#039;&#039;&#039;별도 보호 복사본&#039;&#039;&#039;으로 구성합니다.&lt;br /&gt;
&lt;br /&gt;
# live &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;restic&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; prefix는 현재 보존 정책으로 검증·정리합니다.&lt;br /&gt;
# 검증한 복구 세트 또는 restic repository 세대를 별도 bucket·account의 보호 prefix로 복사합니다. 복사 writer와 retention 변경 권한을 분리합니다.&lt;br /&gt;
# 보호 복사본은 일간 운영 키로 삭제할 수 없게 만들고, 예외 해제 권한은 다른 계정에 보관합니다.&lt;br /&gt;
# Cloudflare 계정 MFA, bucket 범위 API token, 삭제·retention 변경 알림, 분기별 보호 복사본 복원 시험을 함께 적용합니다.&lt;br /&gt;
별도 bucket/retention/API token은 Cloudflare 외부 설정이므로 이 저장소의 Compose나 systemd 파일만으로는 활성화할 수 없습니다. 이 설정이 완료되기 전에는 live R2를 유일한 삭제 방어 본으로 간주하지 마세요.&lt;br /&gt;
&lt;br /&gt;
원격 저장소는 한 번만 초기화합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
sudo bash -c &#039;&lt;br /&gt;
  set -a&lt;br /&gt;
  source /etc/s3-research-memory/remote-backup.env&lt;br /&gt;
  set +a&lt;br /&gt;
  exec /home/mrcha033/Projects/s3wiki/scripts/init-remote-backup.sh&lt;br /&gt;
&#039;&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
systemd 단위를 설치하고 첫 백업을 수동으로 확인합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
sudo install -m 0644 deploy/systemd/s3rm-remote-backup.service \&lt;br /&gt;
  /etc/systemd/system/s3rm-remote-backup.service&lt;br /&gt;
sudo install -m 0644 deploy/systemd/s3rm-remote-backup.timer \&lt;br /&gt;
  /etc/systemd/system/s3rm-remote-backup.timer&lt;br /&gt;
sudo systemctl daemon-reload&lt;br /&gt;
sudo systemctl start s3rm-remote-backup.service&lt;br /&gt;
sudo systemctl status s3rm-remote-backup.service&lt;br /&gt;
sudo systemctl enable --now s3rm-remote-backup.timer&lt;br /&gt;
systemctl list-timers s3rm-remote-backup.timer&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
작업은 매일 서울 시각 03:15 이후 45분 안에 실행합니다. 성공한 원격 snapshot은 일간 14개와 주간 8개를 보존하고, 원격 pack 정리는 일요일에 실행합니다. 전송이나 검증이 실패하면 로컬 복구 세트를 지우지 않습니다. 성공하면 R2에 이미 암호화된 snapshot을 확인한 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/srv/s3rm-backups&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 해당 임시 세트를 정리합니다. snapshot 경로에는 매번 다른 시각이 들어가므로 restic 보존 그룹은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;host,tags&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로만 묶습니다. 경로까지 그룹 기준에 넣으면 각 snapshot이 항상 자신만의 그룹이 되어 14일·8주 정책이 아무 것도 만료시키지 못합니다.&lt;br /&gt;
&lt;br /&gt;
상태와 최근 로그를 정기적으로 확인합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
systemctl status s3rm-remote-backup.timer&lt;br /&gt;
sudo journalctl -u s3rm-remote-backup.service --since &#039;2 days ago&#039;&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
원격 snapshot을 복구할 때는 빈 root 전용 디렉터리에 먼저 내려받고 기존 복원 스크립트로 넘깁니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
sudo install -d -m 0700 /srv/s3rm-remote-restore&lt;br /&gt;
sudo bash -c &#039;&lt;br /&gt;
  set -a&lt;br /&gt;
  source /etc/s3-research-memory/remote-backup.env&lt;br /&gt;
  set +a&lt;br /&gt;
  restic snapshots --tag s3rm-recovery-set&lt;br /&gt;
  restic restore latest --tag s3rm-recovery-set --target /srv/s3rm-remote-restore&lt;br /&gt;
&#039;&lt;br /&gt;
sudo find /srv/s3rm-remote-restore -maxdepth 2 -type f -name SHA256SUMS -print&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
출력된 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;SHA256SUMS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 상위 디렉터리가 한 개이고 원하는 시각의 복구 세트인지 확인한 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;scripts/restore.sh &amp;amp;lt;그 디렉터리&amp;amp;gt; --yes&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 사용합니다. 운영 서버를 덮어쓰기 전에 격리 복원 시험을 먼저 수행합니다.&lt;br /&gt;
&lt;br /&gt;
=== Lesson 내용 정기 감사 ===&lt;br /&gt;
&lt;br /&gt;
읽기 전용 감사 작업은 매주 운영 위키의 안정된 Lesson을 중앙 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 읽습니다. 실행 전 30분 안에 바뀐 Lesson은 다음 감사로 미룹니다. 별도 Lesson 저장소나 기준 파일을 만들지 않습니다. 쓰기 토큰도 사용하지 않습니다. 외부 근거 URL을 열거나 Lesson을 자동으로 수정하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
감사 작업은 다음 항목을 확인합니다.&lt;br /&gt;
&lt;br /&gt;
* 구조화된 근거가 없는 Lesson&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;high&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; confidence인데 최신 append-only 검증 기록이 observation, interpretation, reusable_lesson 전부를 strong &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supports&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 뒷받침하지 않는 Lesson&lt;br /&gt;
* 확인 범위가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;not_recorded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;인 근거&lt;br /&gt;
* 같거나 거꾸로 된 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;verified_at&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 append 순서의 현재 판정을 정할 수 없는 Lesson&lt;br /&gt;
* 서로 같은 관찰·해석 또는 재사용 내용·적용 조건&lt;br /&gt;
* 대상이 없는 relation과 중복 relation&lt;br /&gt;
* 365일 이상 바뀌지 않은 검토 후보&lt;br /&gt;
* 비밀 키나 토큰 형식으로 보이는 문자열&lt;br /&gt;
발견 결과에는 Lesson ID, revision ID, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;codes&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 배열과 상한이 있는 evidence·verification ID만 남깁니다. Lesson 본문, 인용과 relation note는 journal에 남기지 않습니다. 같은 Lesson의 발견 코드는 한 줄로 묶습니다. 오래된 Lesson이라는 이유만으로 틀렸다고 판정하지 않으며 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;stale_review_candidate&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로만 표시합니다. 감사는 저장된 구조, digest, append 순서와 검증 수령 기록의 일관성을 확인합니다. 외부 근거 URL을 열지 않으므로 논문·코드·데이터의 사실 진위를 자동으로 판정하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
systemd 단위를 설치하고 첫 감사를 수동으로 실행합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
sudo install -D -m 0755 scripts/audit-lessons.py \&lt;br /&gt;
  /usr/local/libexec/s3rm-content-audit&lt;br /&gt;
sudo install -m 0644 deploy/systemd/s3rm-content-audit.service \&lt;br /&gt;
  /etc/systemd/system/s3rm-content-audit.service&lt;br /&gt;
sudo install -m 0644 deploy/systemd/s3rm-content-audit.timer \&lt;br /&gt;
  /etc/systemd/system/s3rm-content-audit.timer&lt;br /&gt;
sudo systemctl daemon-reload&lt;br /&gt;
sudo systemctl start s3rm-content-audit.service&lt;br /&gt;
sudo systemctl status s3rm-content-audit.service&lt;br /&gt;
sudo journalctl -u s3rm-content-audit.service --since today&lt;br /&gt;
sudo systemctl enable --now s3rm-content-audit.timer&lt;br /&gt;
systemctl list-timers s3rm-content-audit.timer&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
작업은 일요일 서울 시각 04:30 이후 30분 안에 실행합니다. 놓친 실행은 서버가 다시 켜졌을 때 실행합니다. 배포 단위와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;make audit-lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 현재 서버의 scan limit과 같은 500개 Lesson을 읽습니다. 스크립트 자체의 기본값은 200개이고 설정 가능한 절대 상한은 1000개입니다. Lesson당 근거·relation은 각각 최대 200개를 읽습니다. 서버의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;scan_limit&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 목록이 잘리거나 감사 중 revision과 Lesson 목록이 바뀌면 완료로 기록하지 않습니다. 연결 오류나 동시 변경으로 끝나면 5분 간격으로 최대 세 번 시도합니다. 발견 사항을 뜻하는 종료 코드 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;2&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 다시 시도하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
종료 코드는 다음과 같습니다.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 코드 !! 뜻 !! 다음 작업&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;0&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 감사를 완료했고 발견 사항이 없음 || 다음 예약 실행을 기다립니다.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 연결, 출력 한도 또는 동시 변경으로 감사를 완료하지 못함 || journal의 마지막 오류를 확인하고 다시 실행합니다.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;2&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 감사를 완료했고 확인할 항목이 있음 || 발견된 Lesson을 읽고 근거를 확인합니다.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;systemd&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 서비스는 임시 계정으로 실행합니다. 홈 디렉터리, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, Docker 소켓과 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/etc/s3-research-memory&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 읽을 수 없습니다. 발견 사항이 있으면 실패 상태로 표시합니다. 이 상태는 Lesson을 바꾸지 않으며 다음 예약 실행도 막지 않습니다. 문제를 고칠 때는 근거를 먼저 붙인 뒤 같은 Lesson ID에 현재 revision을 기준으로 새 revision을 추가합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supersedes&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;나 근거 note를 기존 주장 수정 수단으로 쓰지 않습니다.&lt;br /&gt;
&lt;br /&gt;
예약과 무관하게 현재 상태를 확인할 때는 다음 명령을 사용합니다. 발견 사항이 있어도 수동 명령은 감사 완료 뒤 코드 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;0&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 반환합니다. 자동 점검과 같은 종료 코드가 필요하면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--fail-on-findings&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 추가합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
make audit-lessons&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 복원 전 주의 ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;scripts/restore.sh&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 현재 DB, 업로드, 생성된 위키 설정을 교체합니다. 명시적인 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--yes&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 필요하며, 공지한 점검 시간에만 실행합니다. 병합이나 페이지 가져오기 도구가 아닙니다.&lt;br /&gt;
&lt;br /&gt;
복원 전에 다음을 확인합니다.&lt;br /&gt;
&lt;br /&gt;
* 정확한 백업 디렉터리를 정하고 모든 checksum을 확인합니다.&lt;br /&gt;
* 백업의 MediaWiki·애플리케이션 버전이 현재 checkout과 호환되는지 확인합니다. 새 DB를 더 오래된 코드에 복원할 수 없습니다.&lt;br /&gt;
* 점검 시간을 알리고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 멈춘 뒤 브라우저 편집도 중단합니다.&lt;br /&gt;
* 현재 상태를 읽을 수 있으면 복원 직전 백업을 하나 더 만듭니다.&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 복원할 인스턴스와 맞는 자격 증명이 있는지 확인합니다.&lt;br /&gt;
* 복원을 결정한 사람과 이유를 기록합니다.&lt;br /&gt;
=== 현재 서버 복원 ===&lt;br /&gt;
&lt;br /&gt;
백업을 만든 것과 같은 저장소 버전에서 실행합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose --profile domain stop caddy mcp-http&lt;br /&gt;
./scripts/restore.sh /ABS/PATH/TO/BACKUP-DIRECTORY --yes&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
스크립트는 checksum manifest, 백업 형식 버전, 압축 stream, 모든 아카이브 member의 경로와 종류를 먼저 확인합니다. 보조 파일이 있는 형식은 auxiliary manifest digest와 binary-log·source artifact도 모두 확인합니다. 백업의 DB 이름과 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;MARIADB_DATABASE&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;도 같아야 합니다. 다르면 덤프를 편집하지 말고 복구용 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 맞춥니다. 이 검증과 이미지 빌드 사전 확인이 모두 끝난 뒤에만 애플리케이션을 멈추고 기준 DB·설정·업로드를 교체합니다.&lt;br /&gt;
&lt;br /&gt;
기준 dump import는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;sql_log_bin=0&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 세션에서 실행합니다. 이어서 DB를 멈추고 기존 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db_binlogs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 &#039;&#039;&#039;더 새로운 운영 timeline을 비운 뒤&#039;&#039;&#039; 새 local timeline으로 시작합니다. 따라서 과거 DB에 미래 binlog가 이어지는 일은 없지만, backup의 archived binlog를 자동 replay하지도 않습니다. PITR이 필요하면 운영 복원 전에 별도 격리 환경에서 목표 UTC·종료 position을 정해 검증한 절차로만 적용합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 시작 때 MediaWiki·확장 기능 마이그레이션을 적용하고, 이어서 반복 실행이 가능한 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 적용합니다. 그 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-job-state-init&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 강제로 다시 실행해 새 복구 볼륨을 UID/GID 33 쓰기 경로로 만든 다음 bounded &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-jobs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 시작해 worker health까지 확인합니다. 복구 중 MediaWiki host bind는 임시로 loopback에 강제하고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;caddy&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 성공 후에도 중지한 상태로 둡니다. 따라서 마이그레이션· bootstrap 중 공개 브라우저나 MCP 쓰기가 재개되지 않습니다. 교체 경고가 나온 뒤에는 중단하지 마세요. 오류가 나오면 클라이언트를 멈춘 상태로 로그와 소스 백업을 보존합니다. 실패한 상태 위에 부분 SQL이나 일부 볼륨을 임의로 덮어쓰지 마세요.&lt;br /&gt;
&lt;br /&gt;
완료 뒤 상태를 확인합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose ps -a&lt;br /&gt;
docker compose logs mediawiki bootstrap mediawiki-jobs&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
아래 MediaWiki 복구 확인을 마칠 때까지 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 멈춰 둡니다. 확인 뒤 현재 설정으로 공용 어댑터를 다시 만들고, 공개 조회와 토큰 기반 변경을 모두 시험합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose --profile domain up -d --force-recreate mediawiki mcp-http caddy&lt;br /&gt;
docker compose ps mediawiki-jobs mcp-http&lt;br /&gt;
docker compose logs --tail=100 mcp-http&lt;br /&gt;
./scripts/verify-public-domain.sh s3wiki.yonsei.ac.kr&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 격리 복원 시험 ===&lt;br /&gt;
&lt;br /&gt;
실제 서버를 덮어쓰지 말고 별도 Compose 프로젝트와 사용하지 않는 포트에서 시험합니다. 별도 checkout을 사용하고, 복구 디렉터리는 이동하지 말고 복사합니다. Compose 프로젝트와 볼륨 이름을 고유하게 지정하세요. 최상위 이름은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;COMPOSE_PROJECT_NAME&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 바꿀 수 있으며 두 스크립트가 이 값을 사용합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;COMPOSE&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 환경 변수에는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;docker compose --ansi never&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;처럼 고정 접두사를 넣을 수 있습니다. 설치, 백업, 복원에 같은 값을 사용하세요.&lt;br /&gt;
&lt;br /&gt;
권장 경로는 전용 검증 스크립트입니다. 운영 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 아닌 mode &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;0600&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 일회용 env에 사용하지 않는 loopback 포트, 일회용 DB·wiki 비밀번호, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;MW_SERVER_URL=http://127.0.0.1:18080&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;과 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_WIKI_BIND_ADDRESS=127.0.0.1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_HTTP_PORT=18080&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 넣습니다. 복원용 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_WIKI_BOT_PASSWORD&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;도 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;openssl rand -hex 16&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 따로 만들고, 운영 BotPassword와 primary 위키 비밀번호를 재사용하지 않습니다. 검증 스크립트는 DB를 가져온 직후 격리 project에서만 복원된 관리자 비밀번호를 일회용 env의 관리자 secret으로 바꿉니다. secret은 명령행 인자나 로그에 넣지 않으며, 격리 환경을 지울 때 변경된 관리자 해시도 함께 없어집니다. 운영 project에는 이 비밀번호 재설정 옵션을 사용할 수 없습니다. 실제 Compose project 이름과 모든 volume·network 이름도 함께 검사하므로 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;COMPOSE&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 접두사는 검증 스크립트가 만든 고정 명령만 허용합니다. 따라서 다른 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--project-name&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, Compose override, bind mount, 외부 volume 또는 별칭을 덧붙여 이 경계를 우회할 수 없습니다. bootstrap의 관리자·MCP 기본 계정·BotPassword도 secret 파일에서 직접 읽으며 평문을 자식 프로세스 환경이나 명령행 인자로 전달하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
chmod 0600 /srv/s3rm-restore-test.env&lt;br /&gt;
./scripts/verify-restore-isolated.sh /ABS/PATH/TO/BACKUP-DIRECTORY \&lt;br /&gt;
  --env-file /srv/s3rm-restore-test.env \&lt;br /&gt;
  --project-name s3rm-restore-2026q3&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
스크립트는 project 접두사, 새 project에 기존 컨테이너·볼륨·네트워크가 없는지와 resolved Compose가 사용할 실제 volume·network 이름도 모두 비어 있는지, loopback 주소, 기본 checksum과, 있는 경우 PITR 보조 checksum 체인을 &#039;&#039;&#039;변경 전&#039;&#039;&#039; 확인합니다. 격리 project 이름으로 전용 MediaWiki image tag를 만들어 운영 image tag를 동시에 다시 빌드하지 않습니다. 복원 후 Lesson 개수는 정확히 일치해야 하고, revision은 bootstrap이 관리 페이지 변경을 추가할 수 있으므로 백업 기록 &#039;&#039;&#039;이상&#039;&#039;&#039;이어야 합니다. MediaWiki API, bootstrap, bounded &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-jobs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; health도 확인합니다. 성공하면 격리 볼륨을 지우고, 실패하면 진단을 위해 보존합니다. 성공 시 project 전용 MediaWiki image tag도 함께 지웁니다. 자동 정리가 실패하면 스크립트가 실패 상태와 정확한 수동 정리 명령을 출력합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--keep&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 사용하면 성공한 환경도 보존합니다. 이 검증은 스키마·개수·시작 경계를 자동 확인하지만, 아래의 사람이 열어 보는 대표 Lesson·업로드·검색 확인을 대신하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
Docker가 설치된 일회용 VM에서는 다음 순서가 안전합니다.&lt;br /&gt;
&lt;br /&gt;
# 백업과 정확히 같은 저장소 revision, 복구 디렉터리, 복구용 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 VM에 복사합니다.&lt;br /&gt;
# &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;COMPOSE_PROFILES=&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 실제 hostname과 80/443을 사용하지 않게 합니다. 겹치지 않는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_HTTP_PORT&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 맞는 로컬 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;MW_SERVER_URL&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 설정합니다. 별도 loopback &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_HTTP_PORT&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;·&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_PUBLIC_URL&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;과 일회용 MCP 토큰도 사용합니다. 실제 MCP URL이나 토큰을 복원 시험에 쓰지 마세요.&lt;br /&gt;
# VM에서 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;scripts/restore.sh &amp;amp;lt;backup-directory&amp;amp;gt; --yes&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 실행합니다.&lt;br /&gt;
# 아래 복구 확인을 모두 수행합니다.&lt;br /&gt;
# 결과를 기록한 뒤 VM을 지웁니다. 원본 복구 세트는 그대로 둡니다.&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;docker compose config&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;docker volume ls&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 볼륨 이름이 다른지 보기 전에는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;COMPOSE_PROJECT_NAME&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;만 바꿨다고 격리된 것으로 보지 마세요.&lt;br /&gt;
&lt;br /&gt;
=== 복구 완료 확인 ===&lt;br /&gt;
&lt;br /&gt;
다음 항목을 모두 통과해야 복원이 끝납니다.&lt;br /&gt;
&lt;br /&gt;
# &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-jobs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 healthy이고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-install&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;과 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 코드 0으로 종료되었습니다.&lt;br /&gt;
# &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Special:Version&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 예상한 MediaWiki, Semantic MediaWiki, Page Forms, S3 Research Memory 버전이 표시됩니다.&lt;br /&gt;
# Lesson 페이지 수와 최근 revision 수가 백업 기록과 맞습니다.&lt;br /&gt;
# 복원된 Lesson의 필드, 근거, append-only 검증 기록, 관계, 출처, 호환 필드, 시간이 정상 표시됩니다.&lt;br /&gt;
# &#039;&#039;&#039;역사 보기&#039;&#039;&#039;에서 이전 revision 하나 이상이 열립니다.&lt;br /&gt;
# 업로드한 근거가 열리고, 외부 checksum을 기록했다면 값이 맞습니다.&lt;br /&gt;
# 전문 검색이 알려진 제목과 본문 단어를 찾습니다.&lt;br /&gt;
# 격리한 복원 시험 환경에서만 일반 브라우저 계정으로 Lesson 양식을 열고 복구 확인용 메모를 저장합니다. 바로 검색되는지 본 뒤 격리 환경과 함께 폐기합니다.&lt;br /&gt;
# 다시 만든 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 healthy입니다. 토큰 없이 초기화·도구 목록· &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;search_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 실행하면 짧은 인용 결과가 나옵니다. 쓰기 토큰을 넣은 클라이언트는 같은 격리 환경에 확인용 메모를 만들 수 있습니다. 운영 위키에는 확인용 데이터를 만들지 않습니다.&lt;br /&gt;
# 따로 설정한 두 번째 클라이언트가 같은 Lesson을 조회합니다. 두 클라이언트가 로컬 데이터가 아닌 복원된 위키 하나를 보고 있어야 합니다.&lt;br /&gt;
# MCP 계정은 여전히 삭제, 이동, 보호, 관리 기능을 사용할 수 없습니다.&lt;br /&gt;
복구 디렉터리, 소요 시간, 애플리케이션 revision, 결과, 실행자, 필요했던 조치를 기록합니다. 하나라도 실패하면 점검 상태를 유지합니다.&lt;br /&gt;
&lt;br /&gt;
=== 평소 상태 확인 ===&lt;br /&gt;
&lt;br /&gt;
상태와 제한된 길이의 로그를 확인합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose ps -a&lt;br /&gt;
docker compose logs --tail=200 db mediawiki-install mediawiki bootstrap \&lt;br /&gt;
  mediawiki-jobs mcp-http caddy&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
로컬 프로세스 경로와 두 공개 경로를 차례로 확인합니다. 로컬 확인만으로 다른 PC의 HTTPS 접속을 대신할 수 없습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
curl --fail --silent --show-error \&lt;br /&gt;
  &#039;http://localhost:8080/api.php?action=query&amp;amp;meta=siteinfo&amp;amp;format=json&#039; \&lt;br /&gt;
  &amp;gt;/dev/null&lt;br /&gt;
curl --fail --silent --show-error \&lt;br /&gt;
  &#039;http://127.0.0.1:8765/healthz&#039; \&lt;br /&gt;
  | jq --exit-status \&lt;br /&gt;
      &#039;.canonical_store == &amp;quot;ok&amp;quot; and&lt;br /&gt;
       .job_runner == &amp;quot;ok&amp;quot; and&lt;br /&gt;
       .write_admission.active &amp;gt;= 0 and .write_admission.waiting &amp;gt;= 0&#039; \&lt;br /&gt;
  &amp;gt;/dev/null&lt;br /&gt;
curl --fail --silent --show-error \&lt;br /&gt;
  &#039;https://s3wiki.yonsei.ac.kr/api.php?action=query&amp;amp;meta=siteinfo&amp;amp;format=json&#039; \&lt;br /&gt;
  &amp;gt;/dev/null&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
[[Help:S3 연구 메모리/설치와 검증#클라이언트를 연결하기 전 공용 MCP 확인|&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;setup.md&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;]]의 토큰 없는 MCP 초기화도 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;https://s3wiki.yonsei.ac.kr/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 실행합니다. 단순 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;GET /mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 streaming 연결을 계속 열어 둘 수 있으므로 상태 확인이 아닙니다.&lt;br /&gt;
&lt;br /&gt;
같은 서버에서 위 확인을 한 번에 실행하려면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;make verify-domain&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 사용합니다. 이 결과만으로 학교 밖 인바운드 경로까지 확인되지는 않습니다.&lt;br /&gt;
&lt;br /&gt;
Compose 운영의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/healthz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 canonical store와 bounded job runner가 모두 준비돼야 200을 반환합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;job_runner=unavailable&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이면 공유 상태 파일의 generation·최근 성공 시각·권한을 먼저 확인합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;write_admission&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에는 비밀값 없이 현재 active·waiting·principal 수와 동시 실행·queue 상한이 나옵니다. 기본은 전체 동시 쓰기 8개, queue 64개·2초, principal별 지속 600회/분·burst 120입니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;open&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드에서는 client IP가 principal이므로 인증 모드를 바꾸지 않아도 사용자별 admission이 적용됩니다. 지표가 계속 상한에 붙거나 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;server_busy&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 반복되면 DB latency와 실패 요청을 먼저 확인하고, 변경한 상한은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 다시 만들어 적용합니다. 이 queue는 재시작 뒤 작업을 복구하는 durable queue가 아닙니다. 전달된 IP는 Caddy와 MCP의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_PROXY_SECRET&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 맞을 때만 신뢰합니다. 이 값을 교체할 때는 둘을 같은 Compose 재생성에서 함께 전환합니다.&lt;br /&gt;
&lt;br /&gt;
MediaWiki job queue는 전용 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-jobs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 서비스가 기본 25건·20초, 1 process로 처리합니다. 상태 파일은 비밀 값이 없고 현재 queue 깊이, 마지막 성공 시각, 연속 실패 수, batch/idle 상한과 현재 runner generation을 담습니다. generation은 컨테이너 PID 1의 시작 시각에서 만들므로 이전 컨테이너나 복구 전 상태 파일은 새 runner health로 인정되지 않습니다. 상태 볼륨은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-job-state-init&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 UID/GID 33으로 준비하고 MCP에는 읽기 전용으로만 연결합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose ps mediawiki-jobs&lt;br /&gt;
docker compose ps -a mediawiki-job-state-init&lt;br /&gt;
docker compose exec mediawiki-jobs cat /run/s3rm-jobs/status.env&lt;br /&gt;
docker compose exec mediawiki-jobs /usr/local/bin/s3-mediawiki-jobs-health&lt;br /&gt;
docker compose exec mediawiki \&lt;br /&gt;
  php maintenance/run.php showJobs --group&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
runner가 실패하면 15초에서 시작해 최대 5분까지 지수 backoff합니다. 마지막 성공이 기본 120초보다 오래되면 health가 실패합니다. queue를 빠르게 비우기 위해 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--nothrottle&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이나 다중 process를 임의로 사용하지 마세요. 먼저 실패 job 종류와 DB·디스크 상태를 확인하고, 필요한 처리량만 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 조정합니다.&lt;br /&gt;
&lt;br /&gt;
Compose는 각 주요 서비스의 json-file 로그를 파일당 10MB, 최대 5개로 회전하고, PID·메모리·CPU 상한을 둡니다. 평소 사용량과 OOM·재시작 여부를 함께 확인합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker stats --no-stream&lt;br /&gt;
docker inspect --format \&lt;br /&gt;
  &#039;{{.Name}} oom={{.State.OOMKilled}} restarts={{.RestartCount}}&#039; \&lt;br /&gt;
  $(docker compose ps -q)&lt;br /&gt;
docker system df&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
상한 도달은 health check 성공만으로 보이지 않을 수 있습니다. OOM이나 PID 부족을 확인한 뒤 queue·동시성·실패 loop 원인을 먼저 고치고, 측정한 정상 peak와 호스트 여유를 근거로 상한을 조정합니다. 로그 회전 파일은 장기 감사 자료가 아니므로 필요한 운영 event는 별도 제한된 저장소에 보관합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Special:RecentChanges&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 계정 그룹, 로그인 실패, MCP 쓰기 인증 실패와 403/421, 디스크 여유 공간, 백업 시각, 복원 시험 상태를 확인합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 관리하는 스키마·양식 페이지가 반복해서 달라지면 원인을 찾으세요. 일반 Lesson revision은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 바꾸지 않습니다.&lt;br /&gt;
&lt;br /&gt;
=== 공용 MCP 운영 ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에는 영구 지식이 없습니다. 정지하거나 다시 만들면 진행 중인 도구 호출이 끊기고 클라이언트가 재연결해야 하지만 Lesson이나 revision은 없어지지 않습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose stop mcp-http&lt;br /&gt;
docker compose up -d mcp-http&lt;br /&gt;
docker compose up -d --force-recreate mcp-http&lt;br /&gt;
docker compose ps mcp-http&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
일시적인 프로세스 오류에는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;restart&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 사용합니다. 토큰 해시, 공개 URL, bind·port, allowlist, 위키 자격 증명, 이미지를 바꿨다면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--force-recreate&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 사용합니다. 공개 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 복제본을 여러 개 띄우지 마세요. 현재 서비스에는 load balancer나 session 공유 설계가 없습니다. 읽기 요청은 재연결 뒤 다시 시도해도 됩니다. 시간이 초과된 변경 요청은 결과를 먼저 확인한 뒤 다시 보내세요.&lt;br /&gt;
&lt;br /&gt;
=== 공개 HTTPS ===&lt;br /&gt;
&lt;br /&gt;
Caddy는 Compose &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;domain&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 프로필에서 실행하며 공개 포트를 사용합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose ps caddy&lt;br /&gt;
docker compose logs --tail=200 caddy&lt;br /&gt;
docker compose up -d --force-recreate caddy&lt;br /&gt;
make verify-domain&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_PUBLIC_HOST&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;나 저장소의 Caddyfile을 바꾼 뒤 Caddy를 다시 만듭니다. 인증서 발급을 다시 시도하려고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;caddy_data&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 지우지 마세요. 먼저 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;s3wiki.yonsei.ac.kr&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;165.132.118.220&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 가리키는지, 다른 프로세스가 80/443을 쓰는지, 두 포트가 외부에서 서버까지 들어오는지 확인합니다. Caddy는 원래 Host 헤더를 보존하며 MCP는 이를 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_PUBLIC_URL=https://s3wiki.yonsei.ac.kr/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 대조합니다. 학교 밖 네트워크의 브라우저 확인도 별도로 해야 합니다.&lt;br /&gt;
&lt;br /&gt;
==== 공용 MCP 토큰 교체 ====&lt;br /&gt;
&lt;br /&gt;
이 파일럿은 토큰 해시 하나만 받으므로 교체 시 모든 쓰기 클라이언트의 기존 토큰이 한 번에 무효가 됩니다. 공개 조회는 계속됩니다. 변경이 잠시 끊긴다고 알리고 새 서비스 확인이 끝날 때까지 기존 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;client.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 보관하세요.&lt;br /&gt;
&lt;br /&gt;
# 원문을 출력하지 않고 새 토큰과 해시를 만듭니다. ```bash ./scripts/configure-mcp-token.sh --rotate ``` 스크립트는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 해시만 바꾸고, 원문 토큰과 URL은 mode &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;0600&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;~/.config/s3-research-memory/client.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 씁니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--rotate&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 없으면 기존 토큰과 해시가 맞는지 확인하고 그대로 둡니다.&lt;br /&gt;
# 공용 어댑터만 다시 만듭니다. ```bash docker compose up -d --force-recreate mcp-http docker compose ps mcp-http docker compose logs --tail=100 mcp-http ```&lt;br /&gt;
# 토큰 없이 초기화, 도구 목록, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;search_lessons(limit=1)&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 확인합니다. 이어서 신뢰하는 셸에서 새 비공개 파일을 불러오고 [[Help:S3 연구 메모리/설치와 검증|&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;setup.md&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;]]의 쓰기 확인을 실행합니다.&lt;br /&gt;
# 새 원문 토큰 또는 클라이언트 환경을 쓰기 클라이언트에 전달하고 재연결합니다. 공개 조회 클라이언트는 바꿀 것이 없습니다. DB·위키 비밀번호와 쓸 수 없는 토큰 해시가 든 서버 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 전달하지 마세요.&lt;br /&gt;
새 서비스를 확인하기 전에 문제가 생겼다면 이전 해시와 그에 맞는 비공개 토큰을 복구하고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 다시 만듭니다. 변경 인증을 끄는 방식으로 되돌리지 마세요.&lt;br /&gt;
&lt;br /&gt;
=== 애플리케이션 업데이트와 되돌리기 ===&lt;br /&gt;
&lt;br /&gt;
업데이트 전에 이름을 붙인 백업을 만들고 복원을 시험합니다. 새 이미지를 빌드하고 DB 마이그레이션과 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 완료한 뒤 복구 완료 항목을 확인합니다. 새 release가 DB를 마이그레이션했다면 이미지만 낮춰서는 안 됩니다. 이전 저장소 revision과 함께 업데이트 전 DB, 업로드, 설정을 한 세트로 복원해야 합니다.&lt;br /&gt;
&lt;br /&gt;
기본 제약은 MediaWiki [https://www.mediawiki.org/wiki/Manual:Restoring_a_wiki_from_backup 복원 안내]와 [https://www.mediawiki.org/wiki/Manual:Upgrading 업그레이드 안내]를 참고하세요.&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC/%EB%B0%B1%EC%97%85%EA%B3%BC_%EB%B3%B5%EC%9B%90&amp;diff=1731</id>
		<title>도움말:S3 연구 메모리/백업과 복원</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC/%EB%B0%B1%EC%97%85%EA%B3%BC_%EB%B3%B5%EC%9B%90&amp;diff=1731"/>
		<updated>2026-07-18T10:53:13Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 저장소 문서 동기화&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;!-- S3RM managed repository document; edit the Markdown source, not this page. --&amp;gt;&lt;br /&gt;
&#039;&#039;이 페이지는 저장소 문서에서 자동으로 동기화됩니다. 위키에서 직접 편집하지 마세요.&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
Semantic MediaWiki가 유일한 Lesson 저장소입니다. 복구 지점에는 같은 시점의 MariaDB 덤프, 업로드 파일, 생성된 위키 설정이 모두 있어야 합니다. 공용 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 상태를 저장하지 않는 전송·검증 어댑터입니다. Lesson 볼륨, 토큰 DB, 별도 기준 데이터가 없으므로 백업하지 않습니다. 이미지와 설정은 저장소와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 다시 만듭니다.&lt;br /&gt;
&lt;br /&gt;
Caddy는 [https://s3wiki.yonsei.ac.kr/]와 [https://s3wiki.yonsei.ac.kr/mcp]의 HTTPS 진입점이며 지식을 저장하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
복구 스크립트는 Bash 4 이상, GNU coreutils(&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;stat&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;sha256sum&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;), GNU tar, gzip이 있는 GNU/Linux Docker 호스트에서 지원합니다. 필요한 명령이 없으면 바로 실패합니다. macOS와 Windows Docker Desktop에서는 이 Compose 프로젝트를 소유한 Linux VM 또는 Linux 호스트에서 실행하세요. BSD 명령 옵션이 같다고 가정하지 마세요.&lt;br /&gt;
&lt;br /&gt;
=== 영구 데이터 ===&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Compose 볼륨 !! 내용 !! 복구 때 역할&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db_data&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || MediaWiki 페이지, 의미 데이터, 사용자, 변경 이력, 로그, 호환 필드 || 기준 데이터베이스&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db_binlogs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || MariaDB ROW binary log와 index || 기준 덤프 이후 시간점 복구(PITR). 기본 14일 보존&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki_images&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 업로드한 근거 파일과 MediaWiki 업로드 메타데이터 || DB 덤프와 같은 시점이어야 함&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki_config&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 생성된 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;LocalSettings.php&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 MediaWiki 비밀 키 || 위키 식별·설정 보존에 필요&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;caddy_data&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 발급된 TLS 인증서, 개인 키, Caddy 실행 상태 || 평소 보존하되 Lesson 기준 데이터는 아님&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;caddy_config&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || Caddy 실행 설정 || 저장소와 공개 hostname으로 다시 생성 가능&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;embedding_models&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || SHA-256으로 확인한 번들 GGUF || 다시 받을 수 있는 파생 캐시. Lesson 복구 세트가 아님&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
일반 백업 스크립트는 DB 덤프, 설정, 업로드와 함께 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db_binlogs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 캡처 시점 구간과 덤프에 내장된 replay 시작 file·position을 저장합니다. 이 항목이 Lesson 복구 세트입니다. 새 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db_binlogs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 볼륨은 one-shot &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db-binlog-init&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 MariaDB 계정만 쓸 수 있도록 소유권과 mode를 맞춘 뒤 DB가 시작합니다. 이 서비스가 실패하면 DB를 시작하지 않으며, 볼륨 권한을 넓혀 우회하지 않습니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;caddy_data&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;caddy_config&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 평소 재시작 때 남겨 두되, 버전이 있는 위키 백업에 섞지 않습니다. 이 볼륨이 없어지면 DNS와 외부 80/443이 정상일 때 Caddy가 인증서를 다시 받을 수 있습니다. 일반 운영에서는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;docker compose down --volumes&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 쓰지 마세요.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;embedding_models&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에는 Lesson, 검색 query나 벡터가 없습니다. 백업·복원 스크립트는 이 볼륨을 포함하지 않으며, 의미 검색을 다시 켤 때 고정된 원본과 SHA-256으로 재구성합니다. 평소에는 재다운로드와 서비스 중단을 피하도록 볼륨을 남겨 둡니다.&lt;br /&gt;
&lt;br /&gt;
저장소에는 다시 만들 수 있는 코드, 템플릿, 양식이 있습니다. 자격 증명이 든 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 추적하지 않으며 일반 백업 아카이브에도 넣지 않습니다. 서버 담당자가 복구용 사본을 따로 보관하되 Git이나 공개 아카이브에는 넣지 마세요.&lt;br /&gt;
&lt;br /&gt;
복구 세트의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;runtime-images.txt&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 Compose에 설정한 image reference와 실제 컨테이너 image ID를 함께 기록합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;repository-tracked.patch.gz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 현재 HEAD 기준의 &#039;&#039;&#039;추적된 파일&#039;&#039;&#039; 차이만 담습니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, untracked dump, upload, 자격 증명은 구조적으로 제외됩니다. untracked 파일이 있었다면 metadata에 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;present_not_archived&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;만 남고 내용과 이름은 보관하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
서버 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에는 MCP 쓰기 토큰의 해시만 있습니다. 전달용 원문 토큰이 든 mode &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;0600&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;~/.config/s3-research-memory/client.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;도 최소 한 곳에 보관하세요. 원문 토큰을 모두 잃었다면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;./scripts/configure-mcp-token.sh --rotate&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 새 토큰을 만들고 쓰기 클라이언트에 다시 전달합니다. 이 작업 중에도 공개 조회는 계속됩니다.&lt;br /&gt;
&lt;br /&gt;
=== 백업 ===&lt;br /&gt;
&lt;br /&gt;
저장소 밖의 절대 경로를 사용합니다. 가능하면 Docker 호스트와 다른 디스크를 선택하고, 서버 담당자만 읽고 쓸 수 있게 만듭니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
install -d -m 0700 /srv/s3rm-backups&lt;br /&gt;
docker compose stop mcp-http&lt;br /&gt;
./scripts/backup.sh /srv/s3rm-backups&lt;br /&gt;
docker compose up -d mcp-http&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
먼저 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 멈추면 에이전트 변경 경로가 닫힙니다. 백업 스크립트는 DB를 기다린 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-jobs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 실행 중인 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 멈춰 브라우저와 job 변경도 막습니다. MariaDB binary log를 교체해 file·position을 고정한 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--master-data=2&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 논리 덤프, binary-log 구간, 업로드·설정 볼륨을 묶습니다. 백업과 복구 스크립트는 같은 checkout 디렉터리에 nonblocking maintenance lock을 겁니다. 예약 백업, 수동 백업, 복구 중 둘 이상을 겹쳐 실행하면 나중 시작한 작업이 즉시 실패합니다. 다른 checkout으로 같은 운영 Compose project를 동시에 관리하지 마세요. 백업 종료 때는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 실행 중이던 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-jobs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 순서로 복원하고 job worker health를 확인한 뒤에만 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 다시 엽니다. worker가 준비되지 않으면 백업 파일 생성 여부와 관계없이 실패로 끝나며 MCP는 중지 상태를 유지합니다. 실패해도 exit trap이 스크립트가 관리한 서비스를 백업 전 상태로 돌립니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 명시적으로 멈췄으므로 성공하거나 원인을 확인한 실패 뒤에도 마지막 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;up&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 명령을 실행해야 합니다.&lt;br /&gt;
&lt;br /&gt;
모든 파일의 형식과 checksum을 확인한 뒤에만 시각이 붙은 최종 디렉터리를 공개합니다. 덤프나 근거 아카이브를 저장소에 넣지 않습니다. 끝에 출력되는 복구 디렉터리의 정확한 경로를 기록하세요.&lt;br /&gt;
&lt;br /&gt;
==== binary log를 처음 켜는 서버 ====&lt;br /&gt;
&lt;br /&gt;
이 설정을 배포하기 전부터 실행 중인 DB는 컨테이너를 다시 만들기 전까지 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;log_bin=OFF&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;일 수 있습니다. 이 상태에서 일반 백업은 중단되며, 최초 이전 전에만 다음 명시적 기준 백업을 사용합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
./scripts/backup.sh --legacy-base /srv/s3rm-backups&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--legacy-base&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;log_bin=OFF&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;인 DB에서만 실행됩니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;database.sql.gz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 두 볼륨 아카이브, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;metadata.txt&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;SHA256SUMS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 이루어진 복원 가능한 format 1 기준 백업이며 PITR 보조 파일은 &#039;&#039;&#039;하나도&#039;&#039;&#039; 생성하지 않습니다. 나중에 별도 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;database-binlogs.tar.gz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;나 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;AUXILIARY-SHA256SUMS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 더해 일반 백업처럼 만들지 마세요. 보조 파일이 일부만 있는 세트는 복원과 격리 검증이 거부합니다.&lt;br /&gt;
&lt;br /&gt;
체크섬을 확인하고 완성된 디렉터리를 원격 저장소에 복사한 뒤에만 DB를 재생성합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db_data&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 지우지 않고 새 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db_binlogs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 볼륨을 연결합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose stop mediawiki-jobs mcp-http bootstrap mediawiki&lt;br /&gt;
docker compose up -d --force-recreate --wait db&lt;br /&gt;
docker compose exec -T db sh -eu -c &#039;&lt;br /&gt;
  MYSQL_PWD=&amp;quot;$MARIADB_ROOT_PASSWORD&amp;quot; mariadb \&lt;br /&gt;
    --host=127.0.0.1 --user=root --batch \&lt;br /&gt;
    --execute=&amp;quot;SHOW VARIABLES LIKE &#039;\&#039;&#039;log_bin&#039;\&#039;&#039;; SHOW MASTER STATUS;&amp;quot;&lt;br /&gt;
&#039;&lt;br /&gt;
make domain-up&lt;br /&gt;
./scripts/backup.sh /srv/s3rm-backups&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
출력에 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;log_bin\tON&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;과 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mariadb-bin.NNNNNN&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 행이 모두 있어야 합니다. 마지막 일반 백업은 PITR 보조 체인까지 갖춘 새 기준입니다. 이후에는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--legacy-base&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 사용하지 마세요.&lt;br /&gt;
&lt;br /&gt;
복구 디렉터리는 다음 파일을 한 세트로 가집니다.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 파일 !! 내용&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;database.sql.gz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 설정된 MediaWiki DB 전체 논리 덤프&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;database-binlogs.tar.gz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 일반 백업만: 캡처 시점까지 보존된 MariaDB ROW binary log·index 보조 아카이브&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;pitr-manifest.txt&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 일반 백업만: 기준 dump 뒤 replay를 시작할 binary-log file·position·GTID, archive 목록·digest&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-images.tar.gz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 업로드·근거 볼륨의 numeric-owner 아카이브&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-config.tar.gz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 생성된 설정과 위키 키의 numeric-owner 아카이브&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;runtime-images.txt&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 일반 백업만: 설정 reference와 실제 실행 컨테이너 image ID&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;repository-tracked.patch.gz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 일반 백업만: HEAD에 적용할 추적 소스 binary patch. untracked·ignored 파일 제외&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;repository-tracked-manifest.txt&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 일반 백업만: 추적 파일별 SHA-256과 소스 상태&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;metadata.txt&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 형식 버전, DB 이름, 페이지·revision 수, 소스 revision, 이미지 참조, Compose 해시. 일반 백업은 PITR position도 포함&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;SHA256SUMS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 앞의 네 payload checksum&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;AUXILIARY-SHA256SUMS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 일반 백업만: PITR·image·tracked-source 보조 파일 checksum. 이 파일 digest는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;metadata.txt&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 통해 기본 manifest와 결합&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
기본 대상, 각 복구 디렉터리, 모든 파일의 권한은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;0700&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 또는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;0600&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;입니다. 기존 대상에 group/other 접근 비트가 하나라도 있으면 스크립트가 거부하며 권한을 임의로 바꾸지 않습니다. 백업할 때 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 실행 중이어야 합니다. 위키가 원래 정지 상태였다면 그대로 두고, 실행 중이었다면 API가 다시 healthy가 될 때까지 기다린 뒤 성공을 알립니다. 복원도 소스 디렉터리에 같은 권한 검사를 합니다.&lt;br /&gt;
&lt;br /&gt;
기본 로컬 대상에 저장하려면 경로를 생략합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose stop mcp-http&lt;br /&gt;
./scripts/backup.sh&lt;br /&gt;
docker compose up -d mcp-http&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
기본 로컬 경로만 장기 보관에 사용하지 마세요. 완료된 디렉터리 전체를 접근이 제한된 외부 저장소에 복사합니다. 공개 메모가 주 내용이더라도 백업에는 사용자 정보, 삭제·이전 revision, 생성된 비밀 키가 있으므로 공개하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
==== 백업 확인 ====&lt;br /&gt;
&lt;br /&gt;
스크립트가 실패하면 그 백업은 사용할 수 없습니다. 성공한 뒤에도 다음을 확인합니다.&lt;br /&gt;
&lt;br /&gt;
# DB 덤프, 설정·이미지 아카이브, 메타데이터, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;SHA256SUMS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 있는지 확인합니다. 일반 백업은 binary-log·PITR 보조 파일 전체와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;AUXILIARY-SHA256SUMS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;도 있어야 합니다. 최초 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--legacy-base&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 보조 파일이 하나도 없어야 합니다.&lt;br /&gt;
# 스크립트가 출력한 checksum 명령을 실행합니다. ```bash (cd /ABS/PATH/TO/BACKUP-DIRECTORY &amp;amp;amp;&amp;amp;amp; \ sha256sum --check --strict SHA256SUMS) # 일반 PITR 백업에서만 추가 확인 (cd /ABS/PATH/TO/BACKUP-DIRECTORY &amp;amp;amp;&amp;amp;amp; \ sha256sum --check --strict AUXILIARY-SHA256SUMS) ```&lt;br /&gt;
# 빈 파일이 없는지, 시각과 소스 버전이 맞는지 확인합니다.&lt;br /&gt;
# 디렉터리 전체를 한 단위로 복사합니다. 서로 다른 시각의 파일을 섞지 마세요.&lt;br /&gt;
# 아래의 격리 복원 시험을 정기적으로 실행합니다. checksum은 전송 무결성만 확인하며 실제 복원 가능성을 보장하지 않습니다.&lt;br /&gt;
MediaWiki [https://www.mediawiki.org/wiki/Manual:Backing_up_a_wiki 백업 안내]도 DB와 파일을 함께 보관하도록 안내합니다. XML 페이지 내보내기는 보조 이동본으로 쓸 수 있지만 사용자 계정, 모든 로그, 업로드 메타데이터, 사이트 전체 상태를 담지 않으므로 이 복구 세트를 대신할 수 없습니다.&lt;br /&gt;
&lt;br /&gt;
==== MariaDB PITR 경계 ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;database.sql.gz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 완전 기준 백업입니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;pitr-manifest.txt&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;binlog_file&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;·&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;binlog_position&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 그 덤프가 이미 포함한 마지막 경계이자 replay 시작 좌표입니다. 이 좌표 이전 event를 다시 적용하면 중복 변경이 생깁니다. 이후 시각으로 이동하려면 기준 좌표 &#039;&#039;&#039;이후&#039;&#039;&#039;의 연속된 binary log가 전부 있어야 합니다. 백업 당시 archive에는 아직 생기지 않은 미래 event가 들어 있을 수 없으므로, 더 늦게 만든 복구 세트의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;database-binlogs.tar.gz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;나 별도 연속 보관본이 필요합니다. 로컬 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db_binlogs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 기본 14일 후 자동 만료되므로 원격 백업과 복원 시험이 이 기간 안에 성공해야 합니다.&lt;br /&gt;
&lt;br /&gt;
PITR은 운영 DB에 바로 재생하지 마세요. 격리 복원을 먼저 완료한 뒤, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mariadb-binlog --start-position&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에는 manifest의 좌표 다음 event부터 적용되도록 경계를 확인하고, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--stop-datetime&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 또는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--stop-position&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 명시하여 SQL을 만듭니다. 재생 전에 시작·종료 file, position, target UTC, 예상 event 수를 작업 기록에 남기고, Lesson·revision 개수와 대표 페이지를 확인합니다. binary log가 하나라도 빠졌거나 기준 position보다 이전 백업과 섞였다면 PITR를 계속하지 말고 다음 완전 백업으로 복원합니다.&lt;br /&gt;
&lt;br /&gt;
==== 권장 주기 ====&lt;br /&gt;
&lt;br /&gt;
파일럿에서는 다음 주기로 시작합니다.&lt;br /&gt;
&lt;br /&gt;
* 매일 백업, 14일 보관&lt;br /&gt;
* 매주 백업, 8주 보관&lt;br /&gt;
* 이미지·의존성 업데이트나 자격 증명 변경 전 백업&lt;br /&gt;
* 분기마다 격리 복원 시험&lt;br /&gt;
실제 복구 시점 목표는 운영 상황에 맞게 정합니다. 스케줄러 종료 상태와 디스크 여유 공간을 확인하세요. 오류 알림이 없는 예약 명령만으로는 충분하지 않습니다. 새 복원을 확인하기 전에 가장 최근의 정상 백업을 지우지 마세요.&lt;br /&gt;
&lt;br /&gt;
==== Cloudflare R2 자동 백업 ====&lt;br /&gt;
&lt;br /&gt;
이 서버는 다음 비공개 R2 bucket의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;restic&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; prefix를 원격 백업 저장소로 사용합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
https://96f01030a087d07d906bff299d260155.r2.cloudflarestorage.com/s3rm-backups&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
백업 데이터는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;restic&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 서버에서 먼저 암호화한 뒤 전송합니다. R2 Access Key, Secret Key와 restic 암호는 Git이나 프로젝트 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 넣지 않습니다.&lt;br /&gt;
&lt;br /&gt;
서버에 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;restic&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 설치한 뒤 root 권한으로 설정 파일을 준비합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
sudo install -d -m 0700 /etc/s3-research-memory /srv/s3rm-backups&lt;br /&gt;
sudo install -m 0600 config/remote-backup.env.example \&lt;br /&gt;
  /etc/s3-research-memory/remote-backup.env&lt;br /&gt;
sudo sh -c &#039;umask 077; openssl rand -base64 48 &amp;gt; /etc/s3-research-memory/restic-password&#039;&lt;br /&gt;
sudoedit /etc/s3-research-memory/remote-backup.env&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
R2에서 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;s3rm-backups&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; bucket에만 목록 조회·읽기·쓰기·삭제가 가능한 S3 API 자격 증명을 만들고 설정 파일의 두 placeholder를 바꿉니다. 삭제 권한은 오래된 restic snapshot과 pack을 보존 정책에 맞게 정리할 때 필요합니다. restic 암호 파일은 서버 장애 때도 찾을 수 있도록 서버 밖의 비밀번호 관리자에 따로 보관합니다. 이 암호를 잃으면 R2의 백업을 복구할 수 없습니다.&lt;br /&gt;
&lt;br /&gt;
현재 live restic repository의 자격 증명은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;forget&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;·&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;prune&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 위해 삭제 권한도 가집니다. 따라서 &#039;&#039;&#039;R2 자격 증명 탈취와 오작동으로부터의 불변 백업은 현재 저장소 하나만으로 달성되지 않습니다.&#039;&#039;&#039; 이 제한은 복원 시험 기록에 항상 남깁니다.&lt;br /&gt;
&lt;br /&gt;
R2의 lock·retention을 live restic prefix에 바로 적용하면 임시 lock 제거와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;forget&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;·&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;prune&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 실패할 수 있습니다. 삭제 방어는 다음과 같이 &#039;&#039;&#039;별도 보호 복사본&#039;&#039;&#039;으로 구성합니다.&lt;br /&gt;
&lt;br /&gt;
# live &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;restic&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; prefix는 현재 보존 정책으로 검증·정리합니다.&lt;br /&gt;
# 검증한 복구 세트 또는 restic repository 세대를 별도 bucket·account의 보호 prefix로 복사합니다. 복사 writer와 retention 변경 권한을 분리합니다.&lt;br /&gt;
# 보호 복사본은 일간 운영 키로 삭제할 수 없게 만들고, 예외 해제 권한은 다른 계정에 보관합니다.&lt;br /&gt;
# Cloudflare 계정 MFA, bucket 범위 API token, 삭제·retention 변경 알림, 분기별 보호 복사본 복원 시험을 함께 적용합니다.&lt;br /&gt;
별도 bucket/retention/API token은 Cloudflare 외부 설정이므로 이 저장소의 Compose나 systemd 파일만으로는 활성화할 수 없습니다. 이 설정이 완료되기 전에는 live R2를 유일한 삭제 방어 본으로 간주하지 마세요.&lt;br /&gt;
&lt;br /&gt;
원격 저장소는 한 번만 초기화합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
sudo bash -c &#039;&lt;br /&gt;
  set -a&lt;br /&gt;
  source /etc/s3-research-memory/remote-backup.env&lt;br /&gt;
  set +a&lt;br /&gt;
  exec /home/mrcha033/Projects/s3wiki/scripts/init-remote-backup.sh&lt;br /&gt;
&#039;&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
systemd 단위를 설치하고 첫 백업을 수동으로 확인합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
sudo install -m 0644 deploy/systemd/s3rm-remote-backup.service \&lt;br /&gt;
  /etc/systemd/system/s3rm-remote-backup.service&lt;br /&gt;
sudo install -m 0644 deploy/systemd/s3rm-remote-backup.timer \&lt;br /&gt;
  /etc/systemd/system/s3rm-remote-backup.timer&lt;br /&gt;
sudo systemctl daemon-reload&lt;br /&gt;
sudo systemctl start s3rm-remote-backup.service&lt;br /&gt;
sudo systemctl status s3rm-remote-backup.service&lt;br /&gt;
sudo systemctl enable --now s3rm-remote-backup.timer&lt;br /&gt;
systemctl list-timers s3rm-remote-backup.timer&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
작업은 매일 서울 시각 03:15 이후 45분 안에 실행합니다. 성공한 원격 snapshot은 일간 14개와 주간 8개를 보존하고, 원격 pack 정리는 일요일에 실행합니다. 전송이나 검증이 실패하면 로컬 복구 세트를 지우지 않습니다. 성공하면 R2에 이미 암호화된 snapshot을 확인한 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/srv/s3rm-backups&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 해당 임시 세트를 정리합니다. snapshot 경로에는 매번 다른 시각이 들어가므로 restic 보존 그룹은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;host,tags&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로만 묶습니다. 경로까지 그룹 기준에 넣으면 각 snapshot이 항상 자신만의 그룹이 되어 14일·8주 정책이 아무 것도 만료시키지 못합니다.&lt;br /&gt;
&lt;br /&gt;
상태와 최근 로그를 정기적으로 확인합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
systemctl status s3rm-remote-backup.timer&lt;br /&gt;
sudo journalctl -u s3rm-remote-backup.service --since &#039;2 days ago&#039;&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
원격 snapshot을 복구할 때는 빈 root 전용 디렉터리에 먼저 내려받고 기존 복원 스크립트로 넘깁니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
sudo install -d -m 0700 /srv/s3rm-remote-restore&lt;br /&gt;
sudo bash -c &#039;&lt;br /&gt;
  set -a&lt;br /&gt;
  source /etc/s3-research-memory/remote-backup.env&lt;br /&gt;
  set +a&lt;br /&gt;
  restic snapshots --tag s3rm-recovery-set&lt;br /&gt;
  restic restore latest --tag s3rm-recovery-set --target /srv/s3rm-remote-restore&lt;br /&gt;
&#039;&lt;br /&gt;
sudo find /srv/s3rm-remote-restore -maxdepth 2 -type f -name SHA256SUMS -print&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
출력된 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;SHA256SUMS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 상위 디렉터리가 한 개이고 원하는 시각의 복구 세트인지 확인한 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;scripts/restore.sh &amp;amp;lt;그 디렉터리&amp;amp;gt; --yes&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 사용합니다. 운영 서버를 덮어쓰기 전에 격리 복원 시험을 먼저 수행합니다.&lt;br /&gt;
&lt;br /&gt;
=== Lesson 내용 정기 감사 ===&lt;br /&gt;
&lt;br /&gt;
읽기 전용 감사 작업은 매주 운영 위키의 안정된 Lesson을 중앙 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 읽습니다. 실행 전 30분 안에 바뀐 Lesson은 다음 감사로 미룹니다. 별도 Lesson 저장소나 기준 파일을 만들지 않습니다. 쓰기 토큰도 사용하지 않습니다. 외부 근거 URL을 열거나 Lesson을 자동으로 수정하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
감사 작업은 다음 항목을 확인합니다.&lt;br /&gt;
&lt;br /&gt;
* 구조화된 근거가 없는 Lesson&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;high&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; confidence인데 최신 append-only 검증 기록이 observation, interpretation, reusable_lesson 전부를 strong &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supports&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 뒷받침하지 않는 Lesson&lt;br /&gt;
* 확인 범위가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;not_recorded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;인 근거&lt;br /&gt;
* 같거나 거꾸로 된 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;verified_at&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 append 순서의 현재 판정을 정할 수 없는 Lesson&lt;br /&gt;
* 서로 같은 관찰·해석 또는 재사용 내용·적용 조건&lt;br /&gt;
* 대상이 없는 relation과 중복 relation&lt;br /&gt;
* 365일 이상 바뀌지 않은 검토 후보&lt;br /&gt;
* 비밀 키나 토큰 형식으로 보이는 문자열&lt;br /&gt;
발견 결과에는 Lesson ID, revision ID, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;codes&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 배열과 상한이 있는 evidence·verification ID만 남깁니다. Lesson 본문, 인용과 relation note는 journal에 남기지 않습니다. 같은 Lesson의 발견 코드는 한 줄로 묶습니다. 오래된 Lesson이라는 이유만으로 틀렸다고 판정하지 않으며 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;stale_review_candidate&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로만 표시합니다. 감사는 저장된 구조, digest, append 순서와 검증 수령 기록의 일관성을 확인합니다. 외부 근거 URL을 열지 않으므로 논문·코드·데이터의 사실 진위를 자동으로 판정하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
systemd 단위를 설치하고 첫 감사를 수동으로 실행합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
sudo install -D -m 0755 scripts/audit-lessons.py \&lt;br /&gt;
  /usr/local/libexec/s3rm-content-audit&lt;br /&gt;
sudo install -m 0644 deploy/systemd/s3rm-content-audit.service \&lt;br /&gt;
  /etc/systemd/system/s3rm-content-audit.service&lt;br /&gt;
sudo install -m 0644 deploy/systemd/s3rm-content-audit.timer \&lt;br /&gt;
  /etc/systemd/system/s3rm-content-audit.timer&lt;br /&gt;
sudo systemctl daemon-reload&lt;br /&gt;
sudo systemctl start s3rm-content-audit.service&lt;br /&gt;
sudo systemctl status s3rm-content-audit.service&lt;br /&gt;
sudo journalctl -u s3rm-content-audit.service --since today&lt;br /&gt;
sudo systemctl enable --now s3rm-content-audit.timer&lt;br /&gt;
systemctl list-timers s3rm-content-audit.timer&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
작업은 일요일 서울 시각 04:30 이후 30분 안에 실행합니다. 놓친 실행은 서버가 다시 켜졌을 때 실행합니다. 배포 단위와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;make audit-lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 현재 서버의 scan limit과 같은 500개 Lesson을 읽습니다. 스크립트 자체의 기본값은 200개이고 설정 가능한 절대 상한은 1000개입니다. Lesson당 근거·relation은 각각 최대 200개를 읽습니다. 서버의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;scan_limit&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 목록이 잘리거나 감사 중 revision과 Lesson 목록이 바뀌면 완료로 기록하지 않습니다. 연결 오류나 동시 변경으로 끝나면 5분 간격으로 최대 세 번 시도합니다. 발견 사항을 뜻하는 종료 코드 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;2&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 다시 시도하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
종료 코드는 다음과 같습니다.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 코드 !! 뜻 !! 다음 작업&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;0&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 감사를 완료했고 발견 사항이 없음 || 다음 예약 실행을 기다립니다.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 연결, 출력 한도 또는 동시 변경으로 감사를 완료하지 못함 || journal의 마지막 오류를 확인하고 다시 실행합니다.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;2&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 감사를 완료했고 확인할 항목이 있음 || 발견된 Lesson을 읽고 근거를 확인합니다.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;systemd&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 서비스는 임시 계정으로 실행합니다. 홈 디렉터리, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, Docker 소켓과 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/etc/s3-research-memory&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 읽을 수 없습니다. 발견 사항이 있으면 실패 상태로 표시합니다. 이 상태는 Lesson을 바꾸지 않으며 다음 예약 실행도 막지 않습니다. 문제를 고칠 때는 근거를 먼저 붙인 뒤 같은 Lesson ID에 현재 revision을 기준으로 새 revision을 추가합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supersedes&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;나 근거 note를 기존 주장 수정 수단으로 쓰지 않습니다.&lt;br /&gt;
&lt;br /&gt;
예약과 무관하게 현재 상태를 확인할 때는 다음 명령을 사용합니다. 발견 사항이 있어도 수동 명령은 감사 완료 뒤 코드 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;0&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 반환합니다. 자동 점검과 같은 종료 코드가 필요하면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--fail-on-findings&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 추가합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
make audit-lessons&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 복원 전 주의 ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;scripts/restore.sh&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 현재 DB, 업로드, 생성된 위키 설정을 교체합니다. 명시적인 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--yes&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 필요하며, 공지한 점검 시간에만 실행합니다. 병합이나 페이지 가져오기 도구가 아닙니다.&lt;br /&gt;
&lt;br /&gt;
복원 전에 다음을 확인합니다.&lt;br /&gt;
&lt;br /&gt;
* 정확한 백업 디렉터리를 정하고 모든 checksum을 확인합니다.&lt;br /&gt;
* 백업의 MediaWiki·애플리케이션 버전이 현재 checkout과 호환되는지 확인합니다. 새 DB를 더 오래된 코드에 복원할 수 없습니다.&lt;br /&gt;
* 점검 시간을 알리고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 멈춘 뒤 브라우저 편집도 중단합니다.&lt;br /&gt;
* 현재 상태를 읽을 수 있으면 복원 직전 백업을 하나 더 만듭니다.&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 복원할 인스턴스와 맞는 자격 증명이 있는지 확인합니다.&lt;br /&gt;
* 복원을 결정한 사람과 이유를 기록합니다.&lt;br /&gt;
=== 현재 서버 복원 ===&lt;br /&gt;
&lt;br /&gt;
백업을 만든 것과 같은 저장소 버전에서 실행합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose --profile domain stop caddy mcp-http&lt;br /&gt;
./scripts/restore.sh /ABS/PATH/TO/BACKUP-DIRECTORY --yes&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
스크립트는 checksum manifest, 백업 형식 버전, 압축 stream, 모든 아카이브 member의 경로와 종류를 먼저 확인합니다. 보조 파일이 있는 형식은 auxiliary manifest digest와 binary-log·source artifact도 모두 확인합니다. 백업의 DB 이름과 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;MARIADB_DATABASE&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;도 같아야 합니다. 다르면 덤프를 편집하지 말고 복구용 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 맞춥니다. 이 검증과 이미지 빌드 사전 확인이 모두 끝난 뒤에만 애플리케이션을 멈추고 기준 DB·설정·업로드를 교체합니다.&lt;br /&gt;
&lt;br /&gt;
기준 dump import는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;sql_log_bin=0&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 세션에서 실행합니다. 이어서 DB를 멈추고 기존 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db_binlogs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 &#039;&#039;&#039;더 새로운 운영 timeline을 비운 뒤&#039;&#039;&#039; 새 local timeline으로 시작합니다. 따라서 과거 DB에 미래 binlog가 이어지는 일은 없지만, backup의 archived binlog를 자동 replay하지도 않습니다. PITR이 필요하면 운영 복원 전에 별도 격리 환경에서 목표 UTC·종료 position을 정해 검증한 절차로만 적용합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 시작 때 MediaWiki·확장 기능 마이그레이션을 적용하고, 이어서 반복 실행이 가능한 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 적용합니다. 그 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-job-state-init&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 강제로 다시 실행해 새 복구 볼륨을 UID/GID 33 쓰기 경로로 만든 다음 bounded &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-jobs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 시작해 worker health까지 확인합니다. 복구 중 MediaWiki host bind는 임시로 loopback에 강제하고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;caddy&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 성공 후에도 중지한 상태로 둡니다. 따라서 마이그레이션· bootstrap 중 공개 브라우저나 MCP 쓰기가 재개되지 않습니다. 교체 경고가 나온 뒤에는 중단하지 마세요. 오류가 나오면 클라이언트를 멈춘 상태로 로그와 소스 백업을 보존합니다. 실패한 상태 위에 부분 SQL이나 일부 볼륨을 임의로 덮어쓰지 마세요.&lt;br /&gt;
&lt;br /&gt;
완료 뒤 상태를 확인합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose ps -a&lt;br /&gt;
docker compose logs mediawiki bootstrap mediawiki-jobs&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
아래 MediaWiki 복구 확인을 마칠 때까지 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 멈춰 둡니다. 확인 뒤 현재 설정으로 공용 어댑터를 다시 만들고, 공개 조회와 토큰 기반 변경을 모두 시험합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose --profile domain up -d --force-recreate mediawiki mcp-http caddy&lt;br /&gt;
docker compose ps mediawiki-jobs mcp-http&lt;br /&gt;
docker compose logs --tail=100 mcp-http&lt;br /&gt;
./scripts/verify-public-domain.sh s3wiki.yonsei.ac.kr&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 격리 복원 시험 ===&lt;br /&gt;
&lt;br /&gt;
실제 서버를 덮어쓰지 말고 별도 Compose 프로젝트와 사용하지 않는 포트에서 시험합니다. 별도 checkout을 사용하고, 복구 디렉터리는 이동하지 말고 복사합니다. Compose 프로젝트와 볼륨 이름을 고유하게 지정하세요. 최상위 이름은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;COMPOSE_PROJECT_NAME&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 바꿀 수 있으며 두 스크립트가 이 값을 사용합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;COMPOSE&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 환경 변수에는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;docker compose --ansi never&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;처럼 고정 접두사를 넣을 수 있습니다. 설치, 백업, 복원에 같은 값을 사용하세요.&lt;br /&gt;
&lt;br /&gt;
권장 경로는 전용 검증 스크립트입니다. 운영 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 아닌 mode &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;0600&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 일회용 env에 사용하지 않는 loopback 포트, 일회용 DB·wiki 비밀번호, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;MW_SERVER_URL=http://127.0.0.1:18080&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;과 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_WIKI_BIND_ADDRESS=127.0.0.1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_HTTP_PORT=18080&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 넣습니다. 복원용 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_WIKI_BOT_PASSWORD&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;도 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;openssl rand -hex 16&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 따로 만들고, 운영 BotPassword와 primary 위키 비밀번호를 재사용하지 않습니다. 검증 스크립트는 DB를 가져온 직후 격리 project에서만 복원된 관리자 비밀번호를 일회용 env의 관리자 secret으로 바꿉니다. secret은 명령행 인자나 로그에 넣지 않으며, 격리 환경을 지울 때 변경된 관리자 해시도 함께 없어집니다. 운영 project에는 이 비밀번호 재설정 옵션을 사용할 수 없습니다. 실제 Compose project 이름과 모든 volume·network 이름도 함께 검사하므로 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;COMPOSE&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 접두사는 검증 스크립트가 만든 고정 명령만 허용합니다. 따라서 다른 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--project-name&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, Compose override, bind mount, 외부 volume 또는 별칭을 덧붙여 이 경계를 우회할 수 없습니다. bootstrap의 관리자·MCP 기본 계정·BotPassword도 secret 파일에서 직접 읽으며 평문을 자식 프로세스 환경이나 명령행 인자로 전달하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
chmod 0600 /srv/s3rm-restore-test.env&lt;br /&gt;
./scripts/verify-restore-isolated.sh /ABS/PATH/TO/BACKUP-DIRECTORY \&lt;br /&gt;
  --env-file /srv/s3rm-restore-test.env \&lt;br /&gt;
  --project-name s3rm-restore-2026q3&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
스크립트는 project 접두사, 새 project에 기존 컨테이너·볼륨·네트워크가 없는지와 resolved Compose가 사용할 실제 volume·network 이름도 모두 비어 있는지, loopback 주소, 기본 checksum과, 있는 경우 PITR 보조 checksum 체인을 &#039;&#039;&#039;변경 전&#039;&#039;&#039; 확인합니다. 격리 project 이름으로 전용 MediaWiki image tag를 만들어 운영 image tag를 동시에 다시 빌드하지 않습니다. 복원 후 Lesson 개수는 정확히 일치해야 하고, revision은 bootstrap이 관리 페이지 변경을 추가할 수 있으므로 백업 기록 &#039;&#039;&#039;이상&#039;&#039;&#039;이어야 합니다. MediaWiki API, bootstrap, bounded &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-jobs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; health도 확인합니다. 성공하면 격리 볼륨을 지우고, 실패하면 진단을 위해 보존합니다. 성공 시 project 전용 MediaWiki image tag도 함께 지웁니다. 자동 정리가 실패하면 스크립트가 실패 상태와 정확한 수동 정리 명령을 출력합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--keep&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 사용하면 성공한 환경도 보존합니다. 이 검증은 스키마·개수·시작 경계를 자동 확인하지만, 아래의 사람이 열어 보는 대표 Lesson·업로드·검색 확인을 대신하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
Docker가 설치된 일회용 VM에서는 다음 순서가 안전합니다.&lt;br /&gt;
&lt;br /&gt;
# 백업과 정확히 같은 저장소 revision, 복구 디렉터리, 복구용 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 VM에 복사합니다.&lt;br /&gt;
# &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;COMPOSE_PROFILES=&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 실제 hostname과 80/443을 사용하지 않게 합니다. 겹치지 않는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_HTTP_PORT&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 맞는 로컬 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;MW_SERVER_URL&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 설정합니다. 별도 loopback &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_HTTP_PORT&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;·&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_PUBLIC_URL&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;과 일회용 MCP 토큰도 사용합니다. 실제 MCP URL이나 토큰을 복원 시험에 쓰지 마세요.&lt;br /&gt;
# VM에서 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;scripts/restore.sh &amp;amp;lt;backup-directory&amp;amp;gt; --yes&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 실행합니다.&lt;br /&gt;
# 아래 복구 확인을 모두 수행합니다.&lt;br /&gt;
# 결과를 기록한 뒤 VM을 지웁니다. 원본 복구 세트는 그대로 둡니다.&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;docker compose config&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;docker volume ls&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 볼륨 이름이 다른지 보기 전에는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;COMPOSE_PROJECT_NAME&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;만 바꿨다고 격리된 것으로 보지 마세요.&lt;br /&gt;
&lt;br /&gt;
=== 복구 완료 확인 ===&lt;br /&gt;
&lt;br /&gt;
다음 항목을 모두 통과해야 복원이 끝납니다.&lt;br /&gt;
&lt;br /&gt;
# &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-jobs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 healthy이고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-install&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;과 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 코드 0으로 종료되었습니다.&lt;br /&gt;
# &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Special:Version&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 예상한 MediaWiki, Semantic MediaWiki, Page Forms, S3 Research Memory 버전이 표시됩니다.&lt;br /&gt;
# Lesson 페이지 수와 최근 revision 수가 백업 기록과 맞습니다.&lt;br /&gt;
# 복원된 Lesson의 필드, 근거, append-only 검증 기록, 관계, 출처, 호환 필드, 시간이 정상 표시됩니다.&lt;br /&gt;
# &#039;&#039;&#039;역사 보기&#039;&#039;&#039;에서 이전 revision 하나 이상이 열립니다.&lt;br /&gt;
# 업로드한 근거가 열리고, 외부 checksum을 기록했다면 값이 맞습니다.&lt;br /&gt;
# 전문 검색이 알려진 제목과 본문 단어를 찾습니다.&lt;br /&gt;
# 격리한 복원 시험 환경에서만 일반 브라우저 계정으로 Lesson 양식을 열고 복구 확인용 메모를 저장합니다. 바로 검색되는지 본 뒤 격리 환경과 함께 폐기합니다.&lt;br /&gt;
# 다시 만든 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 healthy입니다. 토큰 없이 초기화·도구 목록· &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;search_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 실행하면 짧은 인용 결과가 나옵니다. 쓰기 토큰을 넣은 클라이언트는 같은 격리 환경에 확인용 메모를 만들 수 있습니다. 운영 위키에는 확인용 데이터를 만들지 않습니다.&lt;br /&gt;
# 따로 설정한 두 번째 클라이언트가 같은 Lesson을 조회합니다. 두 클라이언트가 로컬 데이터가 아닌 복원된 위키 하나를 보고 있어야 합니다.&lt;br /&gt;
# MCP 계정은 여전히 삭제, 이동, 보호, 관리 기능을 사용할 수 없습니다.&lt;br /&gt;
복구 디렉터리, 소요 시간, 애플리케이션 revision, 결과, 실행자, 필요했던 조치를 기록합니다. 하나라도 실패하면 점검 상태를 유지합니다.&lt;br /&gt;
&lt;br /&gt;
=== 평소 상태 확인 ===&lt;br /&gt;
&lt;br /&gt;
상태와 제한된 길이의 로그를 확인합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose ps -a&lt;br /&gt;
docker compose logs --tail=200 db mediawiki-install mediawiki bootstrap \&lt;br /&gt;
  mediawiki-jobs mcp-http caddy&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
로컬 프로세스 경로와 두 공개 경로를 차례로 확인합니다. 로컬 확인만으로 다른 PC의 HTTPS 접속을 대신할 수 없습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
curl --fail --silent --show-error \&lt;br /&gt;
  &#039;http://localhost:8080/api.php?action=query&amp;amp;meta=siteinfo&amp;amp;format=json&#039; \&lt;br /&gt;
  &amp;gt;/dev/null&lt;br /&gt;
curl --fail --silent --show-error \&lt;br /&gt;
  &#039;http://127.0.0.1:8765/healthz&#039; \&lt;br /&gt;
  | jq --exit-status \&lt;br /&gt;
      &#039;.canonical_store == &amp;quot;ok&amp;quot; and&lt;br /&gt;
       .job_runner == &amp;quot;ok&amp;quot; and&lt;br /&gt;
       .write_admission.active &amp;gt;= 0 and .write_admission.waiting &amp;gt;= 0&#039; \&lt;br /&gt;
  &amp;gt;/dev/null&lt;br /&gt;
curl --fail --silent --show-error \&lt;br /&gt;
  &#039;https://s3wiki.yonsei.ac.kr/api.php?action=query&amp;amp;meta=siteinfo&amp;amp;format=json&#039; \&lt;br /&gt;
  &amp;gt;/dev/null&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
[[Help:S3 연구 메모리/설치와 검증#클라이언트를 연결하기 전 공용 MCP 확인|&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;setup.md&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;]]의 토큰 없는 MCP 초기화도 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;https://s3wiki.yonsei.ac.kr/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 실행합니다. 단순 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;GET /mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 streaming 연결을 계속 열어 둘 수 있으므로 상태 확인이 아닙니다.&lt;br /&gt;
&lt;br /&gt;
같은 서버에서 위 확인을 한 번에 실행하려면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;make verify-domain&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 사용합니다. 이 결과만으로 학교 밖 인바운드 경로까지 확인되지는 않습니다.&lt;br /&gt;
&lt;br /&gt;
Compose 운영의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/healthz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 canonical store와 bounded job runner가 모두 준비돼야 200을 반환합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;job_runner=unavailable&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이면 공유 상태 파일의 generation·최근 성공 시각·권한을 먼저 확인합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;write_admission&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에는 비밀값 없이 현재 active·waiting·principal 수와 동시 실행·queue 상한이 나옵니다. 기본은 전체 동시 쓰기 8개, queue 64개·2초, principal별 지속 600회/분·burst 120입니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;open&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드에서는 client IP가 principal이므로 인증 모드를 바꾸지 않아도 사용자별 admission이 적용됩니다. 지표가 계속 상한에 붙거나 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;server_busy&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 반복되면 DB latency와 실패 요청을 먼저 확인하고, 변경한 상한은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 다시 만들어 적용합니다. 이 queue는 재시작 뒤 작업을 복구하는 durable queue가 아닙니다. 전달된 IP는 Caddy와 MCP의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_PROXY_SECRET&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 맞을 때만 신뢰합니다. 이 값을 교체할 때는 둘을 같은 Compose 재생성에서 함께 전환합니다.&lt;br /&gt;
&lt;br /&gt;
MediaWiki job queue는 전용 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-jobs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 서비스가 기본 25건·20초, 1 process로 처리합니다. 상태 파일은 비밀 값이 없고 현재 queue 깊이, 마지막 성공 시각, 연속 실패 수, batch/idle 상한과 현재 runner generation을 담습니다. generation은 컨테이너 PID 1의 시작 시각에서 만들므로 이전 컨테이너나 복구 전 상태 파일은 새 runner health로 인정되지 않습니다. 상태 볼륨은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-job-state-init&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 UID/GID 33으로 준비하고 MCP에는 읽기 전용으로만 연결합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose ps mediawiki-jobs&lt;br /&gt;
docker compose ps -a mediawiki-job-state-init&lt;br /&gt;
docker compose exec mediawiki-jobs cat /run/s3rm-jobs/status.env&lt;br /&gt;
docker compose exec mediawiki-jobs /usr/local/bin/s3-mediawiki-jobs-health&lt;br /&gt;
docker compose exec mediawiki \&lt;br /&gt;
  php maintenance/run.php showJobs --group&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
runner가 실패하면 15초에서 시작해 최대 5분까지 지수 backoff합니다. 마지막 성공이 기본 120초보다 오래되면 health가 실패합니다. queue를 빠르게 비우기 위해 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--nothrottle&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이나 다중 process를 임의로 사용하지 마세요. 먼저 실패 job 종류와 DB·디스크 상태를 확인하고, 필요한 처리량만 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 조정합니다.&lt;br /&gt;
&lt;br /&gt;
Compose는 각 주요 서비스의 json-file 로그를 파일당 10MB, 최대 5개로 회전하고, PID·메모리·CPU 상한을 둡니다. 평소 사용량과 OOM·재시작 여부를 함께 확인합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker stats --no-stream&lt;br /&gt;
docker inspect --format \&lt;br /&gt;
  &#039;{{.Name}} oom={{.State.OOMKilled}} restarts={{.RestartCount}}&#039; \&lt;br /&gt;
  $(docker compose ps -q)&lt;br /&gt;
docker system df&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
상한 도달은 health check 성공만으로 보이지 않을 수 있습니다. OOM이나 PID 부족을 확인한 뒤 queue·동시성·실패 loop 원인을 먼저 고치고, 측정한 정상 peak와 호스트 여유를 근거로 상한을 조정합니다. 로그 회전 파일은 장기 감사 자료가 아니므로 필요한 운영 event는 별도 제한된 저장소에 보관합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Special:RecentChanges&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 계정 그룹, 로그인 실패, MCP 쓰기 인증 실패와 403/421, 디스크 여유 공간, 백업 시각, 복원 시험 상태를 확인합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 관리하는 스키마·양식 페이지가 반복해서 달라지면 원인을 찾으세요. 일반 Lesson revision은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 바꾸지 않습니다.&lt;br /&gt;
&lt;br /&gt;
=== 공용 MCP 운영 ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에는 영구 지식이 없습니다. 정지하거나 다시 만들면 진행 중인 도구 호출이 끊기고 클라이언트가 재연결해야 하지만 Lesson이나 revision은 없어지지 않습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose stop mcp-http&lt;br /&gt;
docker compose up -d mcp-http&lt;br /&gt;
docker compose up -d --force-recreate mcp-http&lt;br /&gt;
docker compose ps mcp-http&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
일시적인 프로세스 오류에는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;restart&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 사용합니다. 토큰 해시, 공개 URL, bind·port, allowlist, 위키 자격 증명, 이미지를 바꿨다면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--force-recreate&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 사용합니다. 공개 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 복제본을 여러 개 띄우지 마세요. 현재 서비스에는 load balancer나 session 공유 설계가 없습니다. 읽기 요청은 재연결 뒤 다시 시도해도 됩니다. 시간이 초과된 변경 요청은 결과를 먼저 확인한 뒤 다시 보내세요.&lt;br /&gt;
&lt;br /&gt;
=== 공개 HTTPS ===&lt;br /&gt;
&lt;br /&gt;
Caddy는 Compose &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;domain&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 프로필에서 실행하며 공개 포트를 사용합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose ps caddy&lt;br /&gt;
docker compose logs --tail=200 caddy&lt;br /&gt;
docker compose up -d --force-recreate caddy&lt;br /&gt;
make verify-domain&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_PUBLIC_HOST&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;나 저장소의 Caddyfile을 바꾼 뒤 Caddy를 다시 만듭니다. 인증서 발급을 다시 시도하려고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;caddy_data&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 지우지 마세요. 먼저 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;s3wiki.yonsei.ac.kr&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;165.132.118.220&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 가리키는지, 다른 프로세스가 80/443을 쓰는지, 두 포트가 외부에서 서버까지 들어오는지 확인합니다. Caddy는 원래 Host 헤더를 보존하며 MCP는 이를 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_PUBLIC_URL=https://s3wiki.yonsei.ac.kr/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 대조합니다. 학교 밖 네트워크의 브라우저 확인도 별도로 해야 합니다.&lt;br /&gt;
&lt;br /&gt;
==== 공용 MCP 토큰 교체 ====&lt;br /&gt;
&lt;br /&gt;
이 파일럿은 토큰 해시 하나만 받으므로 교체 시 모든 쓰기 클라이언트의 기존 토큰이 한 번에 무효가 됩니다. 공개 조회는 계속됩니다. 변경이 잠시 끊긴다고 알리고 새 서비스 확인이 끝날 때까지 기존 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;client.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 보관하세요.&lt;br /&gt;
&lt;br /&gt;
# 원문을 출력하지 않고 새 토큰과 해시를 만듭니다. ```bash ./scripts/configure-mcp-token.sh --rotate ``` 스크립트는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 해시만 바꾸고, 원문 토큰과 URL은 mode &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;0600&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;~/.config/s3-research-memory/client.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 씁니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--rotate&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 없으면 기존 토큰과 해시가 맞는지 확인하고 그대로 둡니다.&lt;br /&gt;
# 공용 어댑터만 다시 만듭니다. ```bash docker compose up -d --force-recreate mcp-http docker compose ps mcp-http docker compose logs --tail=100 mcp-http ```&lt;br /&gt;
# 토큰 없이 초기화, 도구 목록, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;search_lessons(limit=1)&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 확인합니다. 이어서 신뢰하는 셸에서 새 비공개 파일을 불러오고 [[Help:S3 연구 메모리/설치와 검증|&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;setup.md&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;]]의 쓰기 확인을 실행합니다.&lt;br /&gt;
# 새 원문 토큰 또는 클라이언트 환경을 쓰기 클라이언트에 전달하고 재연결합니다. 공개 조회 클라이언트는 바꿀 것이 없습니다. DB·위키 비밀번호와 쓸 수 없는 토큰 해시가 든 서버 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 전달하지 마세요.&lt;br /&gt;
새 서비스를 확인하기 전에 문제가 생겼다면 이전 해시와 그에 맞는 비공개 토큰을 복구하고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 다시 만듭니다. 변경 인증을 끄는 방식으로 되돌리지 마세요.&lt;br /&gt;
&lt;br /&gt;
=== 애플리케이션 업데이트와 되돌리기 ===&lt;br /&gt;
&lt;br /&gt;
업데이트 전에 이름을 붙인 백업을 만들고 복원을 시험합니다. 새 이미지를 빌드하고 DB 마이그레이션과 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 완료한 뒤 복구 완료 항목을 확인합니다. 새 release가 DB를 마이그레이션했다면 이미지만 낮춰서는 안 됩니다. 이전 저장소 revision과 함께 업데이트 전 DB, 업로드, 설정을 한 세트로 복원해야 합니다.&lt;br /&gt;
&lt;br /&gt;
기본 제약은 MediaWiki [https://www.mediawiki.org/wiki/Manual:Restoring_a_wiki_from_backup 복원 안내]와 [https://www.mediawiki.org/wiki/Manual:Upgrading 업그레이드 안내]를 참고하세요.&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC/%EB%B0%B1%EC%97%85%EA%B3%BC_%EB%B3%B5%EC%9B%90&amp;diff=1730</id>
		<title>도움말:S3 연구 메모리/백업과 복원</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC/%EB%B0%B1%EC%97%85%EA%B3%BC_%EB%B3%B5%EC%9B%90&amp;diff=1730"/>
		<updated>2026-07-18T10:44:06Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 저장소 문서 동기화&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;!-- S3RM managed repository document; edit the Markdown source, not this page. --&amp;gt;&lt;br /&gt;
&#039;&#039;이 페이지는 저장소 문서에서 자동으로 동기화됩니다. 위키에서 직접 편집하지 마세요.&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
Semantic MediaWiki가 유일한 Lesson 저장소입니다. 복구 지점에는 같은 시점의 MariaDB 덤프, 업로드 파일, 생성된 위키 설정이 모두 있어야 합니다. 공용 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 상태를 저장하지 않는 전송·검증 어댑터입니다. Lesson 볼륨, 토큰 DB, 별도 기준 데이터가 없으므로 백업하지 않습니다. 이미지와 설정은 저장소와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 다시 만듭니다.&lt;br /&gt;
&lt;br /&gt;
Caddy는 [https://s3wiki.yonsei.ac.kr/]와 [https://s3wiki.yonsei.ac.kr/mcp]의 HTTPS 진입점이며 지식을 저장하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
복구 스크립트는 Bash 4 이상, GNU coreutils(&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;stat&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;sha256sum&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;), GNU tar, gzip이 있는 GNU/Linux Docker 호스트에서 지원합니다. 필요한 명령이 없으면 바로 실패합니다. macOS와 Windows Docker Desktop에서는 이 Compose 프로젝트를 소유한 Linux VM 또는 Linux 호스트에서 실행하세요. BSD 명령 옵션이 같다고 가정하지 마세요.&lt;br /&gt;
&lt;br /&gt;
=== 영구 데이터 ===&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Compose 볼륨 !! 내용 !! 복구 때 역할&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db_data&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || MediaWiki 페이지, 의미 데이터, 사용자, 변경 이력, 로그, 호환 필드 || 기준 데이터베이스&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db_binlogs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || MariaDB ROW binary log와 index || 기준 덤프 이후 시간점 복구(PITR). 기본 14일 보존&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki_images&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 업로드한 근거 파일과 MediaWiki 업로드 메타데이터 || DB 덤프와 같은 시점이어야 함&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki_config&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 생성된 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;LocalSettings.php&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 MediaWiki 비밀 키 || 위키 식별·설정 보존에 필요&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;caddy_data&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 발급된 TLS 인증서, 개인 키, Caddy 실행 상태 || 평소 보존하되 Lesson 기준 데이터는 아님&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;caddy_config&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || Caddy 실행 설정 || 저장소와 공개 hostname으로 다시 생성 가능&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;embedding_models&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || SHA-256으로 확인한 번들 GGUF || 다시 받을 수 있는 파생 캐시. Lesson 복구 세트가 아님&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
일반 백업 스크립트는 DB 덤프, 설정, 업로드와 함께 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db_binlogs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 캡처 시점 구간과 덤프에 내장된 replay 시작 file·position을 저장합니다. 이 항목이 Lesson 복구 세트입니다. 새 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db_binlogs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 볼륨은 one-shot &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db-binlog-init&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 MariaDB 계정만 쓸 수 있도록 소유권과 mode를 맞춘 뒤 DB가 시작합니다. 이 서비스가 실패하면 DB를 시작하지 않으며, 볼륨 권한을 넓혀 우회하지 않습니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;caddy_data&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;caddy_config&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 평소 재시작 때 남겨 두되, 버전이 있는 위키 백업에 섞지 않습니다. 이 볼륨이 없어지면 DNS와 외부 80/443이 정상일 때 Caddy가 인증서를 다시 받을 수 있습니다. 일반 운영에서는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;docker compose down --volumes&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 쓰지 마세요.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;embedding_models&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에는 Lesson, 검색 query나 벡터가 없습니다. 백업·복원 스크립트는 이 볼륨을 포함하지 않으며, 의미 검색을 다시 켤 때 고정된 원본과 SHA-256으로 재구성합니다. 평소에는 재다운로드와 서비스 중단을 피하도록 볼륨을 남겨 둡니다.&lt;br /&gt;
&lt;br /&gt;
저장소에는 다시 만들 수 있는 코드, 템플릿, 양식이 있습니다. 자격 증명이 든 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 추적하지 않으며 일반 백업 아카이브에도 넣지 않습니다. 서버 담당자가 복구용 사본을 따로 보관하되 Git이나 공개 아카이브에는 넣지 마세요.&lt;br /&gt;
&lt;br /&gt;
복구 세트의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;runtime-images.txt&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 Compose에 설정한 image reference와 실제 컨테이너 image ID를 함께 기록합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;repository-tracked.patch.gz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 현재 HEAD 기준의 &#039;&#039;&#039;추적된 파일&#039;&#039;&#039; 차이만 담습니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, untracked dump, upload, 자격 증명은 구조적으로 제외됩니다. untracked 파일이 있었다면 metadata에 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;present_not_archived&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;만 남고 내용과 이름은 보관하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
서버 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에는 MCP 쓰기 토큰의 해시만 있습니다. 전달용 원문 토큰이 든 mode &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;0600&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;~/.config/s3-research-memory/client.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;도 최소 한 곳에 보관하세요. 원문 토큰을 모두 잃었다면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;./scripts/configure-mcp-token.sh --rotate&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 새 토큰을 만들고 쓰기 클라이언트에 다시 전달합니다. 이 작업 중에도 공개 조회는 계속됩니다.&lt;br /&gt;
&lt;br /&gt;
=== 백업 ===&lt;br /&gt;
&lt;br /&gt;
저장소 밖의 절대 경로를 사용합니다. 가능하면 Docker 호스트와 다른 디스크를 선택하고, 서버 담당자만 읽고 쓸 수 있게 만듭니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
install -d -m 0700 /srv/s3rm-backups&lt;br /&gt;
docker compose stop mcp-http&lt;br /&gt;
./scripts/backup.sh /srv/s3rm-backups&lt;br /&gt;
docker compose up -d mcp-http&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
먼저 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 멈추면 에이전트 변경 경로가 닫힙니다. 백업 스크립트는 DB를 기다린 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-jobs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 실행 중인 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 멈춰 브라우저와 job 변경도 막습니다. MariaDB binary log를 교체해 file·position을 고정한 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--master-data=2&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 논리 덤프, binary-log 구간, 업로드·설정 볼륨을 묶습니다. 백업과 복구 스크립트는 같은 checkout 디렉터리에 nonblocking maintenance lock을 겁니다. 예약 백업, 수동 백업, 복구 중 둘 이상을 겹쳐 실행하면 나중 시작한 작업이 즉시 실패합니다. 다른 checkout으로 같은 운영 Compose project를 동시에 관리하지 마세요. 백업 종료 때는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 실행 중이던 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-jobs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 순서로 복원하고 job worker health를 확인한 뒤에만 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 다시 엽니다. worker가 준비되지 않으면 백업 파일 생성 여부와 관계없이 실패로 끝나며 MCP는 중지 상태를 유지합니다. 실패해도 exit trap이 스크립트가 관리한 서비스를 백업 전 상태로 돌립니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 명시적으로 멈췄으므로 성공하거나 원인을 확인한 실패 뒤에도 마지막 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;up&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 명령을 실행해야 합니다.&lt;br /&gt;
&lt;br /&gt;
모든 파일의 형식과 checksum을 확인한 뒤에만 시각이 붙은 최종 디렉터리를 공개합니다. 덤프나 근거 아카이브를 저장소에 넣지 않습니다. 끝에 출력되는 복구 디렉터리의 정확한 경로를 기록하세요.&lt;br /&gt;
&lt;br /&gt;
==== binary log를 처음 켜는 서버 ====&lt;br /&gt;
&lt;br /&gt;
이 설정을 배포하기 전부터 실행 중인 DB는 컨테이너를 다시 만들기 전까지 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;log_bin=OFF&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;일 수 있습니다. 이 상태에서 일반 백업은 중단되며, 최초 이전 전에만 다음 명시적 기준 백업을 사용합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
./scripts/backup.sh --legacy-base /srv/s3rm-backups&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--legacy-base&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;log_bin=OFF&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;인 DB에서만 실행됩니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;database.sql.gz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 두 볼륨 아카이브, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;metadata.txt&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;SHA256SUMS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 이루어진 복원 가능한 format 1 기준 백업이며 PITR 보조 파일은 &#039;&#039;&#039;하나도&#039;&#039;&#039; 생성하지 않습니다. 나중에 별도 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;database-binlogs.tar.gz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;나 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;AUXILIARY-SHA256SUMS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 더해 일반 백업처럼 만들지 마세요. 보조 파일이 일부만 있는 세트는 복원과 격리 검증이 거부합니다.&lt;br /&gt;
&lt;br /&gt;
체크섬을 확인하고 완성된 디렉터리를 원격 저장소에 복사한 뒤에만 DB를 재생성합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db_data&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 지우지 않고 새 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db_binlogs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 볼륨을 연결합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose stop mediawiki-jobs mcp-http bootstrap mediawiki&lt;br /&gt;
docker compose up -d --force-recreate --wait db&lt;br /&gt;
docker compose exec -T db sh -eu -c &#039;&lt;br /&gt;
  MYSQL_PWD=&amp;quot;$MARIADB_ROOT_PASSWORD&amp;quot; mariadb \&lt;br /&gt;
    --host=127.0.0.1 --user=root --batch \&lt;br /&gt;
    --execute=&amp;quot;SHOW VARIABLES LIKE &#039;\&#039;&#039;log_bin&#039;\&#039;&#039;; SHOW MASTER STATUS;&amp;quot;&lt;br /&gt;
&#039;&lt;br /&gt;
make domain-up&lt;br /&gt;
./scripts/backup.sh /srv/s3rm-backups&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
출력에 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;log_bin\tON&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;과 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mariadb-bin.NNNNNN&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 행이 모두 있어야 합니다. 마지막 일반 백업은 PITR 보조 체인까지 갖춘 새 기준입니다. 이후에는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--legacy-base&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 사용하지 마세요.&lt;br /&gt;
&lt;br /&gt;
복구 디렉터리는 다음 파일을 한 세트로 가집니다.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 파일 !! 내용&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;database.sql.gz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 설정된 MediaWiki DB 전체 논리 덤프&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;database-binlogs.tar.gz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 일반 백업만: 캡처 시점까지 보존된 MariaDB ROW binary log·index 보조 아카이브&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;pitr-manifest.txt&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 일반 백업만: 기준 dump 뒤 replay를 시작할 binary-log file·position·GTID, archive 목록·digest&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-images.tar.gz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 업로드·근거 볼륨의 numeric-owner 아카이브&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-config.tar.gz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 생성된 설정과 위키 키의 numeric-owner 아카이브&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;runtime-images.txt&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 일반 백업만: 설정 reference와 실제 실행 컨테이너 image ID&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;repository-tracked.patch.gz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 일반 백업만: HEAD에 적용할 추적 소스 binary patch. untracked·ignored 파일 제외&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;repository-tracked-manifest.txt&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 일반 백업만: 추적 파일별 SHA-256과 소스 상태&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;metadata.txt&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 형식 버전, DB 이름, 페이지·revision 수, 소스 revision, 이미지 참조, Compose 해시. 일반 백업은 PITR position도 포함&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;SHA256SUMS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 앞의 네 payload checksum&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;AUXILIARY-SHA256SUMS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 일반 백업만: PITR·image·tracked-source 보조 파일 checksum. 이 파일 digest는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;metadata.txt&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 통해 기본 manifest와 결합&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
기본 대상, 각 복구 디렉터리, 모든 파일의 권한은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;0700&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 또는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;0600&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;입니다. 기존 대상에 group/other 접근 비트가 하나라도 있으면 스크립트가 거부하며 권한을 임의로 바꾸지 않습니다. 백업할 때 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 실행 중이어야 합니다. 위키가 원래 정지 상태였다면 그대로 두고, 실행 중이었다면 API가 다시 healthy가 될 때까지 기다린 뒤 성공을 알립니다. 복원도 소스 디렉터리에 같은 권한 검사를 합니다.&lt;br /&gt;
&lt;br /&gt;
기본 로컬 대상에 저장하려면 경로를 생략합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose stop mcp-http&lt;br /&gt;
./scripts/backup.sh&lt;br /&gt;
docker compose up -d mcp-http&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
기본 로컬 경로만 장기 보관에 사용하지 마세요. 완료된 디렉터리 전체를 접근이 제한된 외부 저장소에 복사합니다. 공개 메모가 주 내용이더라도 백업에는 사용자 정보, 삭제·이전 revision, 생성된 비밀 키가 있으므로 공개하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
==== 백업 확인 ====&lt;br /&gt;
&lt;br /&gt;
스크립트가 실패하면 그 백업은 사용할 수 없습니다. 성공한 뒤에도 다음을 확인합니다.&lt;br /&gt;
&lt;br /&gt;
# DB 덤프, 설정·이미지 아카이브, 메타데이터, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;SHA256SUMS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 있는지 확인합니다. 일반 백업은 binary-log·PITR 보조 파일 전체와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;AUXILIARY-SHA256SUMS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;도 있어야 합니다. 최초 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--legacy-base&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 보조 파일이 하나도 없어야 합니다.&lt;br /&gt;
# 스크립트가 출력한 checksum 명령을 실행합니다. ```bash (cd /ABS/PATH/TO/BACKUP-DIRECTORY &amp;amp;amp;&amp;amp;amp; \ sha256sum --check --strict SHA256SUMS) # 일반 PITR 백업에서만 추가 확인 (cd /ABS/PATH/TO/BACKUP-DIRECTORY &amp;amp;amp;&amp;amp;amp; \ sha256sum --check --strict AUXILIARY-SHA256SUMS) ```&lt;br /&gt;
# 빈 파일이 없는지, 시각과 소스 버전이 맞는지 확인합니다.&lt;br /&gt;
# 디렉터리 전체를 한 단위로 복사합니다. 서로 다른 시각의 파일을 섞지 마세요.&lt;br /&gt;
# 아래의 격리 복원 시험을 정기적으로 실행합니다. checksum은 전송 무결성만 확인하며 실제 복원 가능성을 보장하지 않습니다.&lt;br /&gt;
MediaWiki [https://www.mediawiki.org/wiki/Manual:Backing_up_a_wiki 백업 안내]도 DB와 파일을 함께 보관하도록 안내합니다. XML 페이지 내보내기는 보조 이동본으로 쓸 수 있지만 사용자 계정, 모든 로그, 업로드 메타데이터, 사이트 전체 상태를 담지 않으므로 이 복구 세트를 대신할 수 없습니다.&lt;br /&gt;
&lt;br /&gt;
==== MariaDB PITR 경계 ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;database.sql.gz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 완전 기준 백업입니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;pitr-manifest.txt&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;binlog_file&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;·&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;binlog_position&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 그 덤프가 이미 포함한 마지막 경계이자 replay 시작 좌표입니다. 이 좌표 이전 event를 다시 적용하면 중복 변경이 생깁니다. 이후 시각으로 이동하려면 기준 좌표 &#039;&#039;&#039;이후&#039;&#039;&#039;의 연속된 binary log가 전부 있어야 합니다. 백업 당시 archive에는 아직 생기지 않은 미래 event가 들어 있을 수 없으므로, 더 늦게 만든 복구 세트의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;database-binlogs.tar.gz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;나 별도 연속 보관본이 필요합니다. 로컬 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db_binlogs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 기본 14일 후 자동 만료되므로 원격 백업과 복원 시험이 이 기간 안에 성공해야 합니다.&lt;br /&gt;
&lt;br /&gt;
PITR은 운영 DB에 바로 재생하지 마세요. 격리 복원을 먼저 완료한 뒤, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mariadb-binlog --start-position&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에는 manifest의 좌표 다음 event부터 적용되도록 경계를 확인하고, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--stop-datetime&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 또는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--stop-position&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 명시하여 SQL을 만듭니다. 재생 전에 시작·종료 file, position, target UTC, 예상 event 수를 작업 기록에 남기고, Lesson·revision 개수와 대표 페이지를 확인합니다. binary log가 하나라도 빠졌거나 기준 position보다 이전 백업과 섞였다면 PITR를 계속하지 말고 다음 완전 백업으로 복원합니다.&lt;br /&gt;
&lt;br /&gt;
==== 권장 주기 ====&lt;br /&gt;
&lt;br /&gt;
파일럿에서는 다음 주기로 시작합니다.&lt;br /&gt;
&lt;br /&gt;
* 매일 백업, 14일 보관&lt;br /&gt;
* 매주 백업, 8주 보관&lt;br /&gt;
* 이미지·의존성 업데이트나 자격 증명 변경 전 백업&lt;br /&gt;
* 분기마다 격리 복원 시험&lt;br /&gt;
실제 복구 시점 목표는 운영 상황에 맞게 정합니다. 스케줄러 종료 상태와 디스크 여유 공간을 확인하세요. 오류 알림이 없는 예약 명령만으로는 충분하지 않습니다. 새 복원을 확인하기 전에 가장 최근의 정상 백업을 지우지 마세요.&lt;br /&gt;
&lt;br /&gt;
==== Cloudflare R2 자동 백업 ====&lt;br /&gt;
&lt;br /&gt;
이 서버는 다음 비공개 R2 bucket의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;restic&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; prefix를 원격 백업 저장소로 사용합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
https://96f01030a087d07d906bff299d260155.r2.cloudflarestorage.com/s3rm-backups&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
백업 데이터는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;restic&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 서버에서 먼저 암호화한 뒤 전송합니다. R2 Access Key, Secret Key와 restic 암호는 Git이나 프로젝트 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 넣지 않습니다.&lt;br /&gt;
&lt;br /&gt;
서버에 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;restic&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 설치한 뒤 root 권한으로 설정 파일을 준비합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
sudo install -d -m 0700 /etc/s3-research-memory /srv/s3rm-backups&lt;br /&gt;
sudo install -m 0600 config/remote-backup.env.example \&lt;br /&gt;
  /etc/s3-research-memory/remote-backup.env&lt;br /&gt;
sudo sh -c &#039;umask 077; openssl rand -base64 48 &amp;gt; /etc/s3-research-memory/restic-password&#039;&lt;br /&gt;
sudoedit /etc/s3-research-memory/remote-backup.env&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
R2에서 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;s3rm-backups&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; bucket에만 목록 조회·읽기·쓰기·삭제가 가능한 S3 API 자격 증명을 만들고 설정 파일의 두 placeholder를 바꿉니다. 삭제 권한은 오래된 restic snapshot과 pack을 보존 정책에 맞게 정리할 때 필요합니다. restic 암호 파일은 서버 장애 때도 찾을 수 있도록 서버 밖의 비밀번호 관리자에 따로 보관합니다. 이 암호를 잃으면 R2의 백업을 복구할 수 없습니다.&lt;br /&gt;
&lt;br /&gt;
현재 live restic repository의 자격 증명은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;forget&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;·&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;prune&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 위해 삭제 권한도 가집니다. 따라서 &#039;&#039;&#039;R2 자격 증명 탈취와 오작동으로부터의 불변 백업은 현재 저장소 하나만으로 달성되지 않습니다.&#039;&#039;&#039; 이 제한은 복원 시험 기록에 항상 남깁니다.&lt;br /&gt;
&lt;br /&gt;
R2의 lock·retention을 live restic prefix에 바로 적용하면 임시 lock 제거와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;forget&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;·&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;prune&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 실패할 수 있습니다. 삭제 방어는 다음과 같이 &#039;&#039;&#039;별도 보호 복사본&#039;&#039;&#039;으로 구성합니다.&lt;br /&gt;
&lt;br /&gt;
# live &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;restic&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; prefix는 현재 보존 정책으로 검증·정리합니다.&lt;br /&gt;
# 검증한 복구 세트 또는 restic repository 세대를 별도 bucket·account의 보호 prefix로 복사합니다. 복사 writer와 retention 변경 권한을 분리합니다.&lt;br /&gt;
# 보호 복사본은 일간 운영 키로 삭제할 수 없게 만들고, 예외 해제 권한은 다른 계정에 보관합니다.&lt;br /&gt;
# Cloudflare 계정 MFA, bucket 범위 API token, 삭제·retention 변경 알림, 분기별 보호 복사본 복원 시험을 함께 적용합니다.&lt;br /&gt;
별도 bucket/retention/API token은 Cloudflare 외부 설정이므로 이 저장소의 Compose나 systemd 파일만으로는 활성화할 수 없습니다. 이 설정이 완료되기 전에는 live R2를 유일한 삭제 방어 본으로 간주하지 마세요.&lt;br /&gt;
&lt;br /&gt;
원격 저장소는 한 번만 초기화합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
sudo bash -c &#039;&lt;br /&gt;
  set -a&lt;br /&gt;
  source /etc/s3-research-memory/remote-backup.env&lt;br /&gt;
  set +a&lt;br /&gt;
  exec /home/mrcha033/Projects/s3wiki/scripts/init-remote-backup.sh&lt;br /&gt;
&#039;&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
systemd 단위를 설치하고 첫 백업을 수동으로 확인합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
sudo install -m 0644 deploy/systemd/s3rm-remote-backup.service \&lt;br /&gt;
  /etc/systemd/system/s3rm-remote-backup.service&lt;br /&gt;
sudo install -m 0644 deploy/systemd/s3rm-remote-backup.timer \&lt;br /&gt;
  /etc/systemd/system/s3rm-remote-backup.timer&lt;br /&gt;
sudo systemctl daemon-reload&lt;br /&gt;
sudo systemctl start s3rm-remote-backup.service&lt;br /&gt;
sudo systemctl status s3rm-remote-backup.service&lt;br /&gt;
sudo systemctl enable --now s3rm-remote-backup.timer&lt;br /&gt;
systemctl list-timers s3rm-remote-backup.timer&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
작업은 매일 서울 시각 03:15 이후 45분 안에 실행합니다. 성공한 원격 snapshot은 일간 14개와 주간 8개를 보존하고, 원격 pack 정리는 일요일에 실행합니다. 전송이나 검증이 실패하면 로컬 복구 세트를 지우지 않습니다. 성공하면 R2에 이미 암호화된 snapshot을 확인한 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/srv/s3rm-backups&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 해당 임시 세트를 정리합니다. snapshot 경로에는 매번 다른 시각이 들어가므로 restic 보존 그룹은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;host,tags&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로만 묶습니다. 경로까지 그룹 기준에 넣으면 각 snapshot이 항상 자신만의 그룹이 되어 14일·8주 정책이 아무 것도 만료시키지 못합니다.&lt;br /&gt;
&lt;br /&gt;
상태와 최근 로그를 정기적으로 확인합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
systemctl status s3rm-remote-backup.timer&lt;br /&gt;
sudo journalctl -u s3rm-remote-backup.service --since &#039;2 days ago&#039;&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
원격 snapshot을 복구할 때는 빈 root 전용 디렉터리에 먼저 내려받고 기존 복원 스크립트로 넘깁니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
sudo install -d -m 0700 /srv/s3rm-remote-restore&lt;br /&gt;
sudo bash -c &#039;&lt;br /&gt;
  set -a&lt;br /&gt;
  source /etc/s3-research-memory/remote-backup.env&lt;br /&gt;
  set +a&lt;br /&gt;
  restic snapshots --tag s3rm-recovery-set&lt;br /&gt;
  restic restore latest --tag s3rm-recovery-set --target /srv/s3rm-remote-restore&lt;br /&gt;
&#039;&lt;br /&gt;
sudo find /srv/s3rm-remote-restore -maxdepth 2 -type f -name SHA256SUMS -print&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
출력된 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;SHA256SUMS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 상위 디렉터리가 한 개이고 원하는 시각의 복구 세트인지 확인한 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;scripts/restore.sh &amp;amp;lt;그 디렉터리&amp;amp;gt; --yes&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 사용합니다. 운영 서버를 덮어쓰기 전에 격리 복원 시험을 먼저 수행합니다.&lt;br /&gt;
&lt;br /&gt;
=== Lesson 내용 정기 감사 ===&lt;br /&gt;
&lt;br /&gt;
읽기 전용 감사 작업은 매주 운영 위키의 안정된 Lesson을 중앙 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 읽습니다. 실행 전 30분 안에 바뀐 Lesson은 다음 감사로 미룹니다. 별도 Lesson 저장소나 기준 파일을 만들지 않습니다. 쓰기 토큰도 사용하지 않습니다. 외부 근거 URL을 열거나 Lesson을 자동으로 수정하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
감사 작업은 다음 항목을 확인합니다.&lt;br /&gt;
&lt;br /&gt;
* 구조화된 근거가 없는 Lesson&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;high&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; confidence인데 최신 append-only 검증 기록이 observation, interpretation, reusable_lesson 전부를 strong &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supports&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 뒷받침하지 않는 Lesson&lt;br /&gt;
* 확인 범위가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;not_recorded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;인 근거&lt;br /&gt;
* 같거나 거꾸로 된 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;verified_at&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 append 순서의 현재 판정을 정할 수 없는 Lesson&lt;br /&gt;
* 서로 같은 관찰·해석 또는 재사용 내용·적용 조건&lt;br /&gt;
* 대상이 없는 relation과 중복 relation&lt;br /&gt;
* 365일 이상 바뀌지 않은 검토 후보&lt;br /&gt;
* 비밀 키나 토큰 형식으로 보이는 문자열&lt;br /&gt;
발견 결과에는 Lesson ID, revision ID, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;codes&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 배열과 상한이 있는 evidence·verification ID만 남깁니다. Lesson 본문, 인용과 relation note는 journal에 남기지 않습니다. 같은 Lesson의 발견 코드는 한 줄로 묶습니다. 오래된 Lesson이라는 이유만으로 틀렸다고 판정하지 않으며 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;stale_review_candidate&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로만 표시합니다. 감사는 저장된 구조, digest, append 순서와 검증 수령 기록의 일관성을 확인합니다. 외부 근거 URL을 열지 않으므로 논문·코드·데이터의 사실 진위를 자동으로 판정하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
systemd 단위를 설치하고 첫 감사를 수동으로 실행합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
sudo install -D -m 0755 scripts/audit-lessons.py \&lt;br /&gt;
  /usr/local/libexec/s3rm-content-audit&lt;br /&gt;
sudo install -m 0644 deploy/systemd/s3rm-content-audit.service \&lt;br /&gt;
  /etc/systemd/system/s3rm-content-audit.service&lt;br /&gt;
sudo install -m 0644 deploy/systemd/s3rm-content-audit.timer \&lt;br /&gt;
  /etc/systemd/system/s3rm-content-audit.timer&lt;br /&gt;
sudo systemctl daemon-reload&lt;br /&gt;
sudo systemctl start s3rm-content-audit.service&lt;br /&gt;
sudo systemctl status s3rm-content-audit.service&lt;br /&gt;
sudo journalctl -u s3rm-content-audit.service --since today&lt;br /&gt;
sudo systemctl enable --now s3rm-content-audit.timer&lt;br /&gt;
systemctl list-timers s3rm-content-audit.timer&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
작업은 일요일 서울 시각 04:30 이후 30분 안에 실행합니다. 놓친 실행은 서버가 다시 켜졌을 때 실행합니다. 배포 단위와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;make audit-lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 현재 서버의 scan limit과 같은 500개 Lesson을 읽습니다. 스크립트 자체의 기본값은 200개이고 설정 가능한 절대 상한은 1000개입니다. Lesson당 근거·relation은 각각 최대 200개를 읽습니다. 서버의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;scan_limit&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 목록이 잘리거나 감사 중 revision과 Lesson 목록이 바뀌면 완료로 기록하지 않습니다. 연결 오류나 동시 변경으로 끝나면 5분 간격으로 최대 세 번 시도합니다. 발견 사항을 뜻하는 종료 코드 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;2&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 다시 시도하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
종료 코드는 다음과 같습니다.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 코드 !! 뜻 !! 다음 작업&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;0&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 감사를 완료했고 발견 사항이 없음 || 다음 예약 실행을 기다립니다.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 연결, 출력 한도 또는 동시 변경으로 감사를 완료하지 못함 || journal의 마지막 오류를 확인하고 다시 실행합니다.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;2&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 감사를 완료했고 확인할 항목이 있음 || 발견된 Lesson을 읽고 근거를 확인합니다.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;systemd&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 서비스는 임시 계정으로 실행합니다. 홈 디렉터리, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, Docker 소켓과 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/etc/s3-research-memory&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 읽을 수 없습니다. 발견 사항이 있으면 실패 상태로 표시합니다. 이 상태는 Lesson을 바꾸지 않으며 다음 예약 실행도 막지 않습니다. 문제를 고칠 때는 근거를 먼저 붙인 뒤 같은 Lesson ID에 현재 revision을 기준으로 새 revision을 추가합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supersedes&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;나 근거 note를 기존 주장 수정 수단으로 쓰지 않습니다.&lt;br /&gt;
&lt;br /&gt;
예약과 무관하게 현재 상태를 확인할 때는 다음 명령을 사용합니다. 발견 사항이 있어도 수동 명령은 감사 완료 뒤 코드 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;0&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 반환합니다. 자동 점검과 같은 종료 코드가 필요하면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--fail-on-findings&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 추가합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
make audit-lessons&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 복원 전 주의 ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;scripts/restore.sh&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 현재 DB, 업로드, 생성된 위키 설정을 교체합니다. 명시적인 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--yes&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 필요하며, 공지한 점검 시간에만 실행합니다. 병합이나 페이지 가져오기 도구가 아닙니다.&lt;br /&gt;
&lt;br /&gt;
복원 전에 다음을 확인합니다.&lt;br /&gt;
&lt;br /&gt;
* 정확한 백업 디렉터리를 정하고 모든 checksum을 확인합니다.&lt;br /&gt;
* 백업의 MediaWiki·애플리케이션 버전이 현재 checkout과 호환되는지 확인합니다. 새 DB를 더 오래된 코드에 복원할 수 없습니다.&lt;br /&gt;
* 점검 시간을 알리고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 멈춘 뒤 브라우저 편집도 중단합니다.&lt;br /&gt;
* 현재 상태를 읽을 수 있으면 복원 직전 백업을 하나 더 만듭니다.&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 복원할 인스턴스와 맞는 자격 증명이 있는지 확인합니다.&lt;br /&gt;
* 복원을 결정한 사람과 이유를 기록합니다.&lt;br /&gt;
=== 현재 서버 복원 ===&lt;br /&gt;
&lt;br /&gt;
백업을 만든 것과 같은 저장소 버전에서 실행합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose --profile domain stop caddy mcp-http&lt;br /&gt;
./scripts/restore.sh /ABS/PATH/TO/BACKUP-DIRECTORY --yes&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
스크립트는 checksum manifest, 백업 형식 버전, 압축 stream, 모든 아카이브 member의 경로와 종류를 먼저 확인합니다. 보조 파일이 있는 형식은 auxiliary manifest digest와 binary-log·source artifact도 모두 확인합니다. 백업의 DB 이름과 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;MARIADB_DATABASE&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;도 같아야 합니다. 다르면 덤프를 편집하지 말고 복구용 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 맞춥니다. 이 검증과 이미지 빌드 사전 확인이 모두 끝난 뒤에만 애플리케이션을 멈추고 기준 DB·설정·업로드를 교체합니다.&lt;br /&gt;
&lt;br /&gt;
기준 dump import는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;sql_log_bin=0&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 세션에서 실행합니다. 이어서 DB를 멈추고 기존 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db_binlogs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 &#039;&#039;&#039;더 새로운 운영 timeline을 비운 뒤&#039;&#039;&#039; 새 local timeline으로 시작합니다. 따라서 과거 DB에 미래 binlog가 이어지는 일은 없지만, backup의 archived binlog를 자동 replay하지도 않습니다. PITR이 필요하면 운영 복원 전에 별도 격리 환경에서 목표 UTC·종료 position을 정해 검증한 절차로만 적용합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 시작 때 MediaWiki·확장 기능 마이그레이션을 적용하고, 이어서 반복 실행이 가능한 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 적용합니다. 그 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-job-state-init&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 강제로 다시 실행해 새 복구 볼륨을 UID/GID 33 쓰기 경로로 만든 다음 bounded &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-jobs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 시작해 worker health까지 확인합니다. 복구 중 MediaWiki host bind는 임시로 loopback에 강제하고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;caddy&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 성공 후에도 중지한 상태로 둡니다. 따라서 마이그레이션· bootstrap 중 공개 브라우저나 MCP 쓰기가 재개되지 않습니다. 교체 경고가 나온 뒤에는 중단하지 마세요. 오류가 나오면 클라이언트를 멈춘 상태로 로그와 소스 백업을 보존합니다. 실패한 상태 위에 부분 SQL이나 일부 볼륨을 임의로 덮어쓰지 마세요.&lt;br /&gt;
&lt;br /&gt;
완료 뒤 상태를 확인합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose ps -a&lt;br /&gt;
docker compose logs mediawiki bootstrap mediawiki-jobs&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
아래 MediaWiki 복구 확인을 마칠 때까지 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 멈춰 둡니다. 확인 뒤 현재 설정으로 공용 어댑터를 다시 만들고, 공개 조회와 토큰 기반 변경을 모두 시험합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose --profile domain up -d --force-recreate mediawiki mcp-http caddy&lt;br /&gt;
docker compose ps mediawiki-jobs mcp-http&lt;br /&gt;
docker compose logs --tail=100 mcp-http&lt;br /&gt;
./scripts/verify-public-domain.sh s3wiki.yonsei.ac.kr&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 격리 복원 시험 ===&lt;br /&gt;
&lt;br /&gt;
실제 서버를 덮어쓰지 말고 별도 Compose 프로젝트와 사용하지 않는 포트에서 시험합니다. 별도 checkout을 사용하고, 복구 디렉터리는 이동하지 말고 복사합니다. Compose 프로젝트와 볼륨 이름을 고유하게 지정하세요. 최상위 이름은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;COMPOSE_PROJECT_NAME&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 바꿀 수 있으며 두 스크립트가 이 값을 사용합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;COMPOSE&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 환경 변수에는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;docker compose --ansi never&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;처럼 고정 접두사를 넣을 수 있습니다. 설치, 백업, 복원에 같은 값을 사용하세요.&lt;br /&gt;
&lt;br /&gt;
권장 경로는 전용 검증 스크립트입니다. 운영 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 아닌 mode &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;0600&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 일회용 env에 사용하지 않는 loopback 포트, 일회용 DB·wiki 비밀번호, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;MW_SERVER_URL=http://127.0.0.1:18080&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;과 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_WIKI_BIND_ADDRESS=127.0.0.1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_HTTP_PORT=18080&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 넣습니다. 복원용 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_WIKI_BOT_PASSWORD&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;도 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;openssl rand -hex 16&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 따로 만들고, 운영 BotPassword와 primary 위키 비밀번호를 재사용하지 않습니다. 검증 스크립트는 DB를 가져온 직후 격리 project에서만 복원된 관리자 비밀번호를 일회용 env의 관리자 secret으로 바꿉니다. secret은 명령행 인자나 로그에 넣지 않으며, 격리 환경을 지울 때 변경된 관리자 해시도 함께 없어집니다. 운영 project에는 이 비밀번호 재설정 옵션을 사용할 수 없습니다. 실제 Compose project 이름과 모든 volume·network 이름도 함께 검사하므로 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;COMPOSE&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 접두사는 검증 스크립트가 만든 고정 명령만 허용합니다. 따라서 다른 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--project-name&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, Compose override, bind mount, 외부 volume 또는 별칭을 덧붙여 이 경계를 우회할 수 없습니다. bootstrap의 관리자·MCP 기본 계정·BotPassword도 secret 파일에서 직접 읽으며 평문을 자식 프로세스 환경이나 명령행 인자로 전달하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
chmod 0600 /srv/s3rm-restore-test.env&lt;br /&gt;
./scripts/verify-restore-isolated.sh /ABS/PATH/TO/BACKUP-DIRECTORY \&lt;br /&gt;
  --env-file /srv/s3rm-restore-test.env \&lt;br /&gt;
  --project-name s3rm-restore-2026q3&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
스크립트는 project 접두사, 새 project에 기존 컨테이너·볼륨이 없는지, loopback 주소, 기본 checksum과, 있는 경우 PITR 보조 checksum 체인을 &#039;&#039;&#039;변경 전&#039;&#039;&#039; 확인합니다. 격리 project 이름으로 전용 MediaWiki image tag를 만들어 운영 image tag를 동시에 다시 빌드하지 않습니다. 복원 후 Lesson 개수는 정확히 일치해야 하고, revision은 bootstrap이 관리 페이지 변경을 추가할 수 있으므로 백업 기록 &#039;&#039;&#039;이상&#039;&#039;&#039;이어야 합니다. MediaWiki API, bootstrap, bounded &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-jobs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; health도 확인합니다. 성공하면 격리 볼륨을 지우고, 실패하면 진단을 위해 보존합니다. 성공 시 project 전용 MediaWiki image tag도 함께 지웁니다. 자동 정리가 실패하면 스크립트가 실패 상태와 정확한 수동 정리 명령을 출력합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--keep&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 사용하면 성공한 환경도 보존합니다. 이 검증은 스키마·개수·시작 경계를 자동 확인하지만, 아래의 사람이 열어 보는 대표 Lesson·업로드·검색 확인을 대신하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
Docker가 설치된 일회용 VM에서는 다음 순서가 안전합니다.&lt;br /&gt;
&lt;br /&gt;
# 백업과 정확히 같은 저장소 revision, 복구 디렉터리, 복구용 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 VM에 복사합니다.&lt;br /&gt;
# &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;COMPOSE_PROFILES=&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 실제 hostname과 80/443을 사용하지 않게 합니다. 겹치지 않는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_HTTP_PORT&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 맞는 로컬 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;MW_SERVER_URL&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 설정합니다. 별도 loopback &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_HTTP_PORT&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;·&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_PUBLIC_URL&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;과 일회용 MCP 토큰도 사용합니다. 실제 MCP URL이나 토큰을 복원 시험에 쓰지 마세요.&lt;br /&gt;
# VM에서 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;scripts/restore.sh &amp;amp;lt;backup-directory&amp;amp;gt; --yes&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 실행합니다.&lt;br /&gt;
# 아래 복구 확인을 모두 수행합니다.&lt;br /&gt;
# 결과를 기록한 뒤 VM을 지웁니다. 원본 복구 세트는 그대로 둡니다.&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;docker compose config&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;docker volume ls&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 볼륨 이름이 다른지 보기 전에는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;COMPOSE_PROJECT_NAME&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;만 바꿨다고 격리된 것으로 보지 마세요.&lt;br /&gt;
&lt;br /&gt;
=== 복구 완료 확인 ===&lt;br /&gt;
&lt;br /&gt;
다음 항목을 모두 통과해야 복원이 끝납니다.&lt;br /&gt;
&lt;br /&gt;
# &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-jobs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 healthy이고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-install&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;과 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 코드 0으로 종료되었습니다.&lt;br /&gt;
# &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Special:Version&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 예상한 MediaWiki, Semantic MediaWiki, Page Forms, S3 Research Memory 버전이 표시됩니다.&lt;br /&gt;
# Lesson 페이지 수와 최근 revision 수가 백업 기록과 맞습니다.&lt;br /&gt;
# 복원된 Lesson의 필드, 근거, append-only 검증 기록, 관계, 출처, 호환 필드, 시간이 정상 표시됩니다.&lt;br /&gt;
# &#039;&#039;&#039;역사 보기&#039;&#039;&#039;에서 이전 revision 하나 이상이 열립니다.&lt;br /&gt;
# 업로드한 근거가 열리고, 외부 checksum을 기록했다면 값이 맞습니다.&lt;br /&gt;
# 전문 검색이 알려진 제목과 본문 단어를 찾습니다.&lt;br /&gt;
# 격리한 복원 시험 환경에서만 일반 브라우저 계정으로 Lesson 양식을 열고 복구 확인용 메모를 저장합니다. 바로 검색되는지 본 뒤 격리 환경과 함께 폐기합니다.&lt;br /&gt;
# 다시 만든 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 healthy입니다. 토큰 없이 초기화·도구 목록· &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;search_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 실행하면 짧은 인용 결과가 나옵니다. 쓰기 토큰을 넣은 클라이언트는 같은 격리 환경에 확인용 메모를 만들 수 있습니다. 운영 위키에는 확인용 데이터를 만들지 않습니다.&lt;br /&gt;
# 따로 설정한 두 번째 클라이언트가 같은 Lesson을 조회합니다. 두 클라이언트가 로컬 데이터가 아닌 복원된 위키 하나를 보고 있어야 합니다.&lt;br /&gt;
# MCP 계정은 여전히 삭제, 이동, 보호, 관리 기능을 사용할 수 없습니다.&lt;br /&gt;
복구 디렉터리, 소요 시간, 애플리케이션 revision, 결과, 실행자, 필요했던 조치를 기록합니다. 하나라도 실패하면 점검 상태를 유지합니다.&lt;br /&gt;
&lt;br /&gt;
=== 평소 상태 확인 ===&lt;br /&gt;
&lt;br /&gt;
상태와 제한된 길이의 로그를 확인합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose ps -a&lt;br /&gt;
docker compose logs --tail=200 db mediawiki-install mediawiki bootstrap \&lt;br /&gt;
  mediawiki-jobs mcp-http caddy&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
로컬 프로세스 경로와 두 공개 경로를 차례로 확인합니다. 로컬 확인만으로 다른 PC의 HTTPS 접속을 대신할 수 없습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
curl --fail --silent --show-error \&lt;br /&gt;
  &#039;http://localhost:8080/api.php?action=query&amp;amp;meta=siteinfo&amp;amp;format=json&#039; \&lt;br /&gt;
  &amp;gt;/dev/null&lt;br /&gt;
curl --fail --silent --show-error \&lt;br /&gt;
  &#039;http://127.0.0.1:8765/healthz&#039; \&lt;br /&gt;
  | jq --exit-status \&lt;br /&gt;
      &#039;.canonical_store == &amp;quot;ok&amp;quot; and&lt;br /&gt;
       .job_runner == &amp;quot;ok&amp;quot; and&lt;br /&gt;
       .write_admission.active &amp;gt;= 0 and .write_admission.waiting &amp;gt;= 0&#039; \&lt;br /&gt;
  &amp;gt;/dev/null&lt;br /&gt;
curl --fail --silent --show-error \&lt;br /&gt;
  &#039;https://s3wiki.yonsei.ac.kr/api.php?action=query&amp;amp;meta=siteinfo&amp;amp;format=json&#039; \&lt;br /&gt;
  &amp;gt;/dev/null&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
[[Help:S3 연구 메모리/설치와 검증#클라이언트를 연결하기 전 공용 MCP 확인|&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;setup.md&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;]]의 토큰 없는 MCP 초기화도 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;https://s3wiki.yonsei.ac.kr/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 실행합니다. 단순 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;GET /mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 streaming 연결을 계속 열어 둘 수 있으므로 상태 확인이 아닙니다.&lt;br /&gt;
&lt;br /&gt;
같은 서버에서 위 확인을 한 번에 실행하려면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;make verify-domain&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 사용합니다. 이 결과만으로 학교 밖 인바운드 경로까지 확인되지는 않습니다.&lt;br /&gt;
&lt;br /&gt;
Compose 운영의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/healthz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 canonical store와 bounded job runner가 모두 준비돼야 200을 반환합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;job_runner=unavailable&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이면 공유 상태 파일의 generation·최근 성공 시각·권한을 먼저 확인합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;write_admission&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에는 비밀값 없이 현재 active·waiting·principal 수와 동시 실행·queue 상한이 나옵니다. 기본은 전체 동시 쓰기 8개, queue 64개·2초, principal별 지속 600회/분·burst 120입니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;open&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드에서는 client IP가 principal이므로 인증 모드를 바꾸지 않아도 사용자별 admission이 적용됩니다. 지표가 계속 상한에 붙거나 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;server_busy&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 반복되면 DB latency와 실패 요청을 먼저 확인하고, 변경한 상한은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 다시 만들어 적용합니다. 이 queue는 재시작 뒤 작업을 복구하는 durable queue가 아닙니다. 전달된 IP는 Caddy와 MCP의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_PROXY_SECRET&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 맞을 때만 신뢰합니다. 이 값을 교체할 때는 둘을 같은 Compose 재생성에서 함께 전환합니다.&lt;br /&gt;
&lt;br /&gt;
MediaWiki job queue는 전용 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-jobs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 서비스가 기본 25건·20초, 1 process로 처리합니다. 상태 파일은 비밀 값이 없고 현재 queue 깊이, 마지막 성공 시각, 연속 실패 수, batch/idle 상한과 현재 runner generation을 담습니다. generation은 컨테이너 PID 1의 시작 시각에서 만들므로 이전 컨테이너나 복구 전 상태 파일은 새 runner health로 인정되지 않습니다. 상태 볼륨은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-job-state-init&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 UID/GID 33으로 준비하고 MCP에는 읽기 전용으로만 연결합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose ps mediawiki-jobs&lt;br /&gt;
docker compose ps -a mediawiki-job-state-init&lt;br /&gt;
docker compose exec mediawiki-jobs cat /run/s3rm-jobs/status.env&lt;br /&gt;
docker compose exec mediawiki-jobs /usr/local/bin/s3-mediawiki-jobs-health&lt;br /&gt;
docker compose exec mediawiki \&lt;br /&gt;
  php maintenance/run.php showJobs --group&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
runner가 실패하면 15초에서 시작해 최대 5분까지 지수 backoff합니다. 마지막 성공이 기본 120초보다 오래되면 health가 실패합니다. queue를 빠르게 비우기 위해 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--nothrottle&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이나 다중 process를 임의로 사용하지 마세요. 먼저 실패 job 종류와 DB·디스크 상태를 확인하고, 필요한 처리량만 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 조정합니다.&lt;br /&gt;
&lt;br /&gt;
Compose는 각 주요 서비스의 json-file 로그를 파일당 10MB, 최대 5개로 회전하고, PID·메모리·CPU 상한을 둡니다. 평소 사용량과 OOM·재시작 여부를 함께 확인합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker stats --no-stream&lt;br /&gt;
docker inspect --format \&lt;br /&gt;
  &#039;{{.Name}} oom={{.State.OOMKilled}} restarts={{.RestartCount}}&#039; \&lt;br /&gt;
  $(docker compose ps -q)&lt;br /&gt;
docker system df&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
상한 도달은 health check 성공만으로 보이지 않을 수 있습니다. OOM이나 PID 부족을 확인한 뒤 queue·동시성·실패 loop 원인을 먼저 고치고, 측정한 정상 peak와 호스트 여유를 근거로 상한을 조정합니다. 로그 회전 파일은 장기 감사 자료가 아니므로 필요한 운영 event는 별도 제한된 저장소에 보관합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Special:RecentChanges&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 계정 그룹, 로그인 실패, MCP 쓰기 인증 실패와 403/421, 디스크 여유 공간, 백업 시각, 복원 시험 상태를 확인합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 관리하는 스키마·양식 페이지가 반복해서 달라지면 원인을 찾으세요. 일반 Lesson revision은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 바꾸지 않습니다.&lt;br /&gt;
&lt;br /&gt;
=== 공용 MCP 운영 ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에는 영구 지식이 없습니다. 정지하거나 다시 만들면 진행 중인 도구 호출이 끊기고 클라이언트가 재연결해야 하지만 Lesson이나 revision은 없어지지 않습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose stop mcp-http&lt;br /&gt;
docker compose up -d mcp-http&lt;br /&gt;
docker compose up -d --force-recreate mcp-http&lt;br /&gt;
docker compose ps mcp-http&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
일시적인 프로세스 오류에는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;restart&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 사용합니다. 토큰 해시, 공개 URL, bind·port, allowlist, 위키 자격 증명, 이미지를 바꿨다면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--force-recreate&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 사용합니다. 공개 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 복제본을 여러 개 띄우지 마세요. 현재 서비스에는 load balancer나 session 공유 설계가 없습니다. 읽기 요청은 재연결 뒤 다시 시도해도 됩니다. 시간이 초과된 변경 요청은 결과를 먼저 확인한 뒤 다시 보내세요.&lt;br /&gt;
&lt;br /&gt;
=== 공개 HTTPS ===&lt;br /&gt;
&lt;br /&gt;
Caddy는 Compose &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;domain&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 프로필에서 실행하며 공개 포트를 사용합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose ps caddy&lt;br /&gt;
docker compose logs --tail=200 caddy&lt;br /&gt;
docker compose up -d --force-recreate caddy&lt;br /&gt;
make verify-domain&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_PUBLIC_HOST&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;나 저장소의 Caddyfile을 바꾼 뒤 Caddy를 다시 만듭니다. 인증서 발급을 다시 시도하려고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;caddy_data&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 지우지 마세요. 먼저 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;s3wiki.yonsei.ac.kr&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;165.132.118.220&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 가리키는지, 다른 프로세스가 80/443을 쓰는지, 두 포트가 외부에서 서버까지 들어오는지 확인합니다. Caddy는 원래 Host 헤더를 보존하며 MCP는 이를 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_PUBLIC_URL=https://s3wiki.yonsei.ac.kr/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 대조합니다. 학교 밖 네트워크의 브라우저 확인도 별도로 해야 합니다.&lt;br /&gt;
&lt;br /&gt;
==== 공용 MCP 토큰 교체 ====&lt;br /&gt;
&lt;br /&gt;
이 파일럿은 토큰 해시 하나만 받으므로 교체 시 모든 쓰기 클라이언트의 기존 토큰이 한 번에 무효가 됩니다. 공개 조회는 계속됩니다. 변경이 잠시 끊긴다고 알리고 새 서비스 확인이 끝날 때까지 기존 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;client.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 보관하세요.&lt;br /&gt;
&lt;br /&gt;
# 원문을 출력하지 않고 새 토큰과 해시를 만듭니다. ```bash ./scripts/configure-mcp-token.sh --rotate ``` 스크립트는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 해시만 바꾸고, 원문 토큰과 URL은 mode &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;0600&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;~/.config/s3-research-memory/client.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 씁니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--rotate&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 없으면 기존 토큰과 해시가 맞는지 확인하고 그대로 둡니다.&lt;br /&gt;
# 공용 어댑터만 다시 만듭니다. ```bash docker compose up -d --force-recreate mcp-http docker compose ps mcp-http docker compose logs --tail=100 mcp-http ```&lt;br /&gt;
# 토큰 없이 초기화, 도구 목록, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;search_lessons(limit=1)&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 확인합니다. 이어서 신뢰하는 셸에서 새 비공개 파일을 불러오고 [[Help:S3 연구 메모리/설치와 검증|&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;setup.md&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;]]의 쓰기 확인을 실행합니다.&lt;br /&gt;
# 새 원문 토큰 또는 클라이언트 환경을 쓰기 클라이언트에 전달하고 재연결합니다. 공개 조회 클라이언트는 바꿀 것이 없습니다. DB·위키 비밀번호와 쓸 수 없는 토큰 해시가 든 서버 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 전달하지 마세요.&lt;br /&gt;
새 서비스를 확인하기 전에 문제가 생겼다면 이전 해시와 그에 맞는 비공개 토큰을 복구하고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 다시 만듭니다. 변경 인증을 끄는 방식으로 되돌리지 마세요.&lt;br /&gt;
&lt;br /&gt;
=== 애플리케이션 업데이트와 되돌리기 ===&lt;br /&gt;
&lt;br /&gt;
업데이트 전에 이름을 붙인 백업을 만들고 복원을 시험합니다. 새 이미지를 빌드하고 DB 마이그레이션과 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 완료한 뒤 복구 완료 항목을 확인합니다. 새 release가 DB를 마이그레이션했다면 이미지만 낮춰서는 안 됩니다. 이전 저장소 revision과 함께 업데이트 전 DB, 업로드, 설정을 한 세트로 복원해야 합니다.&lt;br /&gt;
&lt;br /&gt;
기본 제약은 MediaWiki [https://www.mediawiki.org/wiki/Manual:Restoring_a_wiki_from_backup 복원 안내]와 [https://www.mediawiki.org/wiki/Manual:Upgrading 업그레이드 안내]를 참고하세요.&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC/%ED%8C%8C%EC%9D%BC%EB%9F%BF_%EA%B0%80%EC%A0%95&amp;diff=1729</id>
		<title>도움말:S3 연구 메모리/파일럿 가정</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC/%ED%8C%8C%EC%9D%BC%EB%9F%BF_%EA%B0%80%EC%A0%95&amp;diff=1729"/>
		<updated>2026-07-18T10:09:47Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 저장소 문서 동기화&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;!-- S3RM managed repository document; edit the Markdown source, not this page. --&amp;gt;&lt;br /&gt;
&#039;&#039;이 페이지는 저장소 문서에서 자동으로 동기화됩니다. 위키에서 직접 편집하지 마세요.&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
* 2026-07-15에 받은 작업 공간은 비어 있었습니다. Git 메타데이터, README, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;AGENTS.md&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 소스 코드, 배포 규칙이 없었으므로 요청한 디렉터리에 새 구조를 만들었습니다.&lt;br /&gt;
* 신뢰할 수 있는 단일 연구실, 단일 노드용 파일럿입니다. 고가용성 또는 적대적인 다중 테넌트 환경을 대상으로 하지 않습니다. 모든 에이전트는 중앙 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; Streamable HTTP 어댑터 한 개를 공유하며, 기준 저장소는 Semantic MediaWiki 한 곳뿐입니다. 클라이언트별 어댑터나 별도 Lesson 데이터베이스는 두지 않습니다.&lt;br /&gt;
* 모든 클라이언트는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;https://s3wiki.yonsei.ac.kr/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 한 곳에 연결합니다. 검색과 변경 도구 모두 이 엔드포인트를 사용합니다. 쓰기 인증은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;open&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;token&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;github&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 운영 모드 중 하나를 사용합니다. 각 컴퓨터에 둔 별도 엔드포인트나 로컬 사본은 협업 구조에 포함하지 않습니다.&lt;br /&gt;
* 브라우저의 MediaWiki 페이지와 MCP 검색은 공개합니다. 브라우저 편집에는 이름이 있는 계정이 필요하고, MCP의 다섯 변경 도구에는 같은 공유 Bearer 토큰이 필요합니다. 비공개가 필요한 내용은 기본 사용 범위에 넣지 않습니다.&lt;br /&gt;
* 기본 배포에서는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;s3wiki.yonsei.ac.kr&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 Caddy가 HTTPS를 처리하고 MCP의 직접 연결 포트는 loopback에 묶습니다. 8765번은 진단용이며 클라이언트가 연결할 대체 엔드포인트가 아닙니다. 서버 담당자가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_WIKI_DIRECT_URL&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 명시하면 8080에는 브라우저용 직접 HTTP 입구를 함께 둘 수 있습니다. 이 입구는 같은 MediaWiki를 사용하며 MCP 주소나 별도 저장소가 아닙니다. 호스팅 서비스의 쓰기나 개인별 신원 확인이 필요할 때만 OAuth, 기관 SSO, MFA를 추가합니다.&lt;br /&gt;
* 파일럿은 중앙 MCP 쓰기 토큰의 다이제스트 한 개와 최소 권한 MediaWiki 에이전트 계정 한 개를 사용합니다. 여러 클라이언트가 함께 쓸 수 있지만 리비전의 작성자는 개별 클라이언트나 사람이 아닌 서비스 계정으로 기록됩니다. 이 토큰은 개인별로 폐기하거나 만료할 수 없으며 사용자를 확인해 기록하지도 않습니다. MediaWiki 리비전에는 서비스 계정이 무엇을 언제 바꿨는지가 남습니다. 이 구분이 필요해지면 사용자별 OAuth/OIDC를 추가할 수 있습니다. 읽기 전용 클라이언트에는 토큰이 필요하지 않습니다.&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 Lesson 상태를 저장하지 않으므로 다시 만들 수 있습니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;github&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드의 OAuth client와 token 상태도 현재는 프로세스 메모리에만 있어 재시작하면 다시 인증해야 합니다. 중단되어도 저장된 지식은 남습니다. 복구 대상은 MariaDB, 업로드, MediaWiki 설정입니다.&lt;br /&gt;
* Caddy의 인증서 및 실행 상태는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;caddy_data&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;caddy_config&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 저장합니다. 일반 재시작에서는 두 볼륨을 유지하지만 기준 Lesson 복구 대상에는 넣지 않습니다. 인증서는 DNS와 저장소의 Caddyfile을 사용해 다시 발급할 수 있습니다.&lt;br /&gt;
* 새로 설치한 위키에는 Lesson이 없습니다. 브라우저나 중앙 MCP에서 첫 메모를 저장하기 전까지 검색 결과와 최근 페이지 목록이 비어 있는 것은 정상입니다.&lt;br /&gt;
* 백업 및 복구 자동화는 Bash 4+와 GNU coreutils/tar를 갖춘 GNU/Linux Docker 호스트를 대상으로 합니다. 화면 확인에는 Docker Desktop을 쓸 수 있지만, 복구 작업은 Linux VM이나 이에 준하는 Linux 호스트에서 실행합니다.&lt;br /&gt;
* MediaWiki 페이지 리비전에는 변경 출처와 이력이 남으며 이전 내용으로 되돌릴 수 있습니다. 스팸이나 잘못된 지식의 입력을 막지는 않습니다. 파일럿에는 공식 전자 서명이나 변경 불가 규정 준수 저장소가 없습니다.&lt;br /&gt;
* 시각은 UTC ISO-8601 문자열로 저장합니다. 브라우저에 표시할 시간대는 따로 설정할 수 있습니다.&lt;br /&gt;
* 근거 바이너리와 원시 데이터셋은 Lesson 밖에 둡니다. 위키에는 짧은 인용, URL, 체크섬, 설명 메모를 저장합니다. 작은 보조 파일은 일반 MediaWiki 업로드 기능으로 올릴 수 있습니다.&lt;br /&gt;
* 처음에는 MediaWiki의 MariaDB 전체 텍스트 검색을 사용합니다. MCP는 페이지명이 아니라 Lesson 본문을 찾도록 검색 범위를 명시합니다. 띄어쓰기와 정확한 단어에 기반하므로 의미가 비슷한 다른 표현까지 찾지는 않습니다. 벡터 인덱스는 선택 사항이며 기준 쓰기 경로 밖에서 만드는 교체 가능한 파생 데이터입니다.&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Lesson:&amp;amp;lt;slug&amp;amp;gt;&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 고정 외부 식별자로 사용합니다. MCP 인용에 이전 ID가 남을 수 있으므로 이 페이지의 이름은 가급적 바꾸지 말고, 필요한 경우 먼저 조율합니다. MediaWiki 리디렉션은 계속 작동합니다.&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC/%EB%B2%A1%ED%84%B0%C2%B7%ED%95%98%EC%9D%B4%EB%B8%8C%EB%A6%AC%EB%93%9C_%EA%B2%80%EC%83%89_%EC%84%A4%EA%B3%84&amp;diff=1728</id>
		<title>도움말:S3 연구 메모리/벡터·하이브리드 검색 설계</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC/%EB%B2%A1%ED%84%B0%C2%B7%ED%95%98%EC%9D%B4%EB%B8%8C%EB%A6%AC%EB%93%9C_%EA%B2%80%EC%83%89_%EC%84%A4%EA%B3%84&amp;diff=1728"/>
		<updated>2026-07-18T10:09:47Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 저장소 문서 동기화&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;!-- S3RM managed repository document; edit the Markdown source, not this page. --&amp;gt;&lt;br /&gt;
&#039;&#039;이 페이지는 저장소 문서에서 자동으로 동기화됩니다. 위키에서 직접 편집하지 마세요.&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
=== 문서 상태 ===&lt;br /&gt;
&lt;br /&gt;
이 문서는 구현된 1단계 검색 계층과 이후 확장 경계를 함께 설명합니다. 현재 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에는 교체 가능한 임베딩 제공자, 세 가지 facet projection, copy-on-write exact vector index와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;legacy&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;·&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;hybrid_v1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; mode가 들어 있습니다. 기본 mode와 기본 provider는 계속 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;legacy&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;disabled&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;입니다. 선택적 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;embedding&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; Compose profile에는 이 서버의 Vega GPU에서 확인한 Qwen3 F16 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;llama.cpp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; Vulkan 서비스와 검증 downloader가 들어 있습니다.&lt;br /&gt;
&lt;br /&gt;
recent changes poll, 지속형 checkpoint·tombstone, 별도 sidecar, 여러 MCP 인스턴스가 공유하는 generation, cursor v2와 자동 품질 cutover는 아직 후속 단계입니다. 아래 설계에서 이 항목은 구현 목표로 읽습니다.&lt;br /&gt;
&lt;br /&gt;
=== 결정 요약 ===&lt;br /&gt;
&lt;br /&gt;
* Semantic MediaWiki를 유일한 기준 저장소로 유지합니다.&lt;br /&gt;
* 벡터 인덱스는 후보 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Lesson:&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; ID만 반환하는 파생 데이터로 둡니다.&lt;br /&gt;
* 최종 결과는 항상 MediaWiki 최신 리비전을 다시 읽어 만듭니다.&lt;br /&gt;
* 외부 MCP 도구 열 개와 중앙 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 진입점은 그대로 유지합니다.&lt;br /&gt;
* 전체 텍스트, 의미 유사도, 명시적 relation을 각각 찾은 뒤 결정적인 규칙으로 합칩니다.&lt;br /&gt;
* 1단계에서는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 프로세스 안에서 exact vector search를 사용합니다.&lt;br /&gt;
* 재구축 시간, 메모리 또는 지연 시간이 목표를 넘을 때만 내부 검색 서비스로 옮깁니다.&lt;br /&gt;
* 임베딩 모델, 입력 구성 또는 거리 계산이 바뀌면 새 인덱스 세대를 만듭니다.&lt;br /&gt;
* 의미 검색이 실패해도 전체 텍스트와 relation 검색은 계속 제공합니다.&lt;br /&gt;
벡터 저장 제품은 이 경계를 구현하는 선택 사항입니다. 검색 품질과 운영 규모를 확인하기 전에 제품별 API가 도메인 계층으로 퍼지지 않게 합니다.&lt;br /&gt;
&lt;br /&gt;
=== 1단계 구현 범위 ===&lt;br /&gt;
&lt;br /&gt;
현재 exact index는 다음처럼 제한해서 구현했습니다.&lt;br /&gt;
&lt;br /&gt;
* 서비스 시작 시 단 하나의 백그라운드 작업으로 전체 Lesson snapshot을 읽어 프리웜합니다. MCP Lesson 저장의 dirty hint는 즉시 같은 단일 갱신을 시작합니다. 설정 간격이 지나면 다음 hybrid 요청이 전체 대조를 시작하며, 어느 요청도 refresh 완료를 기다리지 않습니다. 별도 주기 poll은 없습니다.&lt;br /&gt;
* 전체 embedding이 성공했을 때만 새 snapshot을 원자적으로 교체합니다. 한 Lesson만 실패해 성공 항목을 부분 반영하는 재시도 큐는 아직 없습니다.&lt;br /&gt;
* 프로세스마다 active generation 하나를 메모리에 둡니다. 재시작하면 MediaWiki에서 시작한 백그라운드 작업으로 다시 만듭니다.&lt;br /&gt;
* relation, lexical과 semantic 순위를 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;rrf_v1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 결합합니다. 후보 사이의 별도 diversity reranking은 아직 적용하지 않습니다.&lt;br /&gt;
* 기존 v1 cursor envelope를 유지하고 mode, ranking version과 generation을 fingerprint에 포함합니다.&lt;br /&gt;
recent changes checkpoint, tombstone, 항목별 재시도, active/rollback generation, cursor v2와 sidecar는 아래에 설명한 확장 단계에서 추가합니다.&lt;br /&gt;
&lt;br /&gt;
=== 목표 ===&lt;br /&gt;
&lt;br /&gt;
다음 문제를 해결합니다.&lt;br /&gt;
&lt;br /&gt;
# 표현이 달라도 증상과 제약이 비슷한 Lesson을 찾습니다.&lt;br /&gt;
# 비슷한 문제에 쓰인 서로 다른 해결 원리를 함께 보여 줍니다.&lt;br /&gt;
# 한국어, 영어와 두 언어가 섞인 메모를 같은 검색 흐름에서 다룹니다.&lt;br /&gt;
# 새 Lesson은 기존 전체 텍스트 검색으로 바로 찾을 수 있게 합니다.&lt;br /&gt;
# 임베딩 제공자와 인덱스 구현을 중단 없이 바꿀 수 있게 합니다.&lt;br /&gt;
# 여러 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 인스턴스로 확장해도 같은 검색 세대를 사용하게 합니다.&lt;br /&gt;
다음 기능은 이 설계의 범위에 넣지 않습니다.&lt;br /&gt;
&lt;br /&gt;
* 벡터 유사도를 사실, 인과관계 또는 relation으로 자동 저장&lt;br /&gt;
* 별도 Lesson 데이터베이스 또는 사용자별 지식 사본&lt;br /&gt;
* 벡터 저장 제품의 범용 API를 MCP 도구로 공개&lt;br /&gt;
* 검색 결과를 근거 없이 요약하거나 해결책으로 확정&lt;br /&gt;
* 다중 테넌트 저장소와 사용자별 비공개 인덱스&lt;br /&gt;
=== 전체 구조 ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
flowchart LR&lt;br /&gt;
    C[&amp;quot;MCP 클라이언트&amp;quot;] --&amp;gt; M[&amp;quot;중앙 mcp-http&amp;quot;]&lt;br /&gt;
    M --&amp;gt; R[&amp;quot;검색 조정 계층&amp;quot;]&lt;br /&gt;
    R --&amp;gt; L[&amp;quot;MediaWiki 전체 텍스트 후보&amp;quot;]&lt;br /&gt;
    R --&amp;gt; G[&amp;quot;MediaWiki relation 후보&amp;quot;]&lt;br /&gt;
    R --&amp;gt; V[&amp;quot;의미 검색 후보&amp;quot;]&lt;br /&gt;
    L --&amp;gt; F[&amp;quot;후보 결합과 다양성 정렬&amp;quot;]&lt;br /&gt;
    G --&amp;gt; F&lt;br /&gt;
    V --&amp;gt; F&lt;br /&gt;
    F --&amp;gt; H[&amp;quot;MediaWiki 최신 리비전 일괄 조회&amp;quot;]&lt;br /&gt;
    H --&amp;gt; O[&amp;quot;필터·페이지 나누기·출력 제한&amp;quot;]&lt;br /&gt;
&lt;br /&gt;
    W[&amp;quot;Semantic MediaWiki 리비전&amp;quot;] --&amp;gt; I[&amp;quot;비공개 색인기&amp;quot;]&lt;br /&gt;
    I --&amp;gt; P[&amp;quot;버전이 있는 입력 구성&amp;quot;]&lt;br /&gt;
    P --&amp;gt; E[&amp;quot;교체 가능한 임베딩 제공자&amp;quot;]&lt;br /&gt;
    E --&amp;gt; X[&amp;quot;세대별 파생 벡터 인덱스&amp;quot;]&lt;br /&gt;
    X --&amp;gt; V&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
읽기 경로와 색인 경로를 분리합니다. 검색 요청이 벡터 인덱스를 직접 읽더라도 최종 Lesson 본문과 필터 판정은 MediaWiki에서 가져옵니다. 색인 실패는 위키 쓰기를 되돌리지 않습니다.&lt;br /&gt;
&lt;br /&gt;
=== 내부 경계 ===&lt;br /&gt;
&lt;br /&gt;
현재 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;WikiAdapter&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에는 기준 저장소 작업과 후보 검색이 함께 있습니다. 동작을 바꾸기 전에 다음 경계로 나눕니다.&lt;br /&gt;
&lt;br /&gt;
==== Lesson 저장소 ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;LessonRepository&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 MediaWiki 읽기와 허용된 쓰기만 담당합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
@dataclass(frozen=True, slots=True)&lt;br /&gt;
class LessonSnapshot:&lt;br /&gt;
    lesson: Lesson&lt;br /&gt;
    revision_id: int&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, slots=True)&lt;br /&gt;
class SnapshotBatch:&lt;br /&gt;
    snapshots: tuple[LessonSnapshot, ...]&lt;br /&gt;
    next_cursor: str | None&lt;br /&gt;
    partial: bool&lt;br /&gt;
    scanned: int&lt;br /&gt;
    scan_limit: int&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, slots=True)&lt;br /&gt;
class RelationEdgeBatch:&lt;br /&gt;
    edges: tuple[LessonRelation, ...]&lt;br /&gt;
    next_cursor: str | None&lt;br /&gt;
    partial: bool&lt;br /&gt;
    scanned: int&lt;br /&gt;
    scan_limit: int&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, slots=True)&lt;br /&gt;
class RankedLessonId:&lt;br /&gt;
    lesson_id: str&lt;br /&gt;
    rank: int&lt;br /&gt;
    source_score: float | None&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, slots=True)&lt;br /&gt;
class RankedLessonIdBatch:&lt;br /&gt;
    items: tuple[RankedLessonId, ...]&lt;br /&gt;
    next_cursor: str | None&lt;br /&gt;
    partial: bool&lt;br /&gt;
    scanned: int&lt;br /&gt;
    scan_limit: int&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class LessonRepository(Protocol):&lt;br /&gt;
    async def get_snapshot(&lt;br /&gt;
        self, lesson_id: str&lt;br /&gt;
    ) -&amp;gt; LessonSnapshot | None: ...&lt;br /&gt;
&lt;br /&gt;
    async def get_snapshots(&lt;br /&gt;
        self, lesson_ids: Sequence[str]&lt;br /&gt;
    ) -&amp;gt; dict[str, LessonSnapshot]: ...&lt;br /&gt;
&lt;br /&gt;
    async def scan_snapshots(&lt;br /&gt;
        self, *, limit: int, cursor: str | None&lt;br /&gt;
    ) -&amp;gt; SnapshotBatch: ...&lt;br /&gt;
&lt;br /&gt;
    async def search_full_text(&lt;br /&gt;
        self, *, query: str, limit: int, cursor: str | None&lt;br /&gt;
    ) -&amp;gt; RankedLessonIdBatch: ...&lt;br /&gt;
&lt;br /&gt;
    async def find_relation_edges(&lt;br /&gt;
        self,&lt;br /&gt;
        *,&lt;br /&gt;
        lesson_id: str,&lt;br /&gt;
        relations: Sequence[RelationType],&lt;br /&gt;
        direction: RelationDirection,&lt;br /&gt;
        limit: int,&lt;br /&gt;
        cursor: str | None,&lt;br /&gt;
    ) -&amp;gt; RelationEdgeBatch: ...&lt;br /&gt;
&lt;br /&gt;
    async def create_lesson(self, lesson: Lesson) -&amp;gt; Lesson: ...&lt;br /&gt;
    async def add_relation(&lt;br /&gt;
        self, relation: LessonRelation&lt;br /&gt;
    ) -&amp;gt; LessonRelation: ...&lt;br /&gt;
    async def add_evidence(&lt;br /&gt;
        self, lesson_id: str, evidence: Evidence&lt;br /&gt;
    ) -&amp;gt; Evidence: ...&lt;br /&gt;
&lt;br /&gt;
    async def revise_lesson(&lt;br /&gt;
        self,&lt;br /&gt;
        *,&lt;br /&gt;
        lesson_id: str,&lt;br /&gt;
        expected_revision_id: int,&lt;br /&gt;
        operation_id: str,&lt;br /&gt;
        changes: Mapping[str, object],&lt;br /&gt;
        basis_evidence_ids: Sequence[str],&lt;br /&gt;
    ) -&amp;gt; LessonSnapshot: ...&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;get_snapshots&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 후보 ID를 최대 50개씩 읽으며 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;audit_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;도 이 경계를 사용합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;scan_snapshots&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 전체 대조와 빈 query 목록에 쓰는 제한된 cursor scan입니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;search_full_text&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 MediaWiki의 ranked 전체 텍스트 후보를 반환합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;find_relation_edges&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 전체 Lesson을 읽지 않고 Semantic MediaWiki의 typed relation에서 들어오고 나가는 간선을 찾습니다. 모든 목록 응답에는 엄격한 상한과 cursor를 둡니다.&lt;br /&gt;
&lt;br /&gt;
snapshot은 MediaWiki 사실만 담습니다. 임베딩 대상 필드의 해시는 해당 generation의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;ProjectionBuilder&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 계산합니다. 그래야 active와 rollback generation이 서로 다른 입력 구성을 써도 같은 snapshot을 안전하게 검증할 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
==== 후보 검색 ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;RetrievalBackend&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 Lesson 본문이 아니라 작은 후보 레코드만 반환합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
@dataclass(frozen=True, slots=True)&lt;br /&gt;
class RankContribution:&lt;br /&gt;
    channel: str&lt;br /&gt;
    rank: int&lt;br /&gt;
    source_score: float | None&lt;br /&gt;
    indexed_revision_id: int | None&lt;br /&gt;
    indexed_retrieval_hash: str | None&lt;br /&gt;
    matched_facets: tuple[str, ...]&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, slots=True)&lt;br /&gt;
class RetrievalCandidate:&lt;br /&gt;
    lesson_id: str&lt;br /&gt;
    contributions: tuple[RankContribution, ...]&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, slots=True)&lt;br /&gt;
class RetrievalRequest:&lt;br /&gt;
    operation: str&lt;br /&gt;
    query: str | None&lt;br /&gt;
    source_projection: ProjectionDocument | None&lt;br /&gt;
    source_revision_id: int | None&lt;br /&gt;
    filter_hints: SearchFilters&lt;br /&gt;
    scan_limit: int&lt;br /&gt;
    expected_generation: str | None&lt;br /&gt;
    ranking_version: str&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
@dataclass(frozen=True, slots=True)&lt;br /&gt;
class RetrievalBatch:&lt;br /&gt;
    candidates: tuple[RetrievalCandidate, ...]&lt;br /&gt;
    generation: str&lt;br /&gt;
    partial: bool&lt;br /&gt;
    scanned: int&lt;br /&gt;
    scan_limit: int&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
class RetrievalBackend(Protocol):&lt;br /&gt;
    async def retrieve(&lt;br /&gt;
        self, request: RetrievalRequest&lt;br /&gt;
    ) -&amp;gt; RetrievalBatch: ...&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
같은 ID가 여러 경로와 facet에서 나올 수 있으므로 각 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;RankContribution&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 유지합니다. 제품별 client, collection 이름과 거리 계산 방식은 backend 구현 안에 둡니다. 검색 조정 계층은 경로별 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;rank&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 기준으로 합치며 raw cosine 값을 서로 직접 비교하지 않습니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;source_score&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 한 후보 경로 안의 진단과 기준값 적용에만 씁니다. canonical 재검증 뒤 유효한 contribution만 다시 합쳐 전역 순서와 서비스 점수를 만듭니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;RetrievalRequest.source_projection&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;find_analogies&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서만 사용합니다. query, source revision, filters, ranking version과 generation 기대값을 cursor fingerprint에 묶습니다. 공개 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;min_score&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 candidate source에 전달하지 않고 canonical 재검증과 fusion이 끝난 서비스 점수에 적용합니다. 각 channel에 사전 기준값이 필요하면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;ranking_version&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 묶인 내부 설정으로 따로 보정합니다.&lt;br /&gt;
&lt;br /&gt;
여기서 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;RetrievalBackend&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 전체 텍스트, relation과 semantic source를 합치는 도메인 전용 composite입니다. 벡터 저장 제품은 그 아래의 비공개 semantic candidate source만 구현합니다.&lt;br /&gt;
&lt;br /&gt;
==== 입력 구성과 임베딩 ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;ProjectionBuilder&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 같은 Lesson에서 검색 목적별 텍스트를 결정적으로 만듭니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;EmbeddingProvider&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 텍스트 묶음을 벡터로 바꿉니다. 두 경계에는 각각 독립된 버전을 둡니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;ProjectionDocument&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;lesson_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, projection version, facet 텍스트, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;retrieval_hash&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 잘림 상태를 넣습니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;retrieval_hash&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 repository가 아니라 builder가 계산합니다.&lt;br /&gt;
&lt;br /&gt;
임베딩 제공자는 다음 정보를 고정해 보고해야 합니다.&lt;br /&gt;
&lt;br /&gt;
* 모델 식별자와 artifact digest&lt;br /&gt;
* tokenizer 식별자와 digest&lt;br /&gt;
* query와 document encoding mode 또는 prefix&lt;br /&gt;
* 벡터 차원&lt;br /&gt;
* 정규화 여부&lt;br /&gt;
* 거리 계산 방식&lt;br /&gt;
* 한 입력의 최대 token 수&lt;br /&gt;
외부 API, 서버 안의 모델 또는 별도 내부 추론 서비스는 같은 인터페이스를 구현할 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
현재 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;s3rm-qwen3-retrieval-v1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; profile은 다음을 하나의 generation 입력으로 묶습니다.&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Qwen/Qwen3-Embedding-0.6B&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; artifact SHA-256과 1024차원&lt;br /&gt;
* last pooling과 16K token 입력 계약&lt;br /&gt;
* 희귀 CJK byte fallback을 포함해 16K 안에 두는 document facet 4,000자 상한&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;situation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;approach&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mechanism&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;별 영문 query instruction&lt;br /&gt;
* instruction을 붙이지 않는 document 형식&lt;br /&gt;
* provider의 입력 개수·문자 수 상한&lt;br /&gt;
HTTP endpoint와 인증은 transport 설정이며 profile의 의미 계약과 분리합니다. 모델 파일, pooling, instruction 또는 차원을 바꾸면 profile generation이 달라져 전체 exact index를 새로 만듭니다. 서로 다른 벡터 공간은 섞지 않습니다. 외부 provider로 자동 failover하지 않으며 사용할 snapshot이 없으면 legacy 검색으로 대체합니다.&lt;br /&gt;
&lt;br /&gt;
==== 색인 동기화 ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;IndexSynchronizer&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 MediaWiki 변경을 읽어 파생 인덱스를 갱신합니다. MCP에는 색인 관리 도구를 추가하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
* MCP 쓰기 성공 뒤 해당 Lesson을 갱신하라는 힌트를 보냅니다.&lt;br /&gt;
* 브라우저 편집을 찾기 위해 MediaWiki recent changes를 주기적으로 읽습니다.&lt;br /&gt;
* 누락된 이벤트를 복구하기 위해 전체 Lesson을 주기적으로 대조합니다.&lt;br /&gt;
* 체크포인트, 재시도 큐와 인덱스 세대는 파생 운영 상태로 취급합니다.&lt;br /&gt;
* 한 generation의 레코드 key는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;(generation, lesson_id)&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;입니다.&lt;br /&gt;
* 처리할 때마다 MediaWiki 최신 snapshot을 다시 읽습니다.&lt;br /&gt;
* incoming revision이 현재 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;indexed_revision_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;보다 새로울 때만 원자적으로 교체합니다.&lt;br /&gt;
* 같은 revision의 재시도는 같은 결과가 나와야 합니다.&lt;br /&gt;
* 삭제와 이동 tombstone도 MediaWiki change 순서와 같은 fencing 규칙을 사용합니다.&lt;br /&gt;
* 여러 색인기를 쓸 때는 lease와 fencing token으로 오래된 worker의 쓰기를 막습니다.&lt;br /&gt;
==== 호환 이행 ====&lt;br /&gt;
&lt;br /&gt;
첫 구조 변경은 검색 결과를 바꾸지 않습니다.&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;ResearchMemoryService&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;LessonRepository&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 선택적인 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;RetrievalBackend&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 받게 합니다.&lt;br /&gt;
* 검색 backend가 없으면 현재 동작을 감싼 legacy 구현을 사용합니다.&lt;br /&gt;
* 기존 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;WikiAdapter&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 검색 메서드는 in-memory adapter와 기존 호출자를 옮기는 동안 유지합니다.&lt;br /&gt;
* 일괄 snapshot 조회에는 단일 조회를 반복하는 기본 구현을 둘 수 있습니다.&lt;br /&gt;
* 외부 MCP 도구 이름, 입력과 응답은 검색 backend 교체 때문에 바꾸지 않습니다.&lt;br /&gt;
새 내부 경계와 legacy 구현의 결과가 같은지 domain-level 테스트로 확인한 뒤 의미 검색을 추가합니다.&lt;br /&gt;
&lt;br /&gt;
=== 검색용 표현 ===&lt;br /&gt;
&lt;br /&gt;
Lesson 전체를 한 벡터로 합치지 않습니다. 첫 입력 구성 버전은 세 가지 facet을 사용합니다.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! facet !! 포함하는 필드 !! 용도&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;situation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;question&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;context&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;observation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 비슷한 문제와 관찰 찾기&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;approach&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;attempt&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 이미 시도한 방법과 실패 형태 찾기&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mechanism&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;interpretation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;reusable_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;applicability&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 해결 원리와 적용 조건 찾기&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;title&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 전체 텍스트 검색에서 높은 가중치로 다룹니다. evidence 개요, 구조화된 인용, note, URL, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;verification_basis&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, append-only evidence 검증 기록, author, reviewer, timestamp와 relation은 임베딩하지 않습니다. 이 값은 검색 근거, 필터, 감사 또는 relation 후보 경로에서 따로 사용합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;metadata_only&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; evidence가 있다는 이유만으로 Lesson 전체를 제외하지 않습니다. 한 Lesson에 서로 다른 확인 범위의 근거가 함께 있을 수 있기 때문입니다.&lt;br /&gt;
&lt;br /&gt;
입력은 다음 규칙으로 만듭니다.&lt;br /&gt;
&lt;br /&gt;
# Unicode NFKC로 정규화하고 연속 공백을 하나로 줄입니다.&lt;br /&gt;
# 필드 이름과 필드 값을 고정된 순서로 붙입니다.&lt;br /&gt;
# MediaWiki 표시 문법은 versioned sanitizer로 제거하고 외부 URL은 가져오지 않습니다.&lt;br /&gt;
# query도 facet별 고정 prefix와 encoding mode로 만듭니다.&lt;br /&gt;
# 빈 필드는 허용하지 않는 현재 Lesson 계약을 그대로 사용합니다.&lt;br /&gt;
# 모델 입력 한도를 넘으면 facet별 token 예산에 따라 앞부분과 뒷부분을 보존합니다.&lt;br /&gt;
# 잘림 여부를 색인 상태에 기록합니다.&lt;br /&gt;
# 요약용 LLM은 색인 경로에 넣지 않습니다.&lt;br /&gt;
긴 입력이 자주 잘리기 시작하면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;ProjectionBuilder&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;만 바꿔 필드별 chunk embedding과 결정적인 pooling을 도입합니다. 이 변경도 새 입력 구성 버전과 새 인덱스 세대를 사용합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;retrieval_hash&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에는 입력 구성 버전과 정규화된 facet 텍스트를 넣습니다. 다음 값은 별도 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;metadata_hash&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 관리합니다.&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;confidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;record_origin&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;author&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;review_state&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;created_at&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;updated_at&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
* 구조화된 evidence 존재 여부&lt;br /&gt;
이렇게 하면 evidence 추가처럼 의미 본문이 바뀌지 않은 수정은 재임베딩하지 않고 필터 metadata만 갱신할 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
=== 인덱스 레코드 ===&lt;br /&gt;
&lt;br /&gt;
파생 인덱스에는 다음 값만 저장합니다.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 값 !! 설명&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;lesson_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || MediaWiki 페이지의 고정 외부 ID&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;indexed_revision_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 색인할 때 읽은 리비전 ID&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;retrieval_hash&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 임베딩 대상 내용의 해시&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;metadata_hash&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 필터 metadata의 해시&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;updated_at&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 지연과 최신성 확인용 시각&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;generation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 모델과 입력 구성의 세대&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;last_change_position&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 마지막으로 적용한 MediaWiki change 순서&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;tombstone&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 삭제·이동 뒤 오래된 upsert를 막는 파생 표시&lt;br /&gt;
|-&lt;br /&gt;
| filter scalar || 기존 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;SearchFilters&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 미리 적용하기 위한 값&lt;br /&gt;
|-&lt;br /&gt;
| facet vectors || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;situation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;approach&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mechanism&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 벡터&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
정규화된 본문, evidence 전문과 relation 목록은 저장하지 않습니다. 필터 scalar는 성능 최적화용 사본입니다. 최종 결과에는 MediaWiki에서 다시 읽은 필터를 적용합니다. 삭제된 Lesson의 vector는 제거하되 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;(generation, lesson_id)&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 tombstone과 마지막 change position은 파생 동기화 상태에 유지합니다. upsert와 delete는 이 값을 원자적으로 비교해 느린 worker가 오래된 vector를 되살리지 못하게 합니다.&lt;br /&gt;
&lt;br /&gt;
=== 후보 생성과 순위 ===&lt;br /&gt;
&lt;br /&gt;
==== &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;search_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; ====&lt;br /&gt;
&lt;br /&gt;
다음 후보 경로를 독립적으로 실행합니다.&lt;br /&gt;
&lt;br /&gt;
# MediaWiki 전체 텍스트 검색&lt;br /&gt;
# query와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;situation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; facet의 의미 유사도&lt;br /&gt;
# query와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;approach&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; facet의 의미 유사도&lt;br /&gt;
# query와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mechanism&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; facet의 의미 유사도&lt;br /&gt;
정확한 식별자, 제목과 오류 문자열은 전체 텍스트 결과가 놓치지 않게 합니다. 의미 검색은 같은 뜻을 다른 표현으로 쓴 후보를 보완합니다.&lt;br /&gt;
&lt;br /&gt;
query가 비어 있으면 임베딩을 호출하지 않습니다. 현재처럼 canonical Lesson 목록을 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;scan_snapshots&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 읽고 filters와 페이지 제한을 적용합니다.&lt;br /&gt;
&lt;br /&gt;
==== &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;find_analogies&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; ====&lt;br /&gt;
&lt;br /&gt;
다음 순서로 후보를 만듭니다.&lt;br /&gt;
&lt;br /&gt;
# 저장된 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;analogous_to&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; relation 후보&lt;br /&gt;
# source의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;situation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;과 비슷한 후보&lt;br /&gt;
# source의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mechanism&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;과 비슷한 교차 문제 후보&lt;br /&gt;
# 현재 Unicode 단어와 한국어 n-gram 후보&lt;br /&gt;
relation 후보는 명시적으로 저장된 연결이라는 점을 계속 구분합니다. 벡터 후보는 relation이 아니며 자동으로 저장하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
==== 결합과 다양성 ====&lt;br /&gt;
&lt;br /&gt;
백엔드마다 점수 분포가 다르므로 raw 점수를 더하지 않습니다. 각 후보 경로의 순위를 versioned reciprocal-rank fusion으로 합칩니다. 같은 해결 방식이 상위 결과를 차지하지 않도록 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;approach&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mechanism&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 벡터에 작은 다양성 가중치를 적용합니다.&lt;br /&gt;
&lt;br /&gt;
다양성은 관련성을 대체하지 않습니다. 먼저 적용 조건과 문제 유사도가 기준을 넘은 후보만 남기고, 그 안에서 서로 다른 해결 원리를 섞습니다. 정렬 규칙과 가중치는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;ranking_version&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 고정합니다.&lt;br /&gt;
&lt;br /&gt;
각 channel은 고정된 내부 후보 상한까지 먼저 수집합니다. 모든 channel의 contribution을 합친 뒤 preliminary fusion을 수행하고, 전역 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;scan_limit&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 안의 ID만 MediaWiki에서 재검증합니다. 다음 page를 만들기 위해 channel을 조금씩 더 읽는 방식은 쓰지 않습니다. 뒤늦게 온 contribution 때문에 이미 반환한 RRF 순서가 바뀌는 것을 막기 위해서입니다.&lt;br /&gt;
&lt;br /&gt;
어느 channel이 내부 상한에 닿으면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;partial=true&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 표시합니다. canonical 재검증과 diversity 정렬까지 끝난 bounded 결과 목록에 cursor offset을 적용합니다.&lt;br /&gt;
&lt;br /&gt;
현재 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;find_analogies&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 명시 relation에 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;score=1.0&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 주고 lexical 후보에는 Jaccard score와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;shared_terms&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 반환합니다. 이 의미를 조용히 바꾸지 않습니다.&lt;br /&gt;
&lt;br /&gt;
semantic 결과는 기존 도구의 versioned &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;retrieval_mode&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 공개합니다.&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;legacy&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 현재 점수, 순서와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;shared_terms&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 그대로 유지합니다.&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;hybrid_v1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 raw cosine이 아닌 0~1 서비스 점수와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;ranking_version&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 반환합니다.&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;hybrid_v1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;basis&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;semantic_similarity&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;hybrid&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 추가합니다.&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;min_score&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 어떤 점수에 적용되는지 mode별로 문서화합니다.&lt;br /&gt;
* 같은 호환 기간에는 기본 mode를 조용히 바꾸지 않습니다.&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;search_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에도 같은 mode를 선택적으로 추가했습니다. 입력과 출력 모델, 서버 schema, 예시와 MCP 문서는 같은 계약으로 관리합니다.&lt;br /&gt;
&lt;br /&gt;
=== 기준 저장소 재검증 ===&lt;br /&gt;
&lt;br /&gt;
후보를 받은 뒤 다음 순서로 확인합니다.&lt;br /&gt;
&lt;br /&gt;
# 후보 ID를 합치되 경로별 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;RankContribution&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 유지합니다.&lt;br /&gt;
# MediaWiki에서 최신 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;LessonSnapshot&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 일괄 조회합니다.&lt;br /&gt;
# 없어진 페이지는 결과에서 빼고 인덱스 정리 대상으로 표시합니다.&lt;br /&gt;
# 현재 generation의 builder로 최신 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;retrieval_hash&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 계산합니다.&lt;br /&gt;
# hash가 다른 semantic contribution만 제거하고 재색인합니다.&lt;br /&gt;
# lexical 또는 relation contribution이 남은 ID는 최신 Lesson으로 계속 평가합니다.&lt;br /&gt;
# 해시는 같고 리비전만 다르면 최신 Lesson을 사용합니다.&lt;br /&gt;
# 기존 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;SearchFilters&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 최신 Lesson에 다시 적용합니다.&lt;br /&gt;
# 유효한 contribution을 다시 결합해 안정적인 순서로 정렬합니다.&lt;br /&gt;
# 페이지와 UTF-8 출력 제한을 적용합니다.&lt;br /&gt;
인덱스 지연으로 오래된 Lesson 내용이 반환되지는 않게 합니다. 대신 일부 semantic 후보가 잠시 빠질 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;scanned&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 인덱스 내부 ANN node 수가 아니라 기준 저장소 재검증 전에 서비스가 검토한 후보 ID 수로 정의합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;scan_limit&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 모든 후보 경로를 합친 엄격한 상한입니다.&lt;br /&gt;
&lt;br /&gt;
기존 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;partial&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 후보 탐색 상한에 닿았다는 뜻을 유지합니다. semantic generation이 준비되지 않았거나 synchronizer checkpoint가 MediaWiki change watermark보다 뒤처졌다는 상태는 작은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;semantic_status&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 값으로 따로 표시합니다. 반환 후보의 hash 검사만으로는 아직 후보에 들어오지 않은 새 Lesson을 찾을 수 없기 때문입니다. 이 값은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;disabled&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;building&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;ready&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;stale&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;degraded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;unavailable&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 제한합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;ready&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 checkpoint가 허용 지연 안에 있고 미해결 missing point나 파생 오류 ID가 없을 때만 사용합니다. checkpoint는 따라잡았지만 일부 Lesson을 색인하지 못했으면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;degraded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 사용합니다. 이 필드를 활성화할 때 output 모델, MCP 문서와 테스트를 함께 바꿉니다.&lt;br /&gt;
&lt;br /&gt;
=== 인덱스 세대 ===&lt;br /&gt;
&lt;br /&gt;
다음 중 하나가 바뀌면 새 generation을 만듭니다.&lt;br /&gt;
&lt;br /&gt;
* 임베딩 모델 또는 artifact&lt;br /&gt;
* tokenizer&lt;br /&gt;
* 입력 정규화와 facet 구성&lt;br /&gt;
* token 예산 또는 chunk pooling&lt;br /&gt;
* 벡터 차원, 정규화 또는 거리 계산&lt;br /&gt;
* 순위 결합에 필요한 인덱스 구조&lt;br /&gt;
generation ID는 이 설정을 직렬화한 값의 불투명 해시로 만듭니다. 기존 인덱스를 제자리에서 덮어쓰지 않습니다.&lt;br /&gt;
&lt;br /&gt;
새 generation은 다음 순서로 활성화합니다.&lt;br /&gt;
&lt;br /&gt;
# &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;BUILDING&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 상태로 빈 generation을 만듭니다.&lt;br /&gt;
# MediaWiki recent changes의 high-water mark를 기록합니다.&lt;br /&gt;
# 변경 tail을 시작하고 전체 Lesson을 최대 50개씩 읽어 색인합니다.&lt;br /&gt;
# build 중 생긴 변경을 계속 적용합니다.&lt;br /&gt;
# 전환 직전 cutover watermark를 기록합니다.&lt;br /&gt;
# 새 generation의 checkpoint가 cutover watermark에 도달했는지 확인합니다.&lt;br /&gt;
# 누락, orphan, 리비전과 모델 구성을 검사합니다.&lt;br /&gt;
# recent changes 보존 구간에 gap이 있으면 활성화하지 않고 다시 구축합니다.&lt;br /&gt;
# shadow query로 현재 generation과 품질·지연 시간을 비교합니다.&lt;br /&gt;
# 검사를 통과하면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;READY&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 바꾸고 active alias를 원자적으로 전환합니다.&lt;br /&gt;
# 이전 generation은 cursor 유효 기간과 rollback 기간 동안 최신 상태로 유지합니다.&lt;br /&gt;
# rollback 기간이 끝나면 이전 generation을 삭제합니다.&lt;br /&gt;
새 generation에 문제가 있으면 active alias만 이전 값으로 되돌립니다. 파생 볼륨이 손상되면 MediaWiki 리비전에서 다시 만듭니다.&lt;br /&gt;
&lt;br /&gt;
generation은 모델, projection과 거리 계산 구성이 불변이라는 뜻입니다. generation 안의 Lesson 레코드는 revision 조건부 upsert로 계속 갱신합니다. 변경 tail은 alias 전환 뒤에도 멈추지 않으며 rollback 기간에는 active와 이전 generation을 함께 갱신합니다. 이전 generation을 조회할 수 있는 동안 해당 builder, tokenizer, embedding provider와 ranking 설정을 주소로 찾을 수 있게 유지합니다. 변경된 Lesson도 각 generation의 모델로 따로 임베딩합니다.&lt;br /&gt;
&lt;br /&gt;
파생 vector volume은 기준 백업 대상이 아닙니다. snapshot은 재시작 시간을 줄이는 선택 사항이며 generation, model digest와 MediaWiki checkpoint가 맞지 않으면 폐기하고 다시 구축합니다.&lt;br /&gt;
&lt;br /&gt;
=== 페이지 나누기와 cursor ===&lt;br /&gt;
&lt;br /&gt;
1단계 구현은 기존 v1 cursor envelope를 유지하고, 요청 fingerprint 안에 query, source revision·projection hash, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;effective_mode&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;ranking_version&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;과 generation을 묶습니다. 따라서 기존 legacy cursor 형식은 바뀌지 않으면서도 generation이나 실제 mode가 달라지면 cursor를 거부합니다.&lt;br /&gt;
&lt;br /&gt;
아래 v2 형태는 만료 시각과 generation을 독립 필드로 운영해야 할 때의 후속 형식입니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;v&amp;quot;: 2,&lt;br /&gt;
  &amp;quot;tool&amp;quot;: &amp;quot;find_analogies&amp;quot;,&lt;br /&gt;
  &amp;quot;fp&amp;quot;: &amp;quot;&amp;lt;request fingerprint&amp;gt;&amp;quot;,&lt;br /&gt;
  &amp;quot;gen&amp;quot;: &amp;quot;&amp;lt;retrieval generation&amp;gt;&amp;quot;,&lt;br /&gt;
  &amp;quot;pos&amp;quot;: 37,&lt;br /&gt;
  &amp;quot;exp&amp;quot;: 1893456000&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
fingerprint에는 query, source revision과 projection hash, filters, fields, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;min_score&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;effective_mode&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;ranking_version&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 포함합니다. generation은 같은 모델과 정렬 규칙을 사용하게 하지만, 페이지 사이에 생긴 MediaWiki 편집까지 snapshot으로 고정하지는 않습니다. 따라서 현재 검색과 마찬가지로 편집이 동시에 일어나면 결과가 중복되거나 다음 페이지로 이동할 수 있습니다. 정확한 query snapshot이 필요해지면 bounded 후보 ID를 별도 cursor 상태로 보존하는 기능을 독립적으로 설계합니다.&lt;br /&gt;
&lt;br /&gt;
해당 generation을 더 이상 제공할 수 없으면 첫 페이지부터 다시 조회하라는 cursor 오류를 반환합니다.&lt;br /&gt;
&lt;br /&gt;
각 호출은 모든 channel의 bounded 후보를 다시 모아 canonical 재검증과 정렬을 완료합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;pos&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 이 최종 목록의 offset입니다. 사라지거나 filter에 맞지 않는 후보는 offset을 적용하기 전에 목록에서 빠집니다. 출력 byte 제한 때문에 페이지가 짧아지면 cursor는 실제 반환한 항목 수만큼만 이동해 빠진 정상 후보가 다음 페이지에 나오게 합니다. v2로 전환할 때는 MCP 문서와 테스트를 함께 바꿉니다.&lt;br /&gt;
&lt;br /&gt;
=== 동기화와 일관성 ===&lt;br /&gt;
&lt;br /&gt;
일관성 보장은 다음처럼 나눕니다.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 항목 !! 보장&lt;br /&gt;
|-&lt;br /&gt;
| 반환한 Lesson 내용 || MediaWiki 최신 리비전으로 확인&lt;br /&gt;
|-&lt;br /&gt;
| 전체 텍스트 검색 || 저장 성공 뒤 바로 검색 가능&lt;br /&gt;
|-&lt;br /&gt;
| 의미 검색 완전성 || 짧은 지연을 허용하는 eventual consistency&lt;br /&gt;
|-&lt;br /&gt;
| relation 의미 || MediaWiki에 저장된 typed relation만 기준&lt;br /&gt;
|-&lt;br /&gt;
| 벡터 유사도 || 후보 추천이며 사실 또는 인과관계가 아님&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
MCP 쓰기는 항상 MediaWiki에 먼저 저장합니다. 1단계에서는 Lesson 생성이나 핵심 내용 revision 성공 뒤 프로세스 안의 dirty hint를 남기고 단일 백그라운드 refresh를 시작합니다. evidence와 relation만 추가하면 현재 projection 본문은 바뀌지 않으므로 재임베딩하지 않습니다. 브라우저 편집은 최소 대조 간격이 지난 hybrid 요청이 백그라운드 refresh를 시작할 때 발견합니다. 대조 결과 Lesson 수와 모든 projection hash가 같으면 모델을 다시 호출하지 않습니다. hybrid 요청 자체는 전체 대조를 기다리지 않습니다. 사용할 snapshot이 있으면 갱신 중에도 그 세대를 사용하고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;stale&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 상태를 표시하며, 없으면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;building&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 즉시 legacy 검색을 반환합니다. hint나 임베딩이 실패해도 위키 쓰기는 되돌리지 않습니다.&lt;br /&gt;
&lt;br /&gt;
1단계 refresh는 all-or-nothing입니다. 한 Lesson이라도 임베딩하지 못하면 기존 snapshot을 유지하고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;degraded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 기존 snapshot이 없으면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;unavailable&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 표시합니다. 항목별 재시도 큐와 recent changes poll은 후속 동기화 단계에서 추가합니다.&lt;br /&gt;
&lt;br /&gt;
=== 장애 동작 ===&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 장애 !! 동작&lt;br /&gt;
|-&lt;br /&gt;
| query 임베딩 실패 || 전체 텍스트와 relation 후보로 fallback&lt;br /&gt;
|-&lt;br /&gt;
| 벡터 인덱스 연결 실패 || 전체 텍스트와 relation 후보로 fallback&lt;br /&gt;
|-&lt;br /&gt;
| 인덱스 지연 || hash가 다른 semantic 기여를 빼고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;semantic_status=stale&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 표시&lt;br /&gt;
|-&lt;br /&gt;
| 일부 Lesson 임베딩 실패 || 1단계는 기존 snapshot 전체를 유지하고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;semantic_status=degraded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 표시&lt;br /&gt;
|-&lt;br /&gt;
| 프로세스 재시작·메모리 손실 || 서비스 시작 시 단일 백그라운드 작업으로 새 generation 프리웜&lt;br /&gt;
|-&lt;br /&gt;
| 브라우저 편집 미반영 || 최소 대조 간격이 지난 hybrid 요청이 백그라운드 전체 대조 시작&lt;br /&gt;
|-&lt;br /&gt;
| 모델과 인덱스 구성 불일치 || readiness 실패, generation 활성화 금지&lt;br /&gt;
|-&lt;br /&gt;
| 새 모델 품질 저하 || 1단계는 provider 설정을 되돌려 재시작, 후속 단계는 active alias rollback&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
fallback은 cursor가 없는 첫 page에서만 검색 mode를 바꿀 수 있습니다. hybrid 요청이 legacy로 fallback되면 응답과 cursor에 실제 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;effective_mode=legacy&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 기록합니다. hybrid cursor를 발급한 뒤 semantic backend가 실패하면 중간 page에서 legacy 순서로 바꾸지 않습니다. 재시도 가능한 오류를 반환하거나 첫 page부터 다시 조회하도록 안내합니다.&lt;br /&gt;
&lt;br /&gt;
semantic 검색 장애만으로 공개 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/healthz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 실패시키지 않습니다. 전체 텍스트 fallback이 가능하면 MCP는 읽기 서비스를 계속 제공합니다. 상세 semantic readiness는 내부 상태와 제한된 로그·지표로 확인합니다.&lt;br /&gt;
&lt;br /&gt;
=== 자원 격리 ===&lt;br /&gt;
&lt;br /&gt;
읽기 도구는 인증 없이 호출할 수 있으므로 semantic 추론 자원을 별도로 제한합니다.&lt;br /&gt;
&lt;br /&gt;
1단계는 query embedding과 background indexing이 하나의 bounded semaphore와 대기열을 공유합니다. 동시 실행 수, 대기열 길이, 대기·요청 timeout, batch 크기, 입력 길이와 응답 크기를 제한하고 circuit breaker를 적용합니다.&lt;br /&gt;
&lt;br /&gt;
트래픽이나 재구축 시간이 이 공유 한도에서 충돌하면 후속 단계에서 query와 indexing queue를 분리하고 background 우선순위와 concurrency를 따로 둡니다. 로컬 CPU·GPU worker를 쓸 때는 MCP event loop와 격리하고, 외부 API에는 비용 예산과 명시적 재시도 정책을 추가합니다. 어느 단계에서도 포화 상태가 lexical 검색과 쓰기 경로의 thread, connection과 memory를 소진하지 않게 합니다.&lt;br /&gt;
&lt;br /&gt;
한도를 넘으면 cursor가 없는 첫 page는 즉시 legacy로 fallback합니다. 이미 hybrid cursor를 발급했다면 검색 순서를 바꾸지 않고 재시도 또는 첫 page 재시작 오류를 반환합니다.&lt;br /&gt;
&lt;br /&gt;
=== 단계별 확장 ===&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 단계 !! 구성 !! 다음 단계로 옮기는 기준&lt;br /&gt;
|-&lt;br /&gt;
| 0 || 현재 전체 텍스트와 lexical analogy || 평가 기준선 확보&lt;br /&gt;
|-&lt;br /&gt;
| 1 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 안의 copy-on-write exact vector snapshot || 품질과 비용 검증&lt;br /&gt;
|-&lt;br /&gt;
| 2 || backend network 전용 검색 sidecar와 파생 volume || 재구축 시간, 메모리 또는 p95 지연 목표 초과&lt;br /&gt;
|-&lt;br /&gt;
| 3 || 공유 내부 검색 서비스와 여러 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 인스턴스 || 가용성 또는 동시 요청 때문에 수평 확장 필요&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
단계 1에서는 서버가 먼저 전체 텍스트 검색을 제공합니다. 시작 프리웜과 dirty/due 백그라운드 대조가 같은 구성 generation의 vector matrix와 metadata map을 만들고, 전체 대조가 성공할 때마다 copy-on-write snapshot을 원자적으로 교체합니다. Lesson 수, 벡터 차원, 실제 메모리, 재구축 시간과 exact cosine p95를 측정합니다. 단순한 Lesson 수만으로 제품을 바꾸지 않습니다.&lt;br /&gt;
&lt;br /&gt;
단계 2의 검색 sidecar도 도메인 전용 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;search_candidates&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 내부 상태 확인만 제공합니다. 벡터 저장 제품의 포트를 호스트나 외부 네트워크에 열지 않습니다.&lt;br /&gt;
&lt;br /&gt;
단계 3에서는 색인기를 한 인스턴스만 실행하거나 lease로 조정합니다. 모든 MCP 인스턴스는 같은 active generation을 읽습니다. 외부에는 계속 중앙 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 주소 하나만 제공합니다. 현재 프로세스 메모리에 있는 인증 상태도 수평 확장 전에 공유 상태 또는 안전한 routing으로 바꿔야 합니다.&lt;br /&gt;
&lt;br /&gt;
=== 보안과 개인정보 ===&lt;br /&gt;
&lt;br /&gt;
임베딩은 익명화된 값이 아닙니다. 원문과 같은 민감도로 다룹니다.&lt;br /&gt;
&lt;br /&gt;
* 기본 선택은 서버 안이나 내부 네트워크의 다국어 임베딩 모델입니다.&lt;br /&gt;
* 외부 임베딩 API는 서버 담당자가 전송, 보존, 학습 사용, region과 비용 조건을 확인한 뒤 명시적으로 켭니다.&lt;br /&gt;
* 검색 query는 아직 위키에 저장하지 않은 민감한 문제를 포함할 수 있습니다.&lt;br /&gt;
* 원문 query와 Lesson 본문을 로그에 남기지 않습니다.&lt;br /&gt;
* 지표에는 generation, revision, 지연 시간, 결과 수와 오류 코드만 남깁니다.&lt;br /&gt;
* 벡터 인덱스는 backend network에서만 접근합니다.&lt;br /&gt;
* 별도 호스트를 쓰면 서비스 인증과 전송 암호화를 적용합니다.&lt;br /&gt;
* 색인기 계정은 Lesson 읽기에 필요한 최소 권한만 가집니다.&lt;br /&gt;
* 모델과 tokenizer artifact는 digest로 고정합니다.&lt;br /&gt;
* 페이지가 삭제되거나 접근 정책이 바뀌면 active와 rollback generation에서 모두 제거합니다.&lt;br /&gt;
공개 위키에 있는 Lesson도 외부 임베딩 제공자에게 전송해도 된다고 자동으로 간주하지 않습니다. 검색 query는 위키보다 더 민감할 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
=== 관측 항목 ===&lt;br /&gt;
&lt;br /&gt;
다음 값을 query 원문 없이 측정합니다.&lt;br /&gt;
&lt;br /&gt;
* recent changes 지연 시간과 처리하지 않은 revision 수&lt;br /&gt;
* 마지막 poll과 전체 대조 시각&lt;br /&gt;
* 기준 Lesson 수와 generation별 색인 수&lt;br /&gt;
* missing, orphan, stale hash 수&lt;br /&gt;
* embedding, 후보 검색, MediaWiki 재조회와 재정렬 지연 시간&lt;br /&gt;
* embedding queue 깊이, 거부, timeout과 circuit breaker 상태&lt;br /&gt;
* fallback 비율과 원인&lt;br /&gt;
* hash 불일치로 제거한 후보 수&lt;br /&gt;
* query당 후보 수와 canonical filter로 제거한 수&lt;br /&gt;
* generation build 진행률, 실패, 재시도와 파생 오류 수&lt;br /&gt;
* active model, 입력 구성과 ranking version&lt;br /&gt;
* cursor 무효화와 출력 byte 제한 발생 비율&lt;br /&gt;
=== 품질 평가 ===&lt;br /&gt;
&lt;br /&gt;
벡터 검색은 기술적으로 동작하는 것만으로 활성화하지 않습니다. 한국어, 영어와 혼합 query를 포함한 작은 고정 평가셋을 먼저 만듭니다.&lt;br /&gt;
&lt;br /&gt;
다음 항목을 현재 검색과 비교합니다.&lt;br /&gt;
&lt;br /&gt;
* top 5 안에서 실제로 적용 가능한 후보 비율&lt;br /&gt;
* 다른 분야이지만 도움이 되는 후보 비율&lt;br /&gt;
* 저장된 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;analogous_to&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; relation의 회수율&lt;br /&gt;
* 정확한 오류 문자열과 고유 명칭 검색의 회귀 여부&lt;br /&gt;
* 같은 해결책이 반복되는 비율&lt;br /&gt;
* 근거와 적용 조건이 부족한 후보 비율&lt;br /&gt;
* p50, p95 지연 시간과 fallback 비율&lt;br /&gt;
평가용 query 원문에 비밀이나 개인 정보를 넣지 않습니다. 실제 사용 기록을 저장소에 그대로 커밋하지 않습니다. 새 generation은 품질 기준, 출력 제한과 장애 fallback 테스트를 모두 통과한 뒤 활성화합니다.&lt;br /&gt;
&lt;br /&gt;
=== 구현 순서 ===&lt;br /&gt;
&lt;br /&gt;
# &#039;&#039;&#039;완료:&#039;&#039;&#039; &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;LessonSnapshot&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;과 MediaWiki 최대 50개 일괄 조회를 추가했습니다.&lt;br /&gt;
# &#039;&#039;&#039;완료:&#039;&#039;&#039; &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;SemanticIndexBackend&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 경계와 기존 기본 동작을 유지하는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;legacy&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; mode를 추가했습니다.&lt;br /&gt;
# &#039;&#039;&#039;완료:&#039;&#039;&#039; 고정 벡터 fake와 domain-level 회귀·fallback·stale hash 테스트를 추가했습니다.&lt;br /&gt;
# &#039;&#039;&#039;완료:&#039;&#039;&#039; &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;ProjectionBuilder&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 교체 가능한 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;EmbeddingProvider&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 exact index를 추가했습니다.&lt;br /&gt;
# &#039;&#039;&#039;완료:&#039;&#039;&#039; &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;find_analogies&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;search_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 명시적 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;hybrid_v1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; mode를 적용했습니다. 기본 mode는 바꾸지 않았습니다.&lt;br /&gt;
# &#039;&#039;&#039;완료:&#039;&#039;&#039; revision ID를 포함한 조회, 조건부 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;revise_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, bounded &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;audit_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 full citation 조회를 추가했습니다. 이 도구들은 기준 MediaWiki를 읽고 쓰며 별도 검색 저장소를 만들지 않습니다.&lt;br /&gt;
# &#039;&#039;&#039;운영 후속:&#039;&#039;&#039; 고정 평가셋과 shadow 측정으로 품질, 비용, p95, fallback 비율을 확인한 뒤 활성화 범위를 정합니다.&lt;br /&gt;
# &#039;&#039;&#039;확장 후속:&#039;&#039;&#039; recent changes checkpoint, tombstone과 지속형 generation을 추가합니다.&lt;br /&gt;
# &#039;&#039;&#039;조건부 후속:&#039;&#039;&#039; 실제 지표가 전환 기준을 넘을 때만 sidecar 또는 공유 검색 서비스로 옮깁니다.&lt;br /&gt;
각 단계에서 기존 최대 20건, cursor, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;partial&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;scanned&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;scan_limit&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 UTF-8 직렬화 상한을 유지합니다. 모델, 응답 또는 검색 정책이 바뀌면 MCP 모델, 예시와 문서를 함께 수정합니다.&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC/%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98&amp;diff=1727</id>
		<title>도움말:S3 연구 메모리/아키텍처</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC/%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98&amp;diff=1727"/>
		<updated>2026-07-18T10:09:47Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 저장소 문서 동기화&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;!-- S3RM managed repository document; edit the Markdown source, not this page. --&amp;gt;&lt;br /&gt;
&#039;&#039;이 페이지는 저장소 문서에서 자동으로 동기화됩니다. 위키에서 직접 편집하지 마세요.&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
=== 용도와 범위 ===&lt;br /&gt;
&lt;br /&gt;
S3 연구 메모리는 프로젝트가 끝난 뒤에도 다시 쓸 수 있는 Lesson을 저장합니다. 작업 추적기, 실행 로그, 산출물 저장소, 원시 지표 데이터베이스로 쓰지 않습니다. 원본 근거는 알맞은 저장소나 객체 스토리지에 두고, Lesson에는 주장과 적용 조건, 해석, 원본을 다시 찾는 데 필요한 인용을 기록합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
flowchart LR&lt;br /&gt;
    H[&amp;quot;브라우저 사용자&amp;lt;br/&amp;gt;읽기는 공개, 쓰기는 이름이 있는 계정&amp;quot;] --&amp;gt;|&amp;quot;https://s3wiki.yonsei.ac.kr/&amp;quot;| P[&amp;quot;Caddy HTTPS 진입점&amp;quot;]&lt;br /&gt;
    C[&amp;quot;모든 MCP 클라이언트&amp;quot;] --&amp;gt;|&amp;quot;https://s3wiki.yonsei.ac.kr/mcp&amp;lt;br/&amp;gt;읽기는 공개, 변경은 운영 모드로 인증&amp;quot;]| P&lt;br /&gt;
    P --&amp;gt;|&amp;quot;/mcp 이외의 모든 경로&amp;quot;| W[&amp;quot;Semantic MediaWiki + Page Forms&amp;quot;]&lt;br /&gt;
    P --&amp;gt;|&amp;quot;/mcp 및 /mcp/*&amp;quot;| M[&amp;quot;중앙 mcp-http&amp;quot;]&lt;br /&gt;
    M --&amp;gt;|&amp;quot;인증된 제한형 MediaWiki API 어댑터&amp;quot;| W&lt;br /&gt;
    W --&amp;gt; D[(&amp;quot;MariaDB: 기준 페이지, 의미 속성, 리비전&amp;quot;)]&lt;br /&gt;
    W --&amp;gt; U[(&amp;quot;업로드 볼륨&amp;quot;)]&lt;br /&gt;
    M --&amp;gt; S[&amp;quot;검색 조정 계층&amp;quot;]&lt;br /&gt;
    S --&amp;gt;|&amp;quot;전체 텍스트·relation·최신 snapshot&amp;quot;| W&lt;br /&gt;
    S -. &amp;quot;선택한 hybrid_v1&amp;quot; .-&amp;gt; V[&amp;quot;프로세스 메모리 exact vector index&amp;quot;]&lt;br /&gt;
    W -. &amp;quot;시작 프리웜·dirty 즉시·due 백그라운드 대조&amp;quot; .-&amp;gt; V&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Semantic MediaWiki는 유일한 기준 저장소입니다. Lesson 내용은 MediaWiki에만 저장합니다. 중앙 MCP 서비스는 데이터를 저장하지 않고 정책만 적용하는 어댑터이며 별도의 지식 저장소가 아닙니다. 따라서 MariaDB와 업로드 볼륨을 함께 백업하며, MCP 전송 상태는 복구 대상에 포함하지 않습니다. Caddy는 TLS 종료와 경로 전달만 맡습니다. 인증서 상태는 Docker 볼륨에 남지만 기준 Lesson 데이터에는 속하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_WIKI_DIRECT_URL&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 명시한 서버는 같은 MediaWiki 화면을 직접 HTTP 포트로도 열 수 있습니다. 정확히 설정한 Host만 이 주소를 선택하고 정식 링크와 백그라운드 작업은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;MW_SERVER_URL&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 HTTPS 도메인을 계속 사용합니다. 이 입구는 위키 프런트엔드 하나를 추가하는 것이 아니라 동일 프로세스의 요청 URL 선택이며, MCP나 Lesson 저장소를 하나 더 만들지 않습니다.&lt;br /&gt;
&lt;br /&gt;
=== 연구실 공용 MCP 연결 ===&lt;br /&gt;
&lt;br /&gt;
에이전트는 장기 실행 서비스 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 한 개와 Streamable HTTP 엔드포인트 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;https://s3wiki.yonsei.ac.kr/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 한 곳만 사용합니다. 각 컴퓨터에서 별도 어댑터나 지식 사본을 실행하지 않습니다. 모든 클라이언트는 같은 어댑터를 거쳐 내부 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;http://mediawiki/api.php&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 접근합니다. 한 클라이언트가 저장한 Lesson은 복제나 내보내기 없이 다음 검색부터 다른 클라이언트에도 보입니다. 동시에 저장할 때는 MediaWiki 리비전과 편집 충돌 검사가 쓰기 순서를 정합니다.&lt;br /&gt;
&lt;br /&gt;
원격 연결에는 다음 네 가지 제한을 둡니다.&lt;br /&gt;
&lt;br /&gt;
# &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_PUBLIC_URL&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 외부에 공개할 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 주소를 고정하고 정확한 Host 및 Origin 허용 목록을 만듭니다.&lt;br /&gt;
# 기본 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;token&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드에서는 초기화, 도구 목록 조회, 읽기 도구 다섯 개를 인증 없이 쓸 수 있습니다. 생성·관계·근거 추가·검증 기록과 기존 Lesson의 조건부 수정은 같은 공유 Bearer token을 확인합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;open&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드는 다섯 변경 도구를 공개합니다. 선택 사항인 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;github&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드는 GitHub 로그인을 상류 인증으로 사용하고 MCP 연결 전체에 자체 OAuth token을 요구합니다.&lt;br /&gt;
# 공개 URL의 정확한 Host와 Origin은 자동으로 허용합니다. 명시적인 역방향 프록시 값이 더 필요하면 쉼표로 구분한 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_ALLOWED_HOSTS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_ALLOWED_ORIGINS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 추가합니다. 와일드카드는 허용하지 않습니다.&lt;br /&gt;
# MCP 프로세스는 최소 권한 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;agent&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 계정으로 MediaWiki에 로그인합니다. 유효한 토큰이 있어도 제한된 도메인 작업과 이 계정에 부여된 위키 권한을 넘을 수 없습니다. 조건부 수정은 MCP와 MediaWiki 훅만 공유하는 HMAC 키로 서명하므로 위키 계정 비밀번호만 가진 직접 Action API 요청은 revision 표식을 만들 수 없습니다.&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;./scripts/configure-public-domain.sh s3wiki.yonsei.ac.kr --expected-ip 165.132.118.220&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 실행하면 MCP의 호스트 직접 연결 포트가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;127.0.0.1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 묶이고 HTTPS가 필수가 되며 Compose &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;domain&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 프로필이 켜집니다. Caddy는 80번과 443번 포트를 열어 TLS를 종료하고, 경로를 삭제하거나 바꾸지 않은 채 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 전달합니다. Caddy는 caller가 보낸 client IP·proxy secret 헤더를 덮어쓰고, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 별도 서버 비밀값이 맞을 때만 전달된 IP를 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;open&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드 rate principal로 사용합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;token&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드에서 토큰이 없는 클라이언트는 공개 조회를 쓸 수 있고, 공용 토큰을 헤더에 넣을 수 있는 클라이언트는 다섯 변경 도구를 쓸 수 있습니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;open&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드는 호스팅 커넥터에도 다섯 변경 도구를 공개합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;github&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드는 같은 프로세스의 OAuth discovery, 동적 client 등록, GitHub callback, token·refresh·revoke 경로를 사용합니다. Lesson 저장소는 계속 MediaWiki 하나뿐입니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;GET /livez&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 MCP 프로세스만 확인하는 작은 비인증 경로입니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;GET /healthz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;GET /readyz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 canonical MediaWiki 로그인 가능 여부와 공유 상태 파일에서 확인한 bounded job runner freshness를 모두 readiness 조건으로 사용합니다. 응답에는 bounded admission·semantic 갱신 상태도 내보내지만 설정값이나 Lesson 내용은 내보내지 않습니다. 세 경로 모두 에이전트 도구는 아닙니다. 현재 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 인증 없이 초기화와 도구 조회를 처리하고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;search_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;get_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;audit_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;find_related_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;find_analogies&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 실행합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;token&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;create_lesson_draft&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;link_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;attach_evidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;record_evidence_verification&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;revise_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에는 같은 공용 Bearer 토큰이 필요합니다. GitHub 모드에서는 다섯 도구에 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp:write&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; scope가 필요합니다.&lt;br /&gt;
&lt;br /&gt;
파일럿은 공용 쓰기 토큰 다이제스트와 공용 MediaWiki 에이전트 계정 한 개를 사용합니다. MediaWiki에는 서비스 계정과 모든 페이지 리비전이 기록됩니다. 토큰을 사용한 컴퓨터, 모델, 사람은 구분하지 않습니다. 개인별 토큰 폐기나 확인된 작성자 구분이 필요해지면 제한된 도구 권한은 그대로 두고 OAuth/OIDC 또는 별도 전송 ID를 추가할 수 있습니다. 클라이언트가 보낸 author 필드나 메모는 참고 정보일 뿐, 확인된 신원은 아닙니다.&lt;br /&gt;
&lt;br /&gt;
=== 기준 페이지 계약 ===&lt;br /&gt;
&lt;br /&gt;
브라우저 화면은 한국어로 표시합니다. 기준 식별자, 템플릿 매개변수 이름, 속성 이름, 이전 버전 호환 상태값, 근거 종류, 관계 토큰, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Lesson:&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 이름공간은 영문 기계 계약을 그대로 씁니다. 한국어 Page Forms 라벨과 매핑 템플릿은 화면에 보일 때만 이 값을 번역합니다. MediaWiki의 기본 이름공간과 화면 문구가 한국어로 바뀌어도 기존 MCP 클라이언트와 저장된 리비전은 그대로 작동합니다.&lt;br /&gt;
&lt;br /&gt;
각 Lesson은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Lesson:&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 이름공간의 페이지 한 건입니다. 전체 페이지 제목을 고정 식별자로 씁니다. 예를 들면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Lesson:gpu_allocator_fragmentation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;입니다. 화면에 보이는 제목은 식별자를 바꾸지 않고 수정할 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
페이지에는 다음 매개변수를 정확히 사용하는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 템플릿 한 개가 들어갑니다.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 필드 !! 뜻&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;title&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 화면에 표시할 Lesson 제목&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;question&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 작업을 시작하게 된 질문&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;attempt&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 시도한 방법&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;context&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 하드웨어, 워크로드, 버전, 규모, 제약 조건&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;observation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 직접 관찰한 내용&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;interpretation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 관찰한 결과가 나온 이유에 대한 해석&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;reusable_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 짧게 정리한 재사용 가능한 내용&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;applicability&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 다시 적용할 수 있는 조건과 범위&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;confidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;low&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;medium&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;high&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 중 하나&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;evidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 사람이 읽을 수 있는 근거 개요&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;record_origin&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 바꿀 수 없는 출처 분류인 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;lab&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;imported&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;synthetic&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 중 하나&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;author&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 처음 작성한 사람 또는 계정&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;reviewer&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 이전 버전 호환 필드. 일반 작성에서는 비워 둠&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;review_state&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 이전 버전 호환 필드. 값은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Draft&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Reviewed&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Stable&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Superseded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이며 일반 작성에서는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Draft&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 사용&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;created_at&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || UTC ISO-8601 생성 시각&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;updated_at&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || UTC ISO-8601 마지막 수정 시각&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
근거 첨부는 반복 가능한 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Lesson evidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 템플릿으로 저장합니다. 필드는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;citation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 선택 항목인 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;url&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;kind&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;verification_basis&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;note&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;added_by&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;added_at&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;입니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;verification_basis&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;full_text&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;official_abstract&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;partial_source&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;metadata_only&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;not_recorded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 중 하나이며, 자료에서 실제로 확인한 범위를 뜻합니다. 이는 주장의 종합 신뢰도인 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;confidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 다릅니다. URL이 없어도 citation은 반드시 입력합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;not_recorded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 이전 자료의 확인 범위가 없음을 나타내는 호환값이며 MCP의 새 evidence에는 허용하지 않습니다. 브라우저 양식에도 기본값이 없고 새 항목에서 선택할 수 없습니다. 브라우저에서 새 Lesson을 작성할 때는 구조화된 근거 한 건 이상이 필요합니다. MCP는 초기 evidence 한 건을 Lesson과 같은 revision에 원자적으로 만들고, 이후 추가 evidence만 별도 제한 작업으로 처리합니다. high confidence 생성에는 아래 검증 기록이 초기 evidence와 같은 revision에 필요합니다. 이 기록은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;full_text&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 또는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;official_abstract&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supports&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이고 observation, interpretation, reusable_lesson을 모두 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;claim_fields&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 연결해야 합니다.&lt;br /&gt;
&lt;br /&gt;
근거 검증은 반복 가능한 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Lesson evidence verification&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 템플릿으로 저장합니다. 필드는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;evidence_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;evidence_digest&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;verification_basis&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;source_identity&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;source_sha256&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;source_locator&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;coverage&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;outcome&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;claim_fields&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;verified_by&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;verified_at&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;입니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;outcome&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supports&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;contradicts&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;inconclusive&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 중 하나입니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;claim_fields&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;question&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;attempt&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;context&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;observation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;interpretation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;reusable_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;applicability&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 중 자료가 실제로 검사한 주장만 나열합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;source_sha256&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 실제로 검사한 원본 파일 바이트의 SHA-256입니다. 원본 대신 저장·정규화한 텍스트를 검사했다면 그 정확한 텍스트의 UTF-8 바이트를 해시합니다. 이 경우 변환 방법과 보존한 산출물을 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;source_identity&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;coverage&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 명시해 다시 동일한 바이트를 만들 수 있게 합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;source_locator&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에는 페이지·절·라인·실험 구간 등 실제 위치를, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;coverage&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에는 읽은 범위와 제외 범위를 적습니다.&lt;br /&gt;
&lt;br /&gt;
검증 기록은 추가 후 수정·삭제하지 않습니다. 같은 evidence를 다시 확인하면 새 기록을 append-only로 붙이고 마지막 기록을 현재 판정으로 사용합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;verified_at&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 이전 Lesson &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;updated_at&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;과 이전 검증 시각보다 반드시 늦고, 새 Lesson &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;updated_at&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;보다 늦을 수 없습니다. 시각이 같거나 거꾸로인 기록은 현재 판정을 추측하지 않고 감사 대상으로 남깁니다.&lt;br /&gt;
&lt;br /&gt;
MediaWiki revision의 actor와 timestamp가 브라우저·raw 저장의 권위 출처입니다. 저장 훅은 새 Lesson의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;author&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 새 하위 항목의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;added_by&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;created_by&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;verified_by&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 현재 저장 계정과 정확히 같은지 확인합니다. 새 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;created_at&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;updated_at&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;added_at&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, relation의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;created_at&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;verified_at&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 revision 저장 시각 전후 5분 안이어야 합니다. 기존 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;created_at&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 변경할 수 없고, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;updated_at&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;과 새로 append하는 하위 시각은 이전 revision·기록보다 늦어야 하는 monotonic 보조 필드입니다. 양식의 검증 출처·시각 필드는 숨김 표시되며, 기존 evidence·relation·검증 기록과 그 출처는 변경할 수 없습니다. 새 Lesson은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;created_at=updated_at&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 저장하고, 초기 evidence와 초기 검증의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;verification_basis&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 다르면 거부합니다.&lt;br /&gt;
&lt;br /&gt;
관계는 반복 가능한 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Lesson relation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 템플릿으로 저장합니다. 필드는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;from&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;relation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;to&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 선택 항목인 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;note&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;created_by&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;created_at&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;입니다. 사용할 수 있는 relation 값은 다음으로 제한합니다.&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supports&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;contradicts&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;analogous_to&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;failed_because&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;works_when&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;suggests&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supersedes&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
관계에는 방향이 있습니다. 조회할 때 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;analogous_to&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 대칭 관계처럼 보여 주지만, 출처를 남기기 위해 저장된 양쪽 끝점은 명시적으로 유지합니다. 같은 간선을 중복해서 저장하면 MCP 도메인 계층이 거부합니다. 브라우저와 에이전트 모두 이미 존재하는 Lesson만 관계 대상으로 지정할 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
=== 저장과 수정 ===&lt;br /&gt;
&lt;br /&gt;
저장에 성공한 Lesson은 기준 위키에 바로 반영되며 브라우저와 MCP에서 즉시 검색할 수 있습니다. 수정 이력과 근거는 다음과 같이 관리합니다.&lt;br /&gt;
&lt;br /&gt;
* MediaWiki 리비전으로 모든 변경을 남기고 되돌릴 수 있습니다.&lt;br /&gt;
* 구조화된 근거로 주장의 출처를 확인할 수 있습니다.&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;confidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 현재 근거의 수준을 표시합니다.&lt;br /&gt;
* 형식이 정해진 관계로 지지, 반박, 유사점, 적용 조건, 대체 관계를 표시합니다.&lt;br /&gt;
* Page Forms, MCP 모델, 도메인 계층에서 같은 스키마를 검사합니다.&lt;br /&gt;
같은 주장이나 같은 원천 논문의 내용을 정정·보강할 때는 같은 Lesson ID에 새 리비전을 추가합니다. evidence note나 별도 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Technical Review&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; Lesson은 본문 수정 수단이 아닙니다. 독립된 주장이 기존 판단을 실제로 대신할 때만 새 Lesson을 만들고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supersedes&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 연결합니다. 충돌하거나 범위를 제한하는 별도 주장은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;contradicts&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;나 다른 알맞은 관계로 연결합니다. 이전 판단은 삭제하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
다섯 쓰기 도구는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;operation_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 요청 digest를 사용합니다. 생성 시 자동 Lesson·evidence ID도 이 요청에서 결정되므로 응답이 유실돼도 같은 요청이 중복 레코드를 만들지 않습니다. 생성에는 구조화된 초기 evidence가 필수이며 Lesson과 같은 revision에 저장됩니다. 같은 Lesson 안에서 S3W 일반 쓰기, S3R revision, S3V 검증 기록은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;operation_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 충돌 범위를 공유합니다. 같은 ID·종류·요청 digest의 정확한 재시도만 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;replayed&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이고, 다른 종류나 digest면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;idempotency_conflict&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;입니다. 다른 Lesson은 같은 ID를 쓰더라도 서로 간섭하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;record_evidence_verification&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 현재 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;expected_revision_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 대상 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;expected_evidence_digest&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 모두 맞을 때만 새 검증 기록을 추가합니다. 검증은 evidence, Lesson 주장, relation을 다시 쓰지 않습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;revise_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 현재 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;expected_revision_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 맞을 때만 허용된 핵심 필드를 바꾸고, 이미 해당 Lesson에 붙은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;basis_evidence_ids&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 1~8개를 요구합니다. ID, 출처, 최초 작성자·생성 시각, 호환 필드, relation·evidence·검증 기록 목록은 바꾸지 않습니다. MediaWiki 편집 충돌은 자동 병합하지 않습니다. 요청 digest, 모든 basis evidence, 실제 변경 필드와 본문 digest를 HMAC으로 봉인해 revision 요약에 남깁니다. 서명·작성자·부모 revision·본문 digest가 모두 맞는 이력만 replay로 인정합니다. 최대 10,000개 revision을 페이지 단위로 확인하며 같은 요청은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;replayed&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 같은 ID의 다른 요청은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;idempotency_conflict&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;입니다. replay 응답의 적용 revision과 현재 최신 revision은 별도 필드로 반환합니다. 최신 검증 기록 중 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;full_text&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 또는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;official_abstract&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supports&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이며 실제 변경 필드를 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;claim_fields&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 연결한 근거만 저장 후 결과 confidence가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;medium&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 또는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;high&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;인 observation, interpretation, reusable_lesson 수정을 뒷받침합니다. confidence를 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;high&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 하면 세 필드 전부를 뒷받침해야 합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;contradicts&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;inconclusive&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 지지 근거가 아닙니다. 결과 confidence가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;low&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;인 교정은 기존 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;medium&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;/&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;high&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 낮추는 경우와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;low&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 유지하는 경우 모두 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;not_recorded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 아닌 basis evidence로 할 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;reviewer&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;review_state&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 이전 리비전을 읽기 위한 고정 호환 필드입니다. 화면, 검색 순서, 공개 여부에는 쓰지 않습니다.&lt;br /&gt;
&lt;br /&gt;
MCP 읽기 도구는 인증 없이 쓸 수 있습니다. 공유 쓰기 자격 증명이 있으면 다음 작업을 할 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;lab&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; Lesson 생성&lt;br /&gt;
* 기존 Lesson에 검증된 관계 한 건 추가&lt;br /&gt;
* 기존 Lesson에 인용이 있는 근거 한 건 추가&lt;br /&gt;
* 기존 evidence에 append-only 검증 기록 한 건 추가&lt;br /&gt;
* 기준 revision과 기존 근거를 전제로 같은 Lesson의 허용된 핵심 필드를 새 revision으로 수정&lt;br /&gt;
서버 관리 필드는 클라이언트가 선택하거나 바꿀 수 없습니다. 페이지 삭제, 이동, 보호, 관리도 허용하지 않습니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supersedes&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 관계를 추가해도 대상 Lesson은 변경되지 않습니다. 클라이언트가 예상하지 않은 필드를 보내면 도메인 계층이 거부하고, MediaWiki 계정에도 관리 권한을 부여하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
Lesson이 바뀔 때마다 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;updated_at&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 이전 값보다 반드시 늦어야 합니다. 에이전트가 근거, 관계나 검증 기록을 추가하면 새 항목의 출처 시각에도 같은 값을 사용합니다. 생성·관계·근거·검증 추가 응답도 적용 뒤 현재 revision ID를 반환하므로 다음 조건부 수정을 이어 갈 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
=== 검색 구현 ===&lt;br /&gt;
&lt;br /&gt;
기본 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;retrieval_mode=legacy&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 MediaWiki 전체 텍스트 검색 결과를 어댑터에서 구조화 필터로 거릅니다. Action API에는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;srwhat=text&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 명시합니다. 이를 생략하면 MediaWiki의 레거시 기본값인 페이지명 검색이 적용되어, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Lesson:&amp;amp;lt;slug&amp;amp;gt;&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 본문에 저장된 한국어 필드를 찾지 못합니다. MySQL 검색 엔진은 색인과 질의에 같은 UTF-8 정규화 및 짧은 단어 길이 보정을 적용하므로 MariaDB의 최소 단어 길이는 기본값을 사용합니다. 유사점 검색에는 Unicode NFKC 정규화, Unicode 단어, 한국어 2글자 및 3글자 n-gram을 사용합니다. MediaWiki 리비전 내용은 한 번에 최대 50페이지씩 가져옵니다. 초기 데이터 규모에서 운영하기에 충분하며 구성이 단순합니다.&lt;br /&gt;
&lt;br /&gt;
의미 검색을 켠 서버에서 호출자가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;retrieval_mode=hybrid_v1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 명시하면 세 가지 facet을 사용합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;situation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 질문·조건·관찰, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;approach&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 시도, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mechanism&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 해석·재사용 가능한 결론·적용 조건으로 구성합니다. 전체 텍스트, 저장된 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;analogous_to&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 의미 후보는 versioned RRF 규칙으로 결합합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;find_analogies&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 결합 점수는 raw cosine이 아니며 의미 후보를 relation으로 저장하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
파생 exact index는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 프로세스 메모리에만 있습니다. HTTP transport와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;EmbeddingProfile&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 분리하며, profile은 모델 artifact digest, 차원, pooling, query/document 형식, facet별 instruction과 입력 상한을 고정합니다. 이 profile과 projection 구성을 함께 해시해 generation을 만듭니다. 외부 Streamable HTTP 요청은 stateless로 처리하지만 위키 어댑터와 exact index는 ASGI 프로세스 lifespan에서 한 번만 만들고 모든 요청이 공유합니다. 서비스 시작 시 단 하나의 백그라운드 작업이 MediaWiki snapshot 전체를 읽어 인덱스를 프리웜합니다. MCP로 새 Lesson을 저장하면 dirty hint가 즉시 같은 단일 갱신을 시작합니다. 대조 주기가 지나면 다음 hybrid 요청이 갱신을 시작합니다. 대조한 Lesson 수와 projection hash가 모두 같으면 재임베딩 없이 대조 시각만 갱신합니다. 어느 경우에도 공개 요청은 전체 재구축을 기다리지 않습니다. 사용할 snapshot이 있으면 갱신 중에도 그 snapshot을 사용하고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;stale&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 또는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;degraded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 상태를 표시합니다. 아직 snapshot이 없으면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;building&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 또는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;unavailable&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 상태로 즉시 legacy 검색을 반환합니다. 새 인덱스는 옆에서 완성한 뒤 원자적으로 교체합니다.&lt;br /&gt;
&lt;br /&gt;
후보는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Lesson:&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; ID와 retrieval hash만 제공하며, 최종 필터와 응답 본문은 최신 MediaWiki snapshot으로 다시 확인합니다. hash가 달라진 의미 기여는 제거하고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;semantic_status=stale&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 표시합니다. 제공자 장애나 과부하 시 첫 페이지는 legacy로 대체하며, 이미 발급한 hybrid cursor의 순서를 중간에 바꾸지 않습니다.&lt;br /&gt;
&lt;br /&gt;
MCP 서비스는 같은 열 도구만 외부에 제공합니다. exact index의 재구축 시간, 메모리 또는 지연 시간이 실제 목표를 넘을 때만 같은 비공개 backend 경계를 내부 sidecar나 공유 검색 서비스로 교체합니다. 필드 및 출처 필터와 이전 버전 호환 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;review_state&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 필터, 현재 응답 제한은 계속 보존합니다. 내부 경계, 색인 세대, 장애 fallback과 단계별 확장안은 [[Help:S3 연구 메모리/벡터·하이브리드 검색 설계|벡터·하이브리드 검색 설계]]에 정리합니다.&lt;br /&gt;
&lt;br /&gt;
현재 서버의 선택적 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;embedding&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; Compose profile은 고정된 Qwen3 F16 GGUF를 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;llama.cpp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; Vulkan으로 실행합니다. 모델 downloader만 별도 egress 네트워크를 쓰고, 추론 서비스는 호스트 포트 없이 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 공유하는 전용 내부 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;embedding-backend&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에만 연결합니다. 이 서비스와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;embedding_models&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 볼륨은 파생 계산 계층이며 Semantic MediaWiki를 대신하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
=== 출력 제한 ===&lt;br /&gt;
&lt;br /&gt;
모든 MCP 목록 작업에는 작은 기본 페이지 크기, 엄격한 최댓값, 커서 기반 페이지 나누기, 필드 선택, UTF-8 직렬화 응답의 바이트 상한이 있습니다. 스캔 응답에는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;partial&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;scanned&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;scan_limit&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 들어갑니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;partial=true&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이면 검색 결과가 완전하지 않은 것으로 처리해야 합니다. 기본 결과에는 ID, 제목, 한 줄짜리 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;reusable_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 출처, confidence, 짧은 근거 인용이 들어갑니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;get_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 현재 revision ID를 포함하고 인용, 검증 기록과 관계를 각각 나누어 반환합니다. 각 하위 목록은 기본 5개, 최대 20개의 독립 cursor 페이지입니다. 기본 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;citation_detail=compact&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 짧은 인용만, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;full&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 note와 추가한 주체·시각까지 반환합니다. full 인용은 한 항목도 조용히 자르지 않고 페이지 크기를 줄이며, 한 항목도 출력 한도에 맞지 않으면 명시적인 오류를 반환합니다. 본문 필드는 필요할 때만 직접 선택합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;audit_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 정확한 ID 기대값을 최대 50개 읽고 결과를 최대 20개씩 반환합니다. revision, 내용 digest, 최대 8개 evidence와 최대 8개 relation을 확인하며, 페이지 사이 어느 대상 revision이라도 바뀌면 이전 cursor를 거부합니다. 긴 본문을 반복 반환하지 않고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;ok&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;missing&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mismatch&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 실패한 기대값 위치만 반환합니다.&lt;br /&gt;
&lt;br /&gt;
열 도구는 모두 닫힌 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;outputSchema&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 공개합니다. 성공 응답의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;structuredContent&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 이 스키마로 검증하며, 기존 클라이언트가 읽는 compact JSON text도 같은 내용으로 유지합니다.&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC/%EB%B3%B4%EC%95%88&amp;diff=1726</id>
		<title>도움말:S3 연구 메모리/보안</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC/%EB%B3%B4%EC%95%88&amp;diff=1726"/>
		<updated>2026-07-18T10:09:47Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 저장소 문서 동기화&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;!-- S3RM managed repository document; edit the Markdown source, not this page. --&amp;gt;&lt;br /&gt;
&#039;&#039;이 페이지는 저장소 문서에서 자동으로 동기화됩니다. 위키에서 직접 편집하지 마세요.&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
=== 파일럿 기준 ===&lt;br /&gt;
&lt;br /&gt;
S3 연구 메모리는 연구실에서 운영하는 단일 서버 파일럿입니다. 위키나 공용 MCP에 접속할 수 있으면 누구나 Lesson을 검색하고 읽을 수 있습니다. 일반적인 시스템 연구 메모를 다루는 용도입니다. 자격 증명, 공개 전 결과, 개인정보처럼 비공개로 유지해야 할 내용은 저장하지 마세요.&lt;br /&gt;
&lt;br /&gt;
읽기는 공개합니다. 쓰기 인증은 운영 모드에 따르며, 현재 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;open&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드에서는 에이전트 쓰기 다섯 가지도 공개됩니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;token&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드에서는 공용 Bearer 토큰이 익명 대량 변경이나 실수를 줄이는 최소한의 경계가 됩니다. 허용된 호출은 메모 작성, 기존 Lesson의 관계 추가, 인용이 있는 근거 첨부, append-only 근거 검증 기록과 조건부 핵심 내용 수정을 할 수 있습니다. 저장한 메모는 바로 검색되며 revision, 근거, confidence, 관계, 스키마와 일괄 감사로 변경 내용을 확인할 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
MediaWiki revision에는 바뀐 내용, 시각, 위키 계정이 남습니다. 에이전트 변경은 개별 사람이나 PC가 아니라 공용 서비스 계정으로 기록됩니다. 되돌리기에는 쓸 수 있지만, 개인별 신원을 증명하거나 변경 불가능한 감사 로그를 제공하지는 않습니다.&lt;br /&gt;
&lt;br /&gt;
=== 서비스 경계 ===&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;: 브라우저 공개 읽기와 로그인한 사용자의 Page Forms 편집&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;: 모든 에이전트가 연결하는 유일한 MCP 주소. 개인별 MCP 복제본이나 MediaWiki 일반 API를 클라이언트에 연결하지 않음&lt;br /&gt;
* MCP의 열 도구: 필드, 변경 권한, 관계, 페이지네이션, 출력 크기 검증&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;: 내부 Compose 네트워크에만 있으며 호스트 포트를 열지 않음&lt;br /&gt;
* Semantic MediaWiki 페이지와 revision: 유일한 기준 데이터. 선택적인 프로세스 메모리 의미 인덱스는 후보 ID만 내며 Lesson DB가 아님&lt;br /&gt;
Docker를 제어하거나 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 바꿀 수 있으면 전체 배포를 제어할 수 있습니다. 서버 관리 권한은 서버 담당자만 사용합니다. 일반 사용자는 브라우저 주소와 공용 MCP 연결 정보만 있으면 됩니다.&lt;br /&gt;
&lt;br /&gt;
모든 주요 서비스에는 PID·메모리·CPU 상한과 회전하는 Docker 로그가 있습니다. 이 상한은 한 프로세스의 runaway와 로그 디스크 고갈 범위를 줄이지만, 컨테이너 탈출 방지나 사용자별 공정성, 호스트 전체의 자원 예약을 보장하지 않습니다. OOM 종료, PID 상한 도달과 반복 재시작은 장애로 감시해야 하며 상한을 없애는 방식으로만 해결하지 않습니다. 회전 로그는 감사 원장이 아니므로 revision과 별도 운영 기록을 대신하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
=== 계정과 역할 ===&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 계정 !! 권한&lt;br /&gt;
|-&lt;br /&gt;
| 익명(&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;*&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;) || 공개 읽기·검색. 페이지 작성·편집과 자기 가입은 불가&lt;br /&gt;
|-&lt;br /&gt;
| 일반 브라우저 계정(&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;user&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;) || Page Forms로 연구 내용 작성·편집&lt;br /&gt;
|-&lt;br /&gt;
| MCP 계정(&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;agent&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;) || 읽기·검색·감사, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;lab&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 메모 작성, 기존 Lesson에 근거·관계·검증 추가와 조건부 핵심 내용 수정&lt;br /&gt;
|-&lt;br /&gt;
| 관리자(&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;sysop&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;/&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bureaucrat&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;) || 계정, 스키마, 확장 기능, 복구 관리&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
자기 가입은 꺼져 있습니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;LocalSettings.s3.php&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 익명 그룹의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;createaccount&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;false&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 설정했고, MediaWiki의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;sysop&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 권한만 계정을 만들 수 있습니다. 새 계정은 관리자가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/index.php/Special:CreateAccount&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 만듭니다. 이 화면을 사용하면 새 비밀번호를 셸 기록이나 프로세스 인자에 남기지 않습니다. 정확한 순서는 [[Help:S3 연구 메모리/설치와 검증#브라우저 계정 만들기|&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;setup.md&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;]]에 있습니다.&lt;br /&gt;
&lt;br /&gt;
브라우저 편집에는 사람마다 이름이 있는 일반 계정을 만드세요. 관리자 계정이나 MCP 계정을 일상 편집에 쓰지 않습니다. 일반 계정에 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;sysop&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bureaucrat&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;agent&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 추가할 필요가 없습니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_WIKI_USERNAME&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;agent&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 그룹에만 두고 높은 권한을 제거합니다.&lt;br /&gt;
&lt;br /&gt;
공용 Bearer 토큰, MediaWiki MCP 기본 계정 비밀번호, MCP BotPassword, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_WIKI_REVISION_HMAC_KEY&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_PROXY_SECRET&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 모두 서로 다릅니다. 원문 토큰은 필요한 클라이언트에만 전달하고 위키 자격 증명과 HMAC 키는 서버에만 둡니다. HMAC 서명이 맞아야 저장 훅이 핵심 내용 수정을 받아들이므로 위키 계정 비밀번호만으로는 MCP의 조건부 수정 경로를 우회할 수 없습니다. DB나 일반 계정 비밀번호를 재사용하지 마세요.&lt;br /&gt;
&lt;br /&gt;
관리자 비밀번호는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-install&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;과 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; one-shot secret에만 마운트하며 장기 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에는 전달하지 않습니다. MCP 기본 계정의 primary 비밀번호도 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 계정 수렴에만 쓰고 장기 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에는 전달하지 않습니다. 장기 서비스는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;&amp;amp;lt;기본 계정&amp;amp;gt;@&amp;amp;lt;app-id&amp;amp;gt;&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 철회 가능한 별도 32자 BotPassword만 사용합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 grant를 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;basic,createeditmovepage&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 수렴하고 실제 로그인 뒤 유효 권한을 확인합니다. grant는 원래 계정 권한의 상한일 뿐 권한을 추가하지 않으므로 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;agent&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 그룹의 이동·삭제·관리 금지와 저장 훅이 계속 적용됩니다.&lt;br /&gt;
&lt;br /&gt;
=== 에이전트 권한 ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;token&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드에서 토큰이 없으면 다음 작업만 할 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
* 초기화와 도구 목록 조회&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;search_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;get_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;audit_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;find_related_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;find_analogies&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;token&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드의 공용 토큰, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;github&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드의 허용 로그인 또는 현재 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;open&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드는 다음 작업도 허용합니다.&lt;br /&gt;
&lt;br /&gt;
* 연구 내용 필드로 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;create_lesson_draft&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 호출&lt;br /&gt;
* 이미 있는 두 Lesson ID와 허용된 관계 하나로 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;link_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 호출&lt;br /&gt;
* 사람이 읽을 수 있는 인용과 선택 사항인 자격 증명 없는 HTTP(S) URL로 기존 Lesson에 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;attach_evidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 호출&lt;br /&gt;
* 현재 revision·evidence digest와 실제 자료 확인 내역으로 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;record_evidence_verification&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 호출&lt;br /&gt;
* 현재 revision ID와 이미 붙어 있는 근거를 지정해 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;revise_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 호출&lt;br /&gt;
* 제목과 문제·시도·조건·관찰·해석·재사용 교훈·적용 범위, confidence, 근거 개요 수정&lt;br /&gt;
다음 작업은 인증 모드와 관계없이 할 수 없습니다.&lt;br /&gt;
&lt;br /&gt;
* MediaWiki revision 출처와 다른 출처, 최초 작성자, 생성 timestamp와 호환 필드 선택·변경&lt;br /&gt;
* relation, evidence 또는 검증 기록 항목 수정·삭제&lt;br /&gt;
* 임의 페이지 삭제·이동·보호·복원·범용 덮어쓰기&lt;br /&gt;
* 사용자 생성, 그룹 변경, 양식·템플릿·스키마 변경, 위키 관리&lt;br /&gt;
* 일반 MediaWiki API를 MCP 도구처럼 호출&lt;br /&gt;
MCP 서비스는 출처, 작성자, UTC 시각과 내부 호환값을 만듭니다. 브라우저·raw 저장은 MediaWiki revision actor·timestamp가 권위 출처이고, 훅은 하위 actor의 현재 저장 계정 exact-match와 시각의 서버 저장 시각 전후 5분·monotonic 제약을 검사합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supersedes&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;도 다른 관계와 같이 두 메모 사이의 연결만 기록합니다.&lt;br /&gt;
&lt;br /&gt;
모든 MCP 클라이언트는 같은 최소 권한 위키 계정을 사용하므로 MediaWiki의 계정별 편집 횟수도 함께 사용합니다. 기본 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;agent&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 편집 한도는 분당 6000회입니다. 일반 로그인 계정은 MediaWiki 기본값인 분당 90회를 유지하고, 편집 이외 작업의 제한도 바꾸지 않습니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;agent&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에는 모든 제한을 우회하는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;noratelimit&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 권한을 주지 않습니다. 저장 훅의 Lesson 범위, 필드 검증, revision ID 조건과 서명 검사는 이 한도와 관계없이 항상 적용됩니다.&lt;br /&gt;
&lt;br /&gt;
분당 한도는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_AGENT_EDITS_PER_MINUTE&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 91~6000 사이로 조정할 수 있습니다. 기본값은 공유 계정 병목을 줄이기 위해 범위의 최댓값을 사용합니다. 더 낮춘 값을 다시 올리거나 MCP admission을 완화하면 유효한 호출이 저장을 빠르게 반복할 수 있는 범위도 같이 커집니다. 동시성과 DB 부하를 계속 제한하세요.&lt;br /&gt;
&lt;br /&gt;
MediaWiki 한도 앞에는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 bounded admission이 있습니다. 기본값은 전체 동시 쓰기 8개, queue 64개·2초, principal별 지속 600회/분·burst 120입니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;open&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드는 Caddy가 전달한 client IP, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;github&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 로그인 subject, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;token&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 공유 token을 principal로 사용합니다. 현재 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;open&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 운영에서는 IP별 bucket이고, 인증 모드를 바꾸지 않아도 이 제한은 적용됩니다. principal bucket은 최근 10000개까지만 메모리에 두며 초과하면 가장 오래된 항목을 제거합니다. 이 제거는 영구 신원 DB나 차단 목록이 아니며 프로세스를 다시 시작해도 bucket 상태가 보존되지 않습니다.&lt;br /&gt;
&lt;br /&gt;
Caddy는 공개 caller가 보낸 client IP와 내부 proxy secret 헤더를 둘 다 덮어씁니다. MCP는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_PROXY_SECRET&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 constant-time 비교로 맞을 때만 전달된 IP를 신뢰합니다. loopback 진단 포트로 직접 보낸 위조 헤더는 즉시 연결 peer IP로 대체되므로 IP별 bucket을 임의로 나눌 수 없습니다.&lt;br /&gt;
&lt;br /&gt;
동시성·queue 상한은 과부하 전파를 줄이기 위한 것이며 작업 완료를 보장하는 durable queue가 아닙니다. busy·rate-limit 응답을 받은 클라이언트는 서버가 준 retry 간격을 따르고, 결과가 불확실한 쓰기는 operation ID로 먼저 조회합니다. 처리량을 높이면 DB 경합과 유효 credential 오용 범위도 커지므로 health의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;write_admission&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;과 MariaDB latency를 함께 확인합니다.&lt;br /&gt;
&lt;br /&gt;
=== 공용 MCP 인증 ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 하나가 다섯 가지 조회와 다섯 가지 변경 도구를 제공합니다. 쓰기 인증은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_WRITE_AUTH_MODE&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 정합니다.&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;token&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;: 기본값입니다. 서버는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_TOKEN_SHA256&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 쓰기 토큰 해시를 저장하고 다섯 변경 도구에서 받은 Bearer 값을 constant-time으로 비교합니다.&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;open&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;: 다섯 변경 도구를 인증 없이 허용합니다.&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;github&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;: GitHub는 사람의 로그인만 확인합니다. 서버는 GitHub token을 폐기한 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 전용 access·refresh token을 별도로 발급합니다. 이 모드에서는 조회를 포함한 MCP 연결 전체에 OAuth가 필요합니다. 다섯 변경 도구에는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp:write&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; scope가 필요합니다.&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;open&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 인터넷에서 누구나 메모 생성, 관계 연결, 근거 첨부·검증과 조건부 수정을 호출할 수 있다는 뜻입니다. 페이지 삭제·이동·보호·관리는 계속 허용하지 않습니다. 공개 운영 정책상 공개 쓰기를 종료할 때만 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;token&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 바꾸고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 다시 만드세요.&lt;br /&gt;
&lt;br /&gt;
GitHub 모드는 저장소 scope를 요청하지 않습니다. 사용자 allowlist에는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;read:user&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 조직 allowlist에는 추가로 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;read:org&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;만 사용합니다. client secret은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에만 두며 GitHub token을 MCP token으로 전달하거나 저장하지 않습니다. OAuth 상태는 프로세스 메모리에만 있으므로 재시작하면 기존 연결이 무효가 됩니다.&lt;br /&gt;
&lt;br /&gt;
HTTPS 배포를 설정합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
./scripts/configure-public-domain.sh s3wiki.yonsei.ac.kr \&lt;br /&gt;
  --expected-ip 165.132.118.220&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
스크립트는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;https://s3wiki.yonsei.ac.kr/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 기록하고, 직접 MCP 포트를 loopback에 묶고, HTTPS와 Compose &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;domain&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 프로필을 사용하게 합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에는 해시만 넣고, 같은 URL과 공용 원문 토큰은 mode &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;0600&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 전달 파일에 씁니다. URL은 그대로 공유해도 됩니다. 공용 토큰은 다섯 변경 도구가 필요한 에이전트에만 전달합니다. revision HMAC 키는 서버에만 두고 이 파일이나 클라이언트에 넣지 않습니다.&lt;br /&gt;
&lt;br /&gt;
공용 Bearer 토큰은 가벼운 연구실 도구에 맞춘 하나의 쓰기 자격 증명입니다. HTTPS로 전송하고, 권한은 일반 브라우저 편집보다 좁습니다. 페이지를 삭제하거나 관리 설정을 바꿀 수 없습니다. 현재 파일럿을 시작하는 데 개인별 클라이언트 신원, SSO, MFA가 필요하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
정적 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Authorization&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 헤더를 보낼 수 있는 클라이언트는 변경 도구 다섯 개를 쓸 수 있습니다. 호스팅형 커넥터는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;open&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드에서 인증 없이 쓰거나 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;github&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드에서 OAuth로 로그인할 수 있습니다. 인증 모드를 바꿔도 MCP 서비스나 Lesson 저장소를 하나 더 만들지는 않습니다.&lt;br /&gt;
&lt;br /&gt;
서버는 라우팅 정보도 확인합니다.&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_PUBLIC_URL&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 Host와 Origin은 자동 허용합니다.&lt;br /&gt;
* 추가 값은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_ALLOWED_HOSTS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_ALLOWED_ORIGINS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 정확한 값으로 쉼표를 넣어 적습니다.&lt;br /&gt;
* wildcard는 받지 않습니다.&lt;br /&gt;
* 네이티브 클라이언트는 Origin을 생략할 수 있습니다.&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/livez&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 인증 없이 MCP 프로세스 상태만 알립니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/healthz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/readyz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 canonical store 연결 여부와 읽기 전용 공유 파일로 확인한 job runner 상태를 readiness에 포함합니다. bounded admission·semantic 갱신 수치는 알리지만 자격 증명, 상태 파일 내용, 설정값, Lesson 내용은 내보내지 않습니다. 일반 위키 API는 내부 어댑터에서만 사용하며 에이전트 주소가 아닙니다.&lt;br /&gt;
&lt;br /&gt;
=== 임베딩과 파생 인덱스 ===&lt;br /&gt;
&lt;br /&gt;
의미 검색은 기본 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_EMBEDDING_PROVIDER=disabled&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 꺼져 있습니다. 켜면 Lesson의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;question&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;context&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;observation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;attempt&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;interpretation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;reusable_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;applicability&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 사용자의 검색 query가 설정한 임베딩 제공자에게 전송될 수 있습니다. 검색 query에는 아직 위키에 저장하지 않은 문제나 정보가 들어갈 수 있으므로 공개 Lesson보다 덜 민감하다고 간주하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
* 번들 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;embedding-model-init&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;만 모델을 받을 때 외부망을 사용합니다. 고정한 Hugging Face commit, 파일 크기와 SHA-256이 모두 맞아야 모델 볼륨에 게시합니다. 이 SHA-256은 검색 generation의 model revision과도 같아야 합니다.&lt;br /&gt;
* 실제 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;embedding&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 런타임은 호스트 포트와 외부망이 없고 전용 내부 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;embedding-backend&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;만 호출합니다. 호스트에서는 지정한 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/dev/dri/renderD128&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 하나만 전달하며 비-root 사용자로 실행하고 입력 prompt cache는 끕니다.&lt;br /&gt;
* 번들 로컬 모델을 쓰면 Lesson 필드와 query는 호스트 밖의 임베딩 제공자에게 전송되지 않습니다. 모델 파일이 있는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;embedding_models&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 다시 받을 수 있는 파생 볼륨이며 Lesson이나 벡터를 저장하지 않습니다.&lt;br /&gt;
* 외부 API는 전송·보존·학습 사용·region·삭제·비용 조건을 확인한 뒤 명시적으로 켭니다.&lt;br /&gt;
* API key는 서버 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;나 secret 저장소에만 두고 클라이언트, 로그, prompt와 Lesson 근거에 넣지 않습니다.&lt;br /&gt;
* 원문 query, 임베딩 입력과 벡터를 로그에 남기지 않습니다.&lt;br /&gt;
* 비-loopback HTTP 제공자는 기본적으로 거부합니다. 내부망 사유가 확인된 경우에만 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_EMBEDDING_ALLOW_INSECURE_HTTP=true&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 사용하고 정확한 Host를 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_EMBEDDING_INSECURE_HTTP_HOSTS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 함께 적습니다.&lt;br /&gt;
* 프로세스 메모리의 exact 인덱스는 파생 후보 인덱스입니다. 최종 Lesson과 필터는 MediaWiki 최신 revision으로 다시 확인합니다.&lt;br /&gt;
* 제공자 timeout, 포화 또는 잘못된 벡터가 발생하면 의미 후보를 제외하고 기존 전체 텍스트와 관계 검색을 계속 사용합니다.&lt;br /&gt;
* 로컬 제공자 장애를 이유로 외부 API로 자동 전환하지 않습니다.&lt;br /&gt;
파생 인덱스에 저장한 벡터도 원문과 같은 민감도로 취급합니다. 나중에 sidecar나 영속 volume으로 옮기면 backend 네트워크, 서비스 인증, 삭제 동기화와 백업 정책을 별도로 검토합니다. 벡터 저장 제품의 범용 API를 MCP나 호스트 포트로 공개하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
=== 자격 증명 보관 ===&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 원문 MCP 토큰, revision HMAC 키, proxy secret, DB·위키 primary 비밀번호와 BotPassword, 생성된 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;LocalSettings.php&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, dump, upload를 커밋하지 않습니다.&lt;br /&gt;
* 외부 임베딩 API key도 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;나 서버 secret에만 두고 커밋하지 않습니다.&lt;br /&gt;
* 클라이언트 URL은 그대로 전달해도 됩니다. 원문 쓰기 토큰은 비공개 채널로 전달하고 추적되는 프로젝트 파일이 아닌 환경 변수나 secret 필드에 넣습니다.&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Authorization&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 헤더나 전체 환경·설정 dump를 공개 이슈, 프롬프트, 스크린샷, Lesson 근거에 넣지 않습니다.&lt;br /&gt;
* 토큰이 의도하지 않은 곳에 공개되면 교체합니다. 쓰기 클라이언트는 모두 함께 바뀌며 조회 클라이언트는 바꿀 것이 없습니다.&lt;br /&gt;
* 백업에는 사용자 정보, 이전·삭제 revision, 업로드, MediaWiki 비밀 키와 DB 변경 내용을 재구성할 수 있는 binary log도 있으므로 일반 메모보다 엄격하게 보관합니다.&lt;br /&gt;
R2의 live restic 자격 증명은 보존 정책상 오래된 snapshot을 지울 권한도 가집니다. 따라서 같은 bucket 하나는 자격 증명 탈취나 오작동에 대한 불변 사본이 아닙니다. 별도 자격 증명·prefix 또는 다른 계정으로 보호 복사본을 만들고, 보존 잠금은 restic의 lock·prune 동작과 함께 시험한 뒤 적용합니다. 구체적인 경계는 [[Help:S3 연구 메모리/백업과 복원#Cloudflare R2 자동 백업|&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;operations.md&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;]]에 있습니다.&lt;br /&gt;
&lt;br /&gt;
Docker 관리자는 컨테이너 환경을 볼 수 있습니다. 단일 서버 파일럿에서는 이 조건을 받아들입니다. 별도 secret 관리 제품은 선택 사항이며 시작 조건이 아닙니다.&lt;br /&gt;
&lt;br /&gt;
=== 공개 범위와 변경 이력 ===&lt;br /&gt;
&lt;br /&gt;
Lesson 본문과 일반 upload는 서비스에 접속할 수 있는 누구에게나 보인다고 생각하세요. MediaWiki의 revision과 최근 바뀜으로 변경을 확인하고 되돌릴 수 있습니다. 이 기능이 spam을 막거나 공용 토큰을 사용한 개인을 증명하지는 않습니다.&lt;br /&gt;
&lt;br /&gt;
틀린 내용이나 같은 원천의 보강은 같은 Lesson ID의 새 revision으로 고칩니다. 근거 note나 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supersedes&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 관계를 업데이트 수단으로 쓰지 않습니다. 내용이 크게 다른 독립 주장은 기존 이력을 숨기거나 지우지 말고 관계가 있는 새 Lesson으로 남깁니다. 다음 항목을 정기적으로 확인합니다.&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Special:RecentChanges&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 해당 페이지의 역사 보기&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;agent&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 관리자 그룹 계정&lt;br /&gt;
* 관리 대상 Form, Template, Property 페이지의 예상하지 못한 변경&lt;br /&gt;
* 반복되는 MCP 인증 실패와 Host·Origin 오류&lt;br /&gt;
=== 근거와 prompt injection ===&lt;br /&gt;
&lt;br /&gt;
Lesson 본문, 근거 메모, 외부 페이지, 업로드 파일은 명령이 아니라 데이터입니다. 에이전트와 브라우저 편집자는 원래 요청을 유지하고, 근거 안의 명령을 실행하지 않으며, 필요한 자료만 읽어야 합니다. 저장된 주장과 에이전트의 추론을 구분하세요. 위키 밖에서 결과가 생기는 작업에는 별도의 명시적인 요청이 필요합니다.&lt;br /&gt;
&lt;br /&gt;
MCP 모델은 사용자 이름이나 비밀번호가 든 근거 URL을 거부합니다. 브라우저로 작성할 때도 인용, 메모, query string, upload에서 자격 증명을 제거합니다.&lt;br /&gt;
&lt;br /&gt;
검증 기록의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;source_sha256&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 실제로 검사한 원본 바이트 또는 보존한 정확한 UTF-8 텍스트 바이트를 묶습니다. 해시는 자료 변조를 자동으로 발견하거나 주장의 진위를 증명하지 않습니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;expected_revision_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;expected_evidence_digest&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 다른 쓰기 사이의 대상 변경을 막는 CAS 경계이며, 검증 기록 자체는 추가 후 수정·삭제할 수 없습니다. 정기 감사는 이 구조와 수령 기록의 일관성을 확인할 뿐 외부 사실 진위를 판정하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
=== 범위를 넓힐 때 ===&lt;br /&gt;
&lt;br /&gt;
나중에 비공개 내용이나 개인별 귀속이 필요해지면 위협 모델을 다시 정합니다. 필요에 따라 OAuth/OIDC, 학교 SSO, MFA, 개인별 자격 증명, rate limit, 별도 secret 저장소를 추가할 수 있습니다. 현재 파일럿의 시작 조건은 아니며, 열 도구의 에이전트 권한을 넓혀서는 안 됩니다.&lt;br /&gt;
&lt;br /&gt;
Docker 호스트를 업데이트하고, 의존성 버전 고정을 유지하며, 실행 중인 컨테이너를 직접 고치지 말고 이미지를 다시 만드세요. 업데이트 전에는 복원을 시험합니다. 자세한 절차는 [[Help:S3 연구 메모리/백업과 복원|&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;operations.md&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;]]에 있습니다.&lt;br /&gt;
&lt;br /&gt;
기본 권한 동작은 MediaWiki [https://www.mediawiki.org/wiki/Manual:User_rights 사용자 권한 안내], Page Forms [https://www.mediawiki.org/wiki/Extension:Page_Forms/Download_and_installation 권한 안내], [https://www.mediawiki.org/wiki/Manual:Bot_passwords BotPasswords 안내]를 참고하세요.&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC/%EB%B0%B1%EC%97%85%EA%B3%BC_%EB%B3%B5%EC%9B%90&amp;diff=1725</id>
		<title>도움말:S3 연구 메모리/백업과 복원</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC/%EB%B0%B1%EC%97%85%EA%B3%BC_%EB%B3%B5%EC%9B%90&amp;diff=1725"/>
		<updated>2026-07-18T10:09:47Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 저장소 문서 동기화&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;!-- S3RM managed repository document; edit the Markdown source, not this page. --&amp;gt;&lt;br /&gt;
&#039;&#039;이 페이지는 저장소 문서에서 자동으로 동기화됩니다. 위키에서 직접 편집하지 마세요.&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
Semantic MediaWiki가 유일한 Lesson 저장소입니다. 복구 지점에는 같은 시점의 MariaDB 덤프, 업로드 파일, 생성된 위키 설정이 모두 있어야 합니다. 공용 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 상태를 저장하지 않는 전송·검증 어댑터입니다. Lesson 볼륨, 토큰 DB, 별도 기준 데이터가 없으므로 백업하지 않습니다. 이미지와 설정은 저장소와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 다시 만듭니다.&lt;br /&gt;
&lt;br /&gt;
Caddy는 [https://s3wiki.yonsei.ac.kr/]와 [https://s3wiki.yonsei.ac.kr/mcp]의 HTTPS 진입점이며 지식을 저장하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
복구 스크립트는 Bash 4 이상, GNU coreutils(&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;stat&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;sha256sum&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;), GNU tar, gzip이 있는 GNU/Linux Docker 호스트에서 지원합니다. 필요한 명령이 없으면 바로 실패합니다. macOS와 Windows Docker Desktop에서는 이 Compose 프로젝트를 소유한 Linux VM 또는 Linux 호스트에서 실행하세요. BSD 명령 옵션이 같다고 가정하지 마세요.&lt;br /&gt;
&lt;br /&gt;
=== 영구 데이터 ===&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Compose 볼륨 !! 내용 !! 복구 때 역할&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db_data&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || MediaWiki 페이지, 의미 데이터, 사용자, 변경 이력, 로그, 호환 필드 || 기준 데이터베이스&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db_binlogs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || MariaDB ROW binary log와 index || 기준 덤프 이후 시간점 복구(PITR). 기본 14일 보존&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki_images&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 업로드한 근거 파일과 MediaWiki 업로드 메타데이터 || DB 덤프와 같은 시점이어야 함&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki_config&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 생성된 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;LocalSettings.php&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 MediaWiki 비밀 키 || 위키 식별·설정 보존에 필요&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;caddy_data&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 발급된 TLS 인증서, 개인 키, Caddy 실행 상태 || 평소 보존하되 Lesson 기준 데이터는 아님&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;caddy_config&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || Caddy 실행 설정 || 저장소와 공개 hostname으로 다시 생성 가능&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;embedding_models&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || SHA-256으로 확인한 번들 GGUF || 다시 받을 수 있는 파생 캐시. Lesson 복구 세트가 아님&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
일반 백업 스크립트는 DB 덤프, 설정, 업로드와 함께 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db_binlogs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 캡처 시점 구간과 덤프에 내장된 replay 시작 file·position을 저장합니다. 이 항목이 Lesson 복구 세트입니다. 새 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db_binlogs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 볼륨은 one-shot &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db-binlog-init&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 MariaDB 계정만 쓸 수 있도록 소유권과 mode를 맞춘 뒤 DB가 시작합니다. 이 서비스가 실패하면 DB를 시작하지 않으며, 볼륨 권한을 넓혀 우회하지 않습니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;caddy_data&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;caddy_config&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 평소 재시작 때 남겨 두되, 버전이 있는 위키 백업에 섞지 않습니다. 이 볼륨이 없어지면 DNS와 외부 80/443이 정상일 때 Caddy가 인증서를 다시 받을 수 있습니다. 일반 운영에서는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;docker compose down --volumes&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 쓰지 마세요.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;embedding_models&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에는 Lesson, 검색 query나 벡터가 없습니다. 백업·복원 스크립트는 이 볼륨을 포함하지 않으며, 의미 검색을 다시 켤 때 고정된 원본과 SHA-256으로 재구성합니다. 평소에는 재다운로드와 서비스 중단을 피하도록 볼륨을 남겨 둡니다.&lt;br /&gt;
&lt;br /&gt;
저장소에는 다시 만들 수 있는 코드, 템플릿, 양식이 있습니다. 자격 증명이 든 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 추적하지 않으며 일반 백업 아카이브에도 넣지 않습니다. 서버 담당자가 복구용 사본을 따로 보관하되 Git이나 공개 아카이브에는 넣지 마세요.&lt;br /&gt;
&lt;br /&gt;
복구 세트의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;runtime-images.txt&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 Compose에 설정한 image reference와 실제 컨테이너 image ID를 함께 기록합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;repository-tracked.patch.gz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 현재 HEAD 기준의 &#039;&#039;&#039;추적된 파일&#039;&#039;&#039; 차이만 담습니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, untracked dump, upload, 자격 증명은 구조적으로 제외됩니다. untracked 파일이 있었다면 metadata에 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;present_not_archived&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;만 남고 내용과 이름은 보관하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
서버 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에는 MCP 쓰기 토큰의 해시만 있습니다. 전달용 원문 토큰이 든 mode &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;0600&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;~/.config/s3-research-memory/client.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;도 최소 한 곳에 보관하세요. 원문 토큰을 모두 잃었다면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;./scripts/configure-mcp-token.sh --rotate&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 새 토큰을 만들고 쓰기 클라이언트에 다시 전달합니다. 이 작업 중에도 공개 조회는 계속됩니다.&lt;br /&gt;
&lt;br /&gt;
=== 백업 ===&lt;br /&gt;
&lt;br /&gt;
저장소 밖의 절대 경로를 사용합니다. 가능하면 Docker 호스트와 다른 디스크를 선택하고, 서버 담당자만 읽고 쓸 수 있게 만듭니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
install -d -m 0700 /srv/s3rm-backups&lt;br /&gt;
docker compose stop mcp-http&lt;br /&gt;
./scripts/backup.sh /srv/s3rm-backups&lt;br /&gt;
docker compose up -d mcp-http&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
먼저 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 멈추면 에이전트 변경 경로가 닫힙니다. 백업 스크립트는 DB를 기다린 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-jobs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 실행 중인 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 멈춰 브라우저와 job 변경도 막습니다. MariaDB binary log를 교체해 file·position을 고정한 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--master-data=2&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 논리 덤프, binary-log 구간, 업로드·설정 볼륨을 묶습니다. 백업과 복구 스크립트는 같은 checkout 디렉터리에 nonblocking maintenance lock을 겁니다. 예약 백업, 수동 백업, 복구 중 둘 이상을 겹쳐 실행하면 나중 시작한 작업이 즉시 실패합니다. 다른 checkout으로 같은 운영 Compose project를 동시에 관리하지 마세요. 백업 종료 때는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 실행 중이던 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-jobs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 순서로 복원하고 job worker health를 확인한 뒤에만 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 다시 엽니다. worker가 준비되지 않으면 백업 파일 생성 여부와 관계없이 실패로 끝나며 MCP는 중지 상태를 유지합니다. 실패해도 exit trap이 스크립트가 관리한 서비스를 백업 전 상태로 돌립니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 명시적으로 멈췄으므로 성공하거나 원인을 확인한 실패 뒤에도 마지막 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;up&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 명령을 실행해야 합니다.&lt;br /&gt;
&lt;br /&gt;
모든 파일의 형식과 checksum을 확인한 뒤에만 시각이 붙은 최종 디렉터리를 공개합니다. 덤프나 근거 아카이브를 저장소에 넣지 않습니다. 끝에 출력되는 복구 디렉터리의 정확한 경로를 기록하세요.&lt;br /&gt;
&lt;br /&gt;
==== binary log를 처음 켜는 서버 ====&lt;br /&gt;
&lt;br /&gt;
이 설정을 배포하기 전부터 실행 중인 DB는 컨테이너를 다시 만들기 전까지 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;log_bin=OFF&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;일 수 있습니다. 이 상태에서 일반 백업은 중단되며, 최초 이전 전에만 다음 명시적 기준 백업을 사용합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
./scripts/backup.sh --legacy-base /srv/s3rm-backups&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--legacy-base&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;log_bin=OFF&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;인 DB에서만 실행됩니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;database.sql.gz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 두 볼륨 아카이브, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;metadata.txt&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;SHA256SUMS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 이루어진 복원 가능한 format 1 기준 백업이며 PITR 보조 파일은 &#039;&#039;&#039;하나도&#039;&#039;&#039; 생성하지 않습니다. 나중에 별도 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;database-binlogs.tar.gz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;나 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;AUXILIARY-SHA256SUMS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 더해 일반 백업처럼 만들지 마세요. 보조 파일이 일부만 있는 세트는 복원과 격리 검증이 거부합니다.&lt;br /&gt;
&lt;br /&gt;
체크섬을 확인하고 완성된 디렉터리를 원격 저장소에 복사한 뒤에만 DB를 재생성합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db_data&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 지우지 않고 새 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db_binlogs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 볼륨을 연결합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose stop mediawiki-jobs mcp-http bootstrap mediawiki&lt;br /&gt;
docker compose up -d --force-recreate --wait db&lt;br /&gt;
docker compose exec -T db sh -eu -c &#039;&lt;br /&gt;
  MYSQL_PWD=&amp;quot;$MARIADB_ROOT_PASSWORD&amp;quot; mariadb \&lt;br /&gt;
    --host=127.0.0.1 --user=root --batch \&lt;br /&gt;
    --execute=&amp;quot;SHOW VARIABLES LIKE &#039;\&#039;&#039;log_bin&#039;\&#039;&#039;; SHOW MASTER STATUS;&amp;quot;&lt;br /&gt;
&#039;&lt;br /&gt;
make domain-up&lt;br /&gt;
./scripts/backup.sh /srv/s3rm-backups&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
출력에 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;log_bin\tON&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;과 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mariadb-bin.NNNNNN&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 행이 모두 있어야 합니다. 마지막 일반 백업은 PITR 보조 체인까지 갖춘 새 기준입니다. 이후에는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--legacy-base&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 사용하지 마세요.&lt;br /&gt;
&lt;br /&gt;
복구 디렉터리는 다음 파일을 한 세트로 가집니다.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 파일 !! 내용&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;database.sql.gz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 설정된 MediaWiki DB 전체 논리 덤프&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;database-binlogs.tar.gz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 일반 백업만: 캡처 시점까지 보존된 MariaDB ROW binary log·index 보조 아카이브&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;pitr-manifest.txt&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 일반 백업만: 기준 dump 뒤 replay를 시작할 binary-log file·position·GTID, archive 목록·digest&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-images.tar.gz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 업로드·근거 볼륨의 numeric-owner 아카이브&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-config.tar.gz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 생성된 설정과 위키 키의 numeric-owner 아카이브&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;runtime-images.txt&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 일반 백업만: 설정 reference와 실제 실행 컨테이너 image ID&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;repository-tracked.patch.gz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 일반 백업만: HEAD에 적용할 추적 소스 binary patch. untracked·ignored 파일 제외&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;repository-tracked-manifest.txt&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 일반 백업만: 추적 파일별 SHA-256과 소스 상태&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;metadata.txt&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 형식 버전, DB 이름, 페이지·revision 수, 소스 revision, 이미지 참조, Compose 해시. 일반 백업은 PITR position도 포함&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;SHA256SUMS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 앞의 네 payload checksum&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;AUXILIARY-SHA256SUMS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 일반 백업만: PITR·image·tracked-source 보조 파일 checksum. 이 파일 digest는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;metadata.txt&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 통해 기본 manifest와 결합&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
기본 대상, 각 복구 디렉터리, 모든 파일의 권한은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;0700&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 또는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;0600&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;입니다. 기존 대상에 group/other 접근 비트가 하나라도 있으면 스크립트가 거부하며 권한을 임의로 바꾸지 않습니다. 백업할 때 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 실행 중이어야 합니다. 위키가 원래 정지 상태였다면 그대로 두고, 실행 중이었다면 API가 다시 healthy가 될 때까지 기다린 뒤 성공을 알립니다. 복원도 소스 디렉터리에 같은 권한 검사를 합니다.&lt;br /&gt;
&lt;br /&gt;
기본 로컬 대상에 저장하려면 경로를 생략합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose stop mcp-http&lt;br /&gt;
./scripts/backup.sh&lt;br /&gt;
docker compose up -d mcp-http&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
기본 로컬 경로만 장기 보관에 사용하지 마세요. 완료된 디렉터리 전체를 접근이 제한된 외부 저장소에 복사합니다. 공개 메모가 주 내용이더라도 백업에는 사용자 정보, 삭제·이전 revision, 생성된 비밀 키가 있으므로 공개하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
==== 백업 확인 ====&lt;br /&gt;
&lt;br /&gt;
스크립트가 실패하면 그 백업은 사용할 수 없습니다. 성공한 뒤에도 다음을 확인합니다.&lt;br /&gt;
&lt;br /&gt;
# DB 덤프, 설정·이미지 아카이브, 메타데이터, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;SHA256SUMS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 있는지 확인합니다. 일반 백업은 binary-log·PITR 보조 파일 전체와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;AUXILIARY-SHA256SUMS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;도 있어야 합니다. 최초 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--legacy-base&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 보조 파일이 하나도 없어야 합니다.&lt;br /&gt;
# 스크립트가 출력한 checksum 명령을 실행합니다. ```bash (cd /ABS/PATH/TO/BACKUP-DIRECTORY &amp;amp;amp;&amp;amp;amp; \ sha256sum --check --strict SHA256SUMS) # 일반 PITR 백업에서만 추가 확인 (cd /ABS/PATH/TO/BACKUP-DIRECTORY &amp;amp;amp;&amp;amp;amp; \ sha256sum --check --strict AUXILIARY-SHA256SUMS) ```&lt;br /&gt;
# 빈 파일이 없는지, 시각과 소스 버전이 맞는지 확인합니다.&lt;br /&gt;
# 디렉터리 전체를 한 단위로 복사합니다. 서로 다른 시각의 파일을 섞지 마세요.&lt;br /&gt;
# 아래의 격리 복원 시험을 정기적으로 실행합니다. checksum은 전송 무결성만 확인하며 실제 복원 가능성을 보장하지 않습니다.&lt;br /&gt;
MediaWiki [https://www.mediawiki.org/wiki/Manual:Backing_up_a_wiki 백업 안내]도 DB와 파일을 함께 보관하도록 안내합니다. XML 페이지 내보내기는 보조 이동본으로 쓸 수 있지만 사용자 계정, 모든 로그, 업로드 메타데이터, 사이트 전체 상태를 담지 않으므로 이 복구 세트를 대신할 수 없습니다.&lt;br /&gt;
&lt;br /&gt;
==== MariaDB PITR 경계 ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;database.sql.gz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 완전 기준 백업입니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;pitr-manifest.txt&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;binlog_file&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;·&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;binlog_position&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 그 덤프가 이미 포함한 마지막 경계이자 replay 시작 좌표입니다. 이 좌표 이전 event를 다시 적용하면 중복 변경이 생깁니다. 이후 시각으로 이동하려면 기준 좌표 &#039;&#039;&#039;이후&#039;&#039;&#039;의 연속된 binary log가 전부 있어야 합니다. 백업 당시 archive에는 아직 생기지 않은 미래 event가 들어 있을 수 없으므로, 더 늦게 만든 복구 세트의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;database-binlogs.tar.gz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;나 별도 연속 보관본이 필요합니다. 로컬 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db_binlogs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 기본 14일 후 자동 만료되므로 원격 백업과 복원 시험이 이 기간 안에 성공해야 합니다.&lt;br /&gt;
&lt;br /&gt;
PITR은 운영 DB에 바로 재생하지 마세요. 격리 복원을 먼저 완료한 뒤, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mariadb-binlog --start-position&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에는 manifest의 좌표 다음 event부터 적용되도록 경계를 확인하고, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--stop-datetime&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 또는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--stop-position&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 명시하여 SQL을 만듭니다. 재생 전에 시작·종료 file, position, target UTC, 예상 event 수를 작업 기록에 남기고, Lesson·revision 개수와 대표 페이지를 확인합니다. binary log가 하나라도 빠졌거나 기준 position보다 이전 백업과 섞였다면 PITR를 계속하지 말고 다음 완전 백업으로 복원합니다.&lt;br /&gt;
&lt;br /&gt;
==== 권장 주기 ====&lt;br /&gt;
&lt;br /&gt;
파일럿에서는 다음 주기로 시작합니다.&lt;br /&gt;
&lt;br /&gt;
* 매일 백업, 14일 보관&lt;br /&gt;
* 매주 백업, 8주 보관&lt;br /&gt;
* 이미지·의존성 업데이트나 자격 증명 변경 전 백업&lt;br /&gt;
* 분기마다 격리 복원 시험&lt;br /&gt;
실제 복구 시점 목표는 운영 상황에 맞게 정합니다. 스케줄러 종료 상태와 디스크 여유 공간을 확인하세요. 오류 알림이 없는 예약 명령만으로는 충분하지 않습니다. 새 복원을 확인하기 전에 가장 최근의 정상 백업을 지우지 마세요.&lt;br /&gt;
&lt;br /&gt;
==== Cloudflare R2 자동 백업 ====&lt;br /&gt;
&lt;br /&gt;
이 서버는 다음 비공개 R2 bucket의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;restic&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; prefix를 원격 백업 저장소로 사용합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
https://96f01030a087d07d906bff299d260155.r2.cloudflarestorage.com/s3rm-backups&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
백업 데이터는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;restic&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 서버에서 먼저 암호화한 뒤 전송합니다. R2 Access Key, Secret Key와 restic 암호는 Git이나 프로젝트 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 넣지 않습니다.&lt;br /&gt;
&lt;br /&gt;
서버에 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;restic&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 설치한 뒤 root 권한으로 설정 파일을 준비합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
sudo install -d -m 0700 /etc/s3-research-memory /srv/s3rm-backups&lt;br /&gt;
sudo install -m 0600 config/remote-backup.env.example \&lt;br /&gt;
  /etc/s3-research-memory/remote-backup.env&lt;br /&gt;
sudo sh -c &#039;umask 077; openssl rand -base64 48 &amp;gt; /etc/s3-research-memory/restic-password&#039;&lt;br /&gt;
sudoedit /etc/s3-research-memory/remote-backup.env&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
R2에서 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;s3rm-backups&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; bucket에만 목록 조회·읽기·쓰기·삭제가 가능한 S3 API 자격 증명을 만들고 설정 파일의 두 placeholder를 바꿉니다. 삭제 권한은 오래된 restic snapshot과 pack을 보존 정책에 맞게 정리할 때 필요합니다. restic 암호 파일은 서버 장애 때도 찾을 수 있도록 서버 밖의 비밀번호 관리자에 따로 보관합니다. 이 암호를 잃으면 R2의 백업을 복구할 수 없습니다.&lt;br /&gt;
&lt;br /&gt;
현재 live restic repository의 자격 증명은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;forget&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;·&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;prune&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 위해 삭제 권한도 가집니다. 따라서 &#039;&#039;&#039;R2 자격 증명 탈취와 오작동으로부터의 불변 백업은 현재 저장소 하나만으로 달성되지 않습니다.&#039;&#039;&#039; 이 제한은 복원 시험 기록에 항상 남깁니다.&lt;br /&gt;
&lt;br /&gt;
R2의 lock·retention을 live restic prefix에 바로 적용하면 임시 lock 제거와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;forget&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;·&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;prune&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 실패할 수 있습니다. 삭제 방어는 다음과 같이 &#039;&#039;&#039;별도 보호 복사본&#039;&#039;&#039;으로 구성합니다.&lt;br /&gt;
&lt;br /&gt;
# live &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;restic&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; prefix는 현재 보존 정책으로 검증·정리합니다.&lt;br /&gt;
# 검증한 복구 세트 또는 restic repository 세대를 별도 bucket·account의 보호 prefix로 복사합니다. 복사 writer와 retention 변경 권한을 분리합니다.&lt;br /&gt;
# 보호 복사본은 일간 운영 키로 삭제할 수 없게 만들고, 예외 해제 권한은 다른 계정에 보관합니다.&lt;br /&gt;
# Cloudflare 계정 MFA, bucket 범위 API token, 삭제·retention 변경 알림, 분기별 보호 복사본 복원 시험을 함께 적용합니다.&lt;br /&gt;
별도 bucket/retention/API token은 Cloudflare 외부 설정이므로 이 저장소의 Compose나 systemd 파일만으로는 활성화할 수 없습니다. 이 설정이 완료되기 전에는 live R2를 유일한 삭제 방어 본으로 간주하지 마세요.&lt;br /&gt;
&lt;br /&gt;
원격 저장소는 한 번만 초기화합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
sudo bash -c &#039;&lt;br /&gt;
  set -a&lt;br /&gt;
  source /etc/s3-research-memory/remote-backup.env&lt;br /&gt;
  set +a&lt;br /&gt;
  exec /home/mrcha033/Projects/s3wiki/scripts/init-remote-backup.sh&lt;br /&gt;
&#039;&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
systemd 단위를 설치하고 첫 백업을 수동으로 확인합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
sudo install -m 0644 deploy/systemd/s3rm-remote-backup.service \&lt;br /&gt;
  /etc/systemd/system/s3rm-remote-backup.service&lt;br /&gt;
sudo install -m 0644 deploy/systemd/s3rm-remote-backup.timer \&lt;br /&gt;
  /etc/systemd/system/s3rm-remote-backup.timer&lt;br /&gt;
sudo systemctl daemon-reload&lt;br /&gt;
sudo systemctl start s3rm-remote-backup.service&lt;br /&gt;
sudo systemctl status s3rm-remote-backup.service&lt;br /&gt;
sudo systemctl enable --now s3rm-remote-backup.timer&lt;br /&gt;
systemctl list-timers s3rm-remote-backup.timer&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
작업은 매일 서울 시각 03:15 이후 45분 안에 실행합니다. 성공한 원격 snapshot은 일간 14개와 주간 8개를 보존하고, 원격 pack 정리는 일요일에 실행합니다. 전송이나 검증이 실패하면 로컬 복구 세트를 지우지 않습니다. 성공하면 R2에 이미 암호화된 snapshot을 확인한 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/srv/s3rm-backups&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 해당 임시 세트를 정리합니다. snapshot 경로에는 매번 다른 시각이 들어가므로 restic 보존 그룹은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;host,tags&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로만 묶습니다. 경로까지 그룹 기준에 넣으면 각 snapshot이 항상 자신만의 그룹이 되어 14일·8주 정책이 아무 것도 만료시키지 못합니다.&lt;br /&gt;
&lt;br /&gt;
상태와 최근 로그를 정기적으로 확인합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
systemctl status s3rm-remote-backup.timer&lt;br /&gt;
sudo journalctl -u s3rm-remote-backup.service --since &#039;2 days ago&#039;&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
원격 snapshot을 복구할 때는 빈 root 전용 디렉터리에 먼저 내려받고 기존 복원 스크립트로 넘깁니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
sudo install -d -m 0700 /srv/s3rm-remote-restore&lt;br /&gt;
sudo bash -c &#039;&lt;br /&gt;
  set -a&lt;br /&gt;
  source /etc/s3-research-memory/remote-backup.env&lt;br /&gt;
  set +a&lt;br /&gt;
  restic snapshots --tag s3rm-recovery-set&lt;br /&gt;
  restic restore latest --tag s3rm-recovery-set --target /srv/s3rm-remote-restore&lt;br /&gt;
&#039;&lt;br /&gt;
sudo find /srv/s3rm-remote-restore -maxdepth 2 -type f -name SHA256SUMS -print&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
출력된 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;SHA256SUMS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 상위 디렉터리가 한 개이고 원하는 시각의 복구 세트인지 확인한 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;scripts/restore.sh &amp;amp;lt;그 디렉터리&amp;amp;gt; --yes&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 사용합니다. 운영 서버를 덮어쓰기 전에 격리 복원 시험을 먼저 수행합니다.&lt;br /&gt;
&lt;br /&gt;
=== Lesson 내용 정기 감사 ===&lt;br /&gt;
&lt;br /&gt;
읽기 전용 감사 작업은 매주 운영 위키의 안정된 Lesson을 중앙 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 읽습니다. 실행 전 30분 안에 바뀐 Lesson은 다음 감사로 미룹니다. 별도 Lesson 저장소나 기준 파일을 만들지 않습니다. 쓰기 토큰도 사용하지 않습니다. 외부 근거 URL을 열거나 Lesson을 자동으로 수정하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
감사 작업은 다음 항목을 확인합니다.&lt;br /&gt;
&lt;br /&gt;
* 구조화된 근거가 없는 Lesson&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;high&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; confidence인데 최신 append-only 검증 기록이 observation, interpretation, reusable_lesson 전부를 strong &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supports&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 뒷받침하지 않는 Lesson&lt;br /&gt;
* 확인 범위가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;not_recorded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;인 근거&lt;br /&gt;
* 같거나 거꾸로 된 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;verified_at&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 append 순서의 현재 판정을 정할 수 없는 Lesson&lt;br /&gt;
* 서로 같은 관찰·해석 또는 재사용 내용·적용 조건&lt;br /&gt;
* 대상이 없는 relation과 중복 relation&lt;br /&gt;
* 365일 이상 바뀌지 않은 검토 후보&lt;br /&gt;
* 비밀 키나 토큰 형식으로 보이는 문자열&lt;br /&gt;
발견 결과에는 Lesson ID, revision ID, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;codes&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 배열과 상한이 있는 evidence·verification ID만 남깁니다. Lesson 본문, 인용과 relation note는 journal에 남기지 않습니다. 같은 Lesson의 발견 코드는 한 줄로 묶습니다. 오래된 Lesson이라는 이유만으로 틀렸다고 판정하지 않으며 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;stale_review_candidate&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로만 표시합니다. 감사는 저장된 구조, digest, append 순서와 검증 수령 기록의 일관성을 확인합니다. 외부 근거 URL을 열지 않으므로 논문·코드·데이터의 사실 진위를 자동으로 판정하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
systemd 단위를 설치하고 첫 감사를 수동으로 실행합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
sudo install -D -m 0755 scripts/audit-lessons.py \&lt;br /&gt;
  /usr/local/libexec/s3rm-content-audit&lt;br /&gt;
sudo install -m 0644 deploy/systemd/s3rm-content-audit.service \&lt;br /&gt;
  /etc/systemd/system/s3rm-content-audit.service&lt;br /&gt;
sudo install -m 0644 deploy/systemd/s3rm-content-audit.timer \&lt;br /&gt;
  /etc/systemd/system/s3rm-content-audit.timer&lt;br /&gt;
sudo systemctl daemon-reload&lt;br /&gt;
sudo systemctl start s3rm-content-audit.service&lt;br /&gt;
sudo systemctl status s3rm-content-audit.service&lt;br /&gt;
sudo journalctl -u s3rm-content-audit.service --since today&lt;br /&gt;
sudo systemctl enable --now s3rm-content-audit.timer&lt;br /&gt;
systemctl list-timers s3rm-content-audit.timer&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
작업은 일요일 서울 시각 04:30 이후 30분 안에 실행합니다. 놓친 실행은 서버가 다시 켜졌을 때 실행합니다. 배포 단위와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;make audit-lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 현재 서버의 scan limit과 같은 500개 Lesson을 읽습니다. 스크립트 자체의 기본값은 200개이고 설정 가능한 절대 상한은 1000개입니다. Lesson당 근거·relation은 각각 최대 200개를 읽습니다. 서버의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;scan_limit&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 목록이 잘리거나 감사 중 revision과 Lesson 목록이 바뀌면 완료로 기록하지 않습니다. 연결 오류나 동시 변경으로 끝나면 5분 간격으로 최대 세 번 시도합니다. 발견 사항을 뜻하는 종료 코드 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;2&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 다시 시도하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
종료 코드는 다음과 같습니다.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 코드 !! 뜻 !! 다음 작업&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;0&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 감사를 완료했고 발견 사항이 없음 || 다음 예약 실행을 기다립니다.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 연결, 출력 한도 또는 동시 변경으로 감사를 완료하지 못함 || journal의 마지막 오류를 확인하고 다시 실행합니다.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;2&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 감사를 완료했고 확인할 항목이 있음 || 발견된 Lesson을 읽고 근거를 확인합니다.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;systemd&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 서비스는 임시 계정으로 실행합니다. 홈 디렉터리, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, Docker 소켓과 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/etc/s3-research-memory&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 읽을 수 없습니다. 발견 사항이 있으면 실패 상태로 표시합니다. 이 상태는 Lesson을 바꾸지 않으며 다음 예약 실행도 막지 않습니다. 문제를 고칠 때는 근거를 먼저 붙인 뒤 같은 Lesson ID에 현재 revision을 기준으로 새 revision을 추가합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supersedes&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;나 근거 note를 기존 주장 수정 수단으로 쓰지 않습니다.&lt;br /&gt;
&lt;br /&gt;
예약과 무관하게 현재 상태를 확인할 때는 다음 명령을 사용합니다. 발견 사항이 있어도 수동 명령은 감사 완료 뒤 코드 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;0&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 반환합니다. 자동 점검과 같은 종료 코드가 필요하면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--fail-on-findings&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 추가합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
make audit-lessons&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 복원 전 주의 ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;scripts/restore.sh&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 현재 DB, 업로드, 생성된 위키 설정을 교체합니다. 명시적인 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--yes&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 필요하며, 공지한 점검 시간에만 실행합니다. 병합이나 페이지 가져오기 도구가 아닙니다.&lt;br /&gt;
&lt;br /&gt;
복원 전에 다음을 확인합니다.&lt;br /&gt;
&lt;br /&gt;
* 정확한 백업 디렉터리를 정하고 모든 checksum을 확인합니다.&lt;br /&gt;
* 백업의 MediaWiki·애플리케이션 버전이 현재 checkout과 호환되는지 확인합니다. 새 DB를 더 오래된 코드에 복원할 수 없습니다.&lt;br /&gt;
* 점검 시간을 알리고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 멈춘 뒤 브라우저 편집도 중단합니다.&lt;br /&gt;
* 현재 상태를 읽을 수 있으면 복원 직전 백업을 하나 더 만듭니다.&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 복원할 인스턴스와 맞는 자격 증명이 있는지 확인합니다.&lt;br /&gt;
* 복원을 결정한 사람과 이유를 기록합니다.&lt;br /&gt;
=== 현재 서버 복원 ===&lt;br /&gt;
&lt;br /&gt;
백업을 만든 것과 같은 저장소 버전에서 실행합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose --profile domain stop caddy mcp-http&lt;br /&gt;
./scripts/restore.sh /ABS/PATH/TO/BACKUP-DIRECTORY --yes&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
스크립트는 checksum manifest, 백업 형식 버전, 압축 stream, 모든 아카이브 member의 경로와 종류를 먼저 확인합니다. 보조 파일이 있는 형식은 auxiliary manifest digest와 binary-log·source artifact도 모두 확인합니다. 백업의 DB 이름과 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;MARIADB_DATABASE&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;도 같아야 합니다. 다르면 덤프를 편집하지 말고 복구용 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 맞춥니다. 이 검증과 이미지 빌드 사전 확인이 모두 끝난 뒤에만 애플리케이션을 멈추고 기준 DB·설정·업로드를 교체합니다.&lt;br /&gt;
&lt;br /&gt;
기준 dump import는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;sql_log_bin=0&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 세션에서 실행합니다. 이어서 DB를 멈추고 기존 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db_binlogs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 &#039;&#039;&#039;더 새로운 운영 timeline을 비운 뒤&#039;&#039;&#039; 새 local timeline으로 시작합니다. 따라서 과거 DB에 미래 binlog가 이어지는 일은 없지만, backup의 archived binlog를 자동 replay하지도 않습니다. PITR이 필요하면 운영 복원 전에 별도 격리 환경에서 목표 UTC·종료 position을 정해 검증한 절차로만 적용합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 시작 때 MediaWiki·확장 기능 마이그레이션을 적용하고, 이어서 반복 실행이 가능한 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 적용합니다. 그 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-job-state-init&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 강제로 다시 실행해 새 복구 볼륨을 UID/GID 33 쓰기 경로로 만든 다음 bounded &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-jobs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 시작해 worker health까지 확인합니다. 복구 중 MediaWiki host bind는 임시로 loopback에 강제하고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;caddy&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 성공 후에도 중지한 상태로 둡니다. 따라서 마이그레이션· bootstrap 중 공개 브라우저나 MCP 쓰기가 재개되지 않습니다. 교체 경고가 나온 뒤에는 중단하지 마세요. 오류가 나오면 클라이언트를 멈춘 상태로 로그와 소스 백업을 보존합니다. 실패한 상태 위에 부분 SQL이나 일부 볼륨을 임의로 덮어쓰지 마세요.&lt;br /&gt;
&lt;br /&gt;
완료 뒤 상태를 확인합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose ps -a&lt;br /&gt;
docker compose logs mediawiki bootstrap mediawiki-jobs&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
아래 MediaWiki 복구 확인을 마칠 때까지 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 멈춰 둡니다. 확인 뒤 현재 설정으로 공용 어댑터를 다시 만들고, 공개 조회와 토큰 기반 변경을 모두 시험합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose --profile domain up -d --force-recreate mediawiki mcp-http caddy&lt;br /&gt;
docker compose ps mediawiki-jobs mcp-http&lt;br /&gt;
docker compose logs --tail=100 mcp-http&lt;br /&gt;
./scripts/verify-public-domain.sh s3wiki.yonsei.ac.kr&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 격리 복원 시험 ===&lt;br /&gt;
&lt;br /&gt;
실제 서버를 덮어쓰지 말고 별도 Compose 프로젝트와 사용하지 않는 포트에서 시험합니다. 별도 checkout을 사용하고, 복구 디렉터리는 이동하지 말고 복사합니다. Compose 프로젝트와 볼륨 이름을 고유하게 지정하세요. 최상위 이름은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;COMPOSE_PROJECT_NAME&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 바꿀 수 있으며 두 스크립트가 이 값을 사용합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;COMPOSE&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 환경 변수에는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;docker compose --ansi never&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;처럼 고정 접두사를 넣을 수 있습니다. 설치, 백업, 복원에 같은 값을 사용하세요.&lt;br /&gt;
&lt;br /&gt;
권장 경로는 전용 검증 스크립트입니다. 운영 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 아닌 mode &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;0600&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 일회용 env에 사용하지 않는 loopback 포트, 일회용 DB·wiki 비밀번호, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;MW_SERVER_URL=http://127.0.0.1:18080&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;과 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_WIKI_BIND_ADDRESS=127.0.0.1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_HTTP_PORT=18080&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 넣습니다. 복원용 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_WIKI_BOT_PASSWORD&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;도 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;openssl rand -hex 16&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 따로 만들고, 운영 BotPassword와 primary 위키 비밀번호를 재사용하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
chmod 0600 /srv/s3rm-restore-test.env&lt;br /&gt;
./scripts/verify-restore-isolated.sh /ABS/PATH/TO/BACKUP-DIRECTORY \&lt;br /&gt;
  --env-file /srv/s3rm-restore-test.env \&lt;br /&gt;
  --project-name s3rm-restore-2026q3&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
스크립트는 project 접두사, 새 project에 기존 컨테이너·볼륨이 없는지, loopback 주소, 기본 checksum과, 있는 경우 PITR 보조 checksum 체인을 &#039;&#039;&#039;변경 전&#039;&#039;&#039; 확인합니다. 격리 project 이름으로 전용 MediaWiki image tag를 만들어 운영 image tag를 동시에 다시 빌드하지 않습니다. 복원 후 Lesson 개수는 정확히 일치해야 하고, revision은 bootstrap이 관리 페이지 변경을 추가할 수 있으므로 백업 기록 &#039;&#039;&#039;이상&#039;&#039;&#039;이어야 합니다. MediaWiki API, bootstrap, bounded &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-jobs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; health도 확인합니다. 성공하면 격리 볼륨을 지우고, 실패하면 진단을 위해 보존합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--keep&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 사용하면 성공한 환경도 보존합니다. 이 검증은 스키마·개수·시작 경계를 자동 확인하지만, 아래의 사람이 열어 보는 대표 Lesson·업로드·검색 확인을 대신하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
Docker가 설치된 일회용 VM에서는 다음 순서가 안전합니다.&lt;br /&gt;
&lt;br /&gt;
# 백업과 정확히 같은 저장소 revision, 복구 디렉터리, 복구용 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 VM에 복사합니다.&lt;br /&gt;
# &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;COMPOSE_PROFILES=&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 실제 hostname과 80/443을 사용하지 않게 합니다. 겹치지 않는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_HTTP_PORT&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 맞는 로컬 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;MW_SERVER_URL&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 설정합니다. 별도 loopback &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_HTTP_PORT&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;·&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_PUBLIC_URL&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;과 일회용 MCP 토큰도 사용합니다. 실제 MCP URL이나 토큰을 복원 시험에 쓰지 마세요.&lt;br /&gt;
# VM에서 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;scripts/restore.sh &amp;amp;lt;backup-directory&amp;amp;gt; --yes&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 실행합니다.&lt;br /&gt;
# 아래 복구 확인을 모두 수행합니다.&lt;br /&gt;
# 결과를 기록한 뒤 VM을 지웁니다. 원본 복구 세트는 그대로 둡니다.&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;docker compose config&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;docker volume ls&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 볼륨 이름이 다른지 보기 전에는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;COMPOSE_PROJECT_NAME&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;만 바꿨다고 격리된 것으로 보지 마세요.&lt;br /&gt;
&lt;br /&gt;
=== 복구 완료 확인 ===&lt;br /&gt;
&lt;br /&gt;
다음 항목을 모두 통과해야 복원이 끝납니다.&lt;br /&gt;
&lt;br /&gt;
# &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-jobs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 healthy이고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-install&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;과 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 코드 0으로 종료되었습니다.&lt;br /&gt;
# &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Special:Version&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 예상한 MediaWiki, Semantic MediaWiki, Page Forms, S3 Research Memory 버전이 표시됩니다.&lt;br /&gt;
# Lesson 페이지 수와 최근 revision 수가 백업 기록과 맞습니다.&lt;br /&gt;
# 복원된 Lesson의 필드, 근거, append-only 검증 기록, 관계, 출처, 호환 필드, 시간이 정상 표시됩니다.&lt;br /&gt;
# &#039;&#039;&#039;역사 보기&#039;&#039;&#039;에서 이전 revision 하나 이상이 열립니다.&lt;br /&gt;
# 업로드한 근거가 열리고, 외부 checksum을 기록했다면 값이 맞습니다.&lt;br /&gt;
# 전문 검색이 알려진 제목과 본문 단어를 찾습니다.&lt;br /&gt;
# 격리한 복원 시험 환경에서만 일반 브라우저 계정으로 Lesson 양식을 열고 복구 확인용 메모를 저장합니다. 바로 검색되는지 본 뒤 격리 환경과 함께 폐기합니다.&lt;br /&gt;
# 다시 만든 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 healthy입니다. 토큰 없이 초기화·도구 목록· &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;search_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 실행하면 짧은 인용 결과가 나옵니다. 쓰기 토큰을 넣은 클라이언트는 같은 격리 환경에 확인용 메모를 만들 수 있습니다. 운영 위키에는 확인용 데이터를 만들지 않습니다.&lt;br /&gt;
# 따로 설정한 두 번째 클라이언트가 같은 Lesson을 조회합니다. 두 클라이언트가 로컬 데이터가 아닌 복원된 위키 하나를 보고 있어야 합니다.&lt;br /&gt;
# MCP 계정은 여전히 삭제, 이동, 보호, 관리 기능을 사용할 수 없습니다.&lt;br /&gt;
복구 디렉터리, 소요 시간, 애플리케이션 revision, 결과, 실행자, 필요했던 조치를 기록합니다. 하나라도 실패하면 점검 상태를 유지합니다.&lt;br /&gt;
&lt;br /&gt;
=== 평소 상태 확인 ===&lt;br /&gt;
&lt;br /&gt;
상태와 제한된 길이의 로그를 확인합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose ps -a&lt;br /&gt;
docker compose logs --tail=200 db mediawiki-install mediawiki bootstrap \&lt;br /&gt;
  mediawiki-jobs mcp-http caddy&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
로컬 프로세스 경로와 두 공개 경로를 차례로 확인합니다. 로컬 확인만으로 다른 PC의 HTTPS 접속을 대신할 수 없습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
curl --fail --silent --show-error \&lt;br /&gt;
  &#039;http://localhost:8080/api.php?action=query&amp;amp;meta=siteinfo&amp;amp;format=json&#039; \&lt;br /&gt;
  &amp;gt;/dev/null&lt;br /&gt;
curl --fail --silent --show-error \&lt;br /&gt;
  &#039;http://127.0.0.1:8765/healthz&#039; \&lt;br /&gt;
  | jq --exit-status \&lt;br /&gt;
      &#039;.canonical_store == &amp;quot;ok&amp;quot; and&lt;br /&gt;
       .job_runner == &amp;quot;ok&amp;quot; and&lt;br /&gt;
       .write_admission.active &amp;gt;= 0 and .write_admission.waiting &amp;gt;= 0&#039; \&lt;br /&gt;
  &amp;gt;/dev/null&lt;br /&gt;
curl --fail --silent --show-error \&lt;br /&gt;
  &#039;https://s3wiki.yonsei.ac.kr/api.php?action=query&amp;amp;meta=siteinfo&amp;amp;format=json&#039; \&lt;br /&gt;
  &amp;gt;/dev/null&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
[[Help:S3 연구 메모리/설치와 검증#클라이언트를 연결하기 전 공용 MCP 확인|&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;setup.md&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;]]의 토큰 없는 MCP 초기화도 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;https://s3wiki.yonsei.ac.kr/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 실행합니다. 단순 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;GET /mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 streaming 연결을 계속 열어 둘 수 있으므로 상태 확인이 아닙니다.&lt;br /&gt;
&lt;br /&gt;
같은 서버에서 위 확인을 한 번에 실행하려면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;make verify-domain&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 사용합니다. 이 결과만으로 학교 밖 인바운드 경로까지 확인되지는 않습니다.&lt;br /&gt;
&lt;br /&gt;
Compose 운영의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/healthz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 canonical store와 bounded job runner가 모두 준비돼야 200을 반환합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;job_runner=unavailable&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이면 공유 상태 파일의 generation·최근 성공 시각·권한을 먼저 확인합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;write_admission&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에는 비밀값 없이 현재 active·waiting·principal 수와 동시 실행·queue 상한이 나옵니다. 기본은 전체 동시 쓰기 8개, queue 64개·2초, principal별 지속 600회/분·burst 120입니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;open&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드에서는 client IP가 principal이므로 인증 모드를 바꾸지 않아도 사용자별 admission이 적용됩니다. 지표가 계속 상한에 붙거나 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;server_busy&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 반복되면 DB latency와 실패 요청을 먼저 확인하고, 변경한 상한은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 다시 만들어 적용합니다. 이 queue는 재시작 뒤 작업을 복구하는 durable queue가 아닙니다. 전달된 IP는 Caddy와 MCP의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_PROXY_SECRET&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 맞을 때만 신뢰합니다. 이 값을 교체할 때는 둘을 같은 Compose 재생성에서 함께 전환합니다.&lt;br /&gt;
&lt;br /&gt;
MediaWiki job queue는 전용 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-jobs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 서비스가 기본 25건·20초, 1 process로 처리합니다. 상태 파일은 비밀 값이 없고 현재 queue 깊이, 마지막 성공 시각, 연속 실패 수, batch/idle 상한과 현재 runner generation을 담습니다. generation은 컨테이너 PID 1의 시작 시각에서 만들므로 이전 컨테이너나 복구 전 상태 파일은 새 runner health로 인정되지 않습니다. 상태 볼륨은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-job-state-init&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 UID/GID 33으로 준비하고 MCP에는 읽기 전용으로만 연결합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose ps mediawiki-jobs&lt;br /&gt;
docker compose ps -a mediawiki-job-state-init&lt;br /&gt;
docker compose exec mediawiki-jobs cat /run/s3rm-jobs/status.env&lt;br /&gt;
docker compose exec mediawiki-jobs /usr/local/bin/s3-mediawiki-jobs-health&lt;br /&gt;
docker compose exec mediawiki \&lt;br /&gt;
  php maintenance/run.php showJobs --group&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
runner가 실패하면 15초에서 시작해 최대 5분까지 지수 backoff합니다. 마지막 성공이 기본 120초보다 오래되면 health가 실패합니다. queue를 빠르게 비우기 위해 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--nothrottle&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이나 다중 process를 임의로 사용하지 마세요. 먼저 실패 job 종류와 DB·디스크 상태를 확인하고, 필요한 처리량만 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 조정합니다.&lt;br /&gt;
&lt;br /&gt;
Compose는 각 주요 서비스의 json-file 로그를 파일당 10MB, 최대 5개로 회전하고, PID·메모리·CPU 상한을 둡니다. 평소 사용량과 OOM·재시작 여부를 함께 확인합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker stats --no-stream&lt;br /&gt;
docker inspect --format \&lt;br /&gt;
  &#039;{{.Name}} oom={{.State.OOMKilled}} restarts={{.RestartCount}}&#039; \&lt;br /&gt;
  $(docker compose ps -q)&lt;br /&gt;
docker system df&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
상한 도달은 health check 성공만으로 보이지 않을 수 있습니다. OOM이나 PID 부족을 확인한 뒤 queue·동시성·실패 loop 원인을 먼저 고치고, 측정한 정상 peak와 호스트 여유를 근거로 상한을 조정합니다. 로그 회전 파일은 장기 감사 자료가 아니므로 필요한 운영 event는 별도 제한된 저장소에 보관합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Special:RecentChanges&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 계정 그룹, 로그인 실패, MCP 쓰기 인증 실패와 403/421, 디스크 여유 공간, 백업 시각, 복원 시험 상태를 확인합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 관리하는 스키마·양식 페이지가 반복해서 달라지면 원인을 찾으세요. 일반 Lesson revision은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 바꾸지 않습니다.&lt;br /&gt;
&lt;br /&gt;
=== 공용 MCP 운영 ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에는 영구 지식이 없습니다. 정지하거나 다시 만들면 진행 중인 도구 호출이 끊기고 클라이언트가 재연결해야 하지만 Lesson이나 revision은 없어지지 않습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose stop mcp-http&lt;br /&gt;
docker compose up -d mcp-http&lt;br /&gt;
docker compose up -d --force-recreate mcp-http&lt;br /&gt;
docker compose ps mcp-http&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
일시적인 프로세스 오류에는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;restart&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 사용합니다. 토큰 해시, 공개 URL, bind·port, allowlist, 위키 자격 증명, 이미지를 바꿨다면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--force-recreate&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 사용합니다. 공개 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 복제본을 여러 개 띄우지 마세요. 현재 서비스에는 load balancer나 session 공유 설계가 없습니다. 읽기 요청은 재연결 뒤 다시 시도해도 됩니다. 시간이 초과된 변경 요청은 결과를 먼저 확인한 뒤 다시 보내세요.&lt;br /&gt;
&lt;br /&gt;
=== 공개 HTTPS ===&lt;br /&gt;
&lt;br /&gt;
Caddy는 Compose &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;domain&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 프로필에서 실행하며 공개 포트를 사용합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose ps caddy&lt;br /&gt;
docker compose logs --tail=200 caddy&lt;br /&gt;
docker compose up -d --force-recreate caddy&lt;br /&gt;
make verify-domain&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_PUBLIC_HOST&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;나 저장소의 Caddyfile을 바꾼 뒤 Caddy를 다시 만듭니다. 인증서 발급을 다시 시도하려고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;caddy_data&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 지우지 마세요. 먼저 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;s3wiki.yonsei.ac.kr&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;165.132.118.220&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 가리키는지, 다른 프로세스가 80/443을 쓰는지, 두 포트가 외부에서 서버까지 들어오는지 확인합니다. Caddy는 원래 Host 헤더를 보존하며 MCP는 이를 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_PUBLIC_URL=https://s3wiki.yonsei.ac.kr/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 대조합니다. 학교 밖 네트워크의 브라우저 확인도 별도로 해야 합니다.&lt;br /&gt;
&lt;br /&gt;
==== 공용 MCP 토큰 교체 ====&lt;br /&gt;
&lt;br /&gt;
이 파일럿은 토큰 해시 하나만 받으므로 교체 시 모든 쓰기 클라이언트의 기존 토큰이 한 번에 무효가 됩니다. 공개 조회는 계속됩니다. 변경이 잠시 끊긴다고 알리고 새 서비스 확인이 끝날 때까지 기존 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;client.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 보관하세요.&lt;br /&gt;
&lt;br /&gt;
# 원문을 출력하지 않고 새 토큰과 해시를 만듭니다. ```bash ./scripts/configure-mcp-token.sh --rotate ``` 스크립트는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 해시만 바꾸고, 원문 토큰과 URL은 mode &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;0600&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;~/.config/s3-research-memory/client.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 씁니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--rotate&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 없으면 기존 토큰과 해시가 맞는지 확인하고 그대로 둡니다.&lt;br /&gt;
# 공용 어댑터만 다시 만듭니다. ```bash docker compose up -d --force-recreate mcp-http docker compose ps mcp-http docker compose logs --tail=100 mcp-http ```&lt;br /&gt;
# 토큰 없이 초기화, 도구 목록, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;search_lessons(limit=1)&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 확인합니다. 이어서 신뢰하는 셸에서 새 비공개 파일을 불러오고 [[Help:S3 연구 메모리/설치와 검증|&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;setup.md&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;]]의 쓰기 확인을 실행합니다.&lt;br /&gt;
# 새 원문 토큰 또는 클라이언트 환경을 쓰기 클라이언트에 전달하고 재연결합니다. 공개 조회 클라이언트는 바꿀 것이 없습니다. DB·위키 비밀번호와 쓸 수 없는 토큰 해시가 든 서버 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 전달하지 마세요.&lt;br /&gt;
새 서비스를 확인하기 전에 문제가 생겼다면 이전 해시와 그에 맞는 비공개 토큰을 복구하고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 다시 만듭니다. 변경 인증을 끄는 방식으로 되돌리지 마세요.&lt;br /&gt;
&lt;br /&gt;
=== 애플리케이션 업데이트와 되돌리기 ===&lt;br /&gt;
&lt;br /&gt;
업데이트 전에 이름을 붙인 백업을 만들고 복원을 시험합니다. 새 이미지를 빌드하고 DB 마이그레이션과 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 완료한 뒤 복구 완료 항목을 확인합니다. 새 release가 DB를 마이그레이션했다면 이미지만 낮춰서는 안 됩니다. 이전 저장소 revision과 함께 업데이트 전 DB, 업로드, 설정을 한 세트로 복원해야 합니다.&lt;br /&gt;
&lt;br /&gt;
기본 제약은 MediaWiki [https://www.mediawiki.org/wiki/Manual:Restoring_a_wiki_from_backup 복원 안내]와 [https://www.mediawiki.org/wiki/Manual:Upgrading 업그레이드 안내]를 참고하세요.&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC/%EC%84%A4%EC%B9%98%EC%99%80_%EA%B2%80%EC%A6%9D&amp;diff=1724</id>
		<title>도움말:S3 연구 메모리/설치와 검증</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC/%EC%84%A4%EC%B9%98%EC%99%80_%EA%B2%80%EC%A6%9D&amp;diff=1724"/>
		<updated>2026-07-18T10:09:46Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 저장소 문서 동기화&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;!-- S3RM managed repository document; edit the Markdown source, not this page. --&amp;gt;&lt;br /&gt;
&#039;&#039;이 페이지는 저장소 문서에서 자동으로 동기화됩니다. 위키에서 직접 편집하지 마세요.&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
한 대의 서버에서 위키와 공용 MCP를 함께 실행합니다. 브라우저는 [https://s3wiki.yonsei.ac.kr/]에 접속하고, 모든 에이전트는 [https://s3wiki.yonsei.ac.kr/mcp]에 연결합니다. 두 주소는 하나의 Semantic MediaWiki를 사용합니다. 클라이언트 PC에는 이 저장소, Docker 권한, MediaWiki 에이전트 비밀번호가 필요하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
데이터베이스, 생성된 위키 설정, 업로드 파일은 Docker 볼륨에 저장합니다. 저장소에는 데이터, 자격 증명, 생성된 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;LocalSettings.php&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 넣지 않습니다.&lt;br /&gt;
&lt;br /&gt;
=== 준비 사항 ===&lt;br /&gt;
&lt;br /&gt;
* Docker Engine 24 이상과 Docker Compose v2 플러그인 또는 최신 Docker Desktop&lt;br /&gt;
* Python 3.11 이상과 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;uv&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
* 메모리 4 GiB 이상, 디스크 여유 공간 8 GiB 이상&lt;br /&gt;
* Bash, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;make&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;openssl&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
* 백업·복원용 GNU/Linux 호스트: Bash 4 이상, GNU coreutils(&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;stat&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;sha256sum&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;), GNU tar, gzip, 일반 파일 권한 지원&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;s3wiki.yonsei.ac.kr&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 서버의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;165.132.118.220&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 연결된 DNS&lt;br /&gt;
* 서버까지 들어오는 TCP 80, 443&lt;br /&gt;
macOS와 Windows의 Docker Desktop은 화면 확인에 사용할 수 있습니다. 백업·복원 스크립트는 이 Compose 프로젝트를 실행하는 Linux VM이나 Linux Docker 호스트에서 실행하세요. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;uv&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 없다면 [https://docs.astral.sh/uv/getting-started/installation/ 공식 설치 안내]를 따릅니다.&lt;br /&gt;
&lt;br /&gt;
필수 명령을 확인합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker version&lt;br /&gt;
docker compose version&lt;br /&gt;
make --version&lt;br /&gt;
bash --version&lt;br /&gt;
sha256sum --version&lt;br /&gt;
uv --version&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;docker version&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 클라이언트 버전만 출력하지 않고 Docker 데몬에도 연결되어야 합니다. 이 확인 전에 도메인 설정 스크립트를 실행하지 마세요. 첫 빌드에서는 이미지와 고정된 PHP·Python 의존성을 내려받으므로 인터넷 연결이 필요합니다.&lt;br /&gt;
&lt;br /&gt;
일반 운영 경로는 위 HTTPS 도메인 하나입니다. 읽기와 검색은 공개됩니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_WRITE_AUTH_MODE=token&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이면 생성·관계·근거 첨부·검증과 기존 Lesson의 조건부 수정에 같은 공용 Bearer 토큰이 필요합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;open&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 다섯 변경 도구를 인증 없이 허용합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;github&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 GitHub 로그인을 거쳐 MCP용 access·refresh token을 발급하며 이 모드에서는 MCP 연결 전체가 OAuth 인증 대상입니다. Caddy는 TLS를 종료하고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 OAuth 경로를 MCP 서비스로 보냅니다. 나머지 경로는 MediaWiki로 보냅니다.&lt;br /&gt;
&lt;br /&gt;
기본값은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;token&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;입니다. 짧은 공개 시험에서만 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 다음 값을 넣고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 다시 만드세요.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
S3RM_MCP_WRITE_AUTH_MODE=open&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
다시 잠그려면 값을 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;token&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 바꿉니다. 기존 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_TOKEN_SHA256&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 지우지 않아도 되며 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;open&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드에서는 쓰기에 사용하지 않습니다. 사용자 연결은 [[Help:S3 연구 메모리/Linux 연결|Linux]], [[Help:S3 연구 메모리/macOS 연결|macOS]], [[Help:S3 연구 메모리/Windows 연결|Windows]] 문서에 있고 제품별 제약은 [[Help:S3 연구 메모리/MCP 클라이언트|&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-clients.md&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;]]에 있습니다.&lt;br /&gt;
&lt;br /&gt;
==== GitHub OAuth 모드 준비 ====&lt;br /&gt;
&lt;br /&gt;
GitHub의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Settings → Developer settings → OAuth Apps&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 앱을 만들고 callback URL을 정확히 다음 값으로 등록합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
https://s3wiki.yonsei.ac.kr/oauth/github/callback&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
그다음 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 설정합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
S3RM_MCP_WRITE_AUTH_MODE=github&lt;br /&gt;
S3RM_GITHUB_CLIENT_ID=발급받은-client-id&lt;br /&gt;
S3RM_GITHUB_CLIENT_SECRET=발급받은-client-secret&lt;br /&gt;
S3RM_GITHUB_ALLOWED_USERS=허용할-github-사용자&lt;br /&gt;
S3RM_GITHUB_ALLOWED_ORGS=&lt;br /&gt;
S3RM_GITHUB_ALLOW_ANY_USER=false&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
사용자 또는 조직 allowlist 중 하나는 설정해야 합니다. 정말 모든 GitHub 사용자를 허용할 때만 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_GITHUB_ALLOW_ANY_USER=true&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 사용하세요. GitHub 모드는 저장소 권한을 요청하지 않습니다. 조직 allowlist를 사용할 때만 조직 확인을 위해 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;read:org&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; scope를 요청합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp:write&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; scope는 생성, 관계, 근거 첨부·검증과 조건부 수정에 함께 적용됩니다.&lt;br /&gt;
&lt;br /&gt;
현재 OAuth client와 token 상태는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 프로세스 메모리에만 있습니다. 서비스를 다시 만들면 기존 OAuth 연결이 무효가 되므로 ChatGPT 앱을 다시 인증하거나 다시 만들어야 합니다.&lt;br /&gt;
&lt;br /&gt;
=== 환경 설정 ===&lt;br /&gt;
&lt;br /&gt;
저장소 루트에서 실행합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
cp .env.example .env&lt;br /&gt;
chmod 600 .env&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
비밀번호의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;change-me&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 값을 모두 바꿉니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_TOKEN_SHA256=change-me-...&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 다음 단계의 토큰 스크립트가 바꾸므로 그대로 둡니다. 비밀번호마다 새 값을 만드세요.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
openssl rand -base64 36&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== 공용 MCP 설정 ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 비밀번호를 바꾼 뒤 등록된 도메인을 설정합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
./scripts/configure-public-domain.sh s3wiki.yonsei.ac.kr \&lt;br /&gt;
  --expected-ip 165.132.118.220&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
스크립트는 DNS를 확인하고 위키 URL, MCP URL, loopback 바인딩, HTTPS 설정, Compose &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;domain&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 프로필을 맞춥니다. 공용 쓰기 토큰은 화면에 출력하지 않습니다. 서버의 mode &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;0600&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에는 SHA-256 해시만 저장하고, 원문 토큰과 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_URL&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 mode &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;0600&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;~/.config/s3-research-memory/client.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 저장합니다. 두 파일의 값이 이미 일치하면 토큰을 바꾸지 않습니다.&lt;br /&gt;
&lt;br /&gt;
모든 클라이언트에 같은 MCP URL을 설정합니다. 조회만 할 때는 토큰이 필요하지 않습니다. 메모를 생성·수정하거나 관계·근거를 추가할 에이전트에만 공용 토큰을 전달합니다. 이 토큰으로 페이지 삭제나 위키 관리를 할 수는 없습니다. 위키 관리자·DB 비밀번호를 토큰으로 재사용하지 마세요.&lt;br /&gt;
&lt;br /&gt;
서버에서 쓰기 연결을 시험할 때는 값을 출력하지 않고 불러옵니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
set -a&lt;br /&gt;
source &amp;quot;$HOME/.config/s3-research-memory/client.env&amp;quot;&lt;br /&gt;
set +a&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
원문 토큰은 필요한 사람에게 비공개 채널로 전달합니다. Git, 공개 이슈, 프롬프트, 스크린샷에는 넣지 마세요. 공용 토큰을 바꾸면 쓰기 클라이언트를 함께 갱신해야 합니다. 공개 조회에는 영향이 없습니다.&lt;br /&gt;
&lt;br /&gt;
설정 스크립트는 비어 있거나 예시인 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_WIKI_BOT_PASSWORD&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 32자리 API 전용 비밀번호를 만들고, 비어 있는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_WIKI_REVISION_HMAC_KEY&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 URL-safe 무작위 32바이트 값을 만듭니다. 이후 실행과 토큰 교체에서는 둘 다 보존합니다. 스크립트를 쓰지 않는 설정은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;openssl rand -hex 32&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 직접 만들 수 있습니다. 이 키는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 MediaWiki 저장 훅만 공유하며 클라이언트에는 전달하지 않습니다. 공용 토큰이나 위키 비밀번호와 같은 값을 쓰지 마세요. 키가 없거나 형식이 맞지 않으면 MCP가 시작을 거부합니다.&lt;br /&gt;
&lt;br /&gt;
같은 스크립트는 별도의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_PROXY_SECRET&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;도 생성합니다. 이 값은 Caddy와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;만 공유하며, Caddy가 덮어쓴 client IP 헤더임을 확인하는 데만 씁니다. revision HMAC 키와 재사용하거나 클라이언트에 전달하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
Compose는 현재 프로젝트 디렉터리 또는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--project-directory&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 가리키는 곳의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 읽습니다. 자세한 동작은 Docker의 [https://docs.docker.com/compose/how-tos/environment-variables/variable-interpolation/ 환경 변수 보간 안내]를 참고하세요. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 추적하지 않습니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;docker compose config&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 비밀번호와 토큰 해시를 출력하므로 일반 확인에는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;docker compose config --quiet&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 사용합니다.&lt;br /&gt;
&lt;br /&gt;
==== 환경 변수 ====&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 변수 !! 필수 !! 기본값·용도&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;MARIADB_DATABASE&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아니요 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;s3_research_memory&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;; 위키 데이터베이스 이름&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;MARIADB_USER&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아니요 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;s3wiki&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;; MediaWiki DB 계정&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;MARIADB_PASSWORD&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 예 || 제한된 MediaWiki DB 계정 비밀번호&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;MARIADB_ROOT_PASSWORD&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 예 || 초기화·복구 때만 쓰는 MariaDB 관리자 비밀번호&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;MW_SITE_NAME&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아니요 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3 연구 메모리&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;; 브라우저에 표시할 이름&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;MW_SERVER_URL&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아니요 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;https://s3wiki.yonsei.ac.kr&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;; 끝에 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 없는 외부 주소&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;MW_ADMIN_USERNAME&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아니요 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3Admin&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;; 처음 만드는 관리자 계정 이름&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;MW_ADMIN_PASSWORD&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 예 || 처음 만드는 관리자 비밀번호. Compose secret으로 one-shot &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-install&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;·&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에만 마운트&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_WIKI_USERNAME&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아니요 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3ResearchAgent&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;; MCP 전용 최소 권한 &#039;&#039;&#039;기본 계정&#039;&#039;&#039;. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;@app-id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 붙이지 않음&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_WIKI_PASSWORD&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 예 || 기본 계정의 primary 비밀번호. one-shot &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 계정 수렴에만 쓰고 장기 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에는 전달하지 않음&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_WIKI_BOT_APP_ID&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아니요 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;; API 전용 BotPassword app ID. 안전한 ASCII 1~32자&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_WIKI_BOT_PASSWORD&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 예 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;0-9&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;a-w&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 된 정확히 32자 BotPassword. one-shot &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 수렴하고 장기 MCP에는 이 API 전용 값만 환경으로 전달&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_WIKI_REVISION_HMAC_KEY&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 예 || MCP와 MediaWiki 저장 훅만 공유하는 43~256자 URL-safe 서명 키&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_AGENT_EDITS_PER_MINUTE&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아니요 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;6000&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;; 공유 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;agent&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 계정의 편집 한도 91~6000회/분. 일반 계정과 편집 이외 작업에는 적용하지 않음&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_JOBS_MAX_JOBS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아니요 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;25&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;; job runner 한 batch의 최대 job 수, 1~100&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_JOBS_MAX_SECONDS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아니요 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;20&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;; job runner 한 batch의 wall-clock 상한, 5~120초&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_JOBS_IDLE_SECONDS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아니요 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;5&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;; 정상 batch 사이 대기, 1~60초&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_JOBS_ERROR_BACKOFF_SECONDS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아니요 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;15&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;; 실패 후 첫 재시도 대기, 5~300초&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_JOBS_MAX_BACKOFF_SECONDS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아니요 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;300&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;; 지수 backoff 상한, 최대 900초&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_JOBS_HEALTH_STALE_SECONDS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아니요 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;120&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;; 마지막 성공 시각이 이보다 오래되면 job runner를 unhealthy로 판정, 30~900초. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;MAX_SECONDS + IDLE_SECONDS + 30&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 이상이어야 함&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_BINLOG_RETENTION_SECONDS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아니요 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;1209600&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;; 로컬 MariaDB binary log 보존 14일. 원격 백업과 복원 시험 주기보다 길게 유지&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_HTTP_PORT&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아니요 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;8080&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;; MediaWiki 80번 포트에 연결한 호스트 진단 포트&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_WIKI_BIND_ADDRESS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아니요 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;127.0.0.1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;; 진단 포트를 외부에 열지 않음&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_WIKI_DIRECT_URL&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아니요 || 비워 두면 직접 주소를 사용하지 않음. 외부 HTTP 화면을 명시적으로 열 때만 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;http://SERVER_IP:8080&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;처럼 전체 주소를 입력하고 bind를 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;0.0.0.0&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 설정&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_PUBLIC_URL&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 예 || 클라이언트 주소 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;https://s3wiki.yonsei.ac.kr/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_WRITE_AUTH_MODE&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아니요 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;token&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;; &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;open&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;token&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;github&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 중 하나&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_TOKEN_SHA256&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;token&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드 || 쓰기 토큰의 64자리 16진수 SHA-256 해시. 원문은 저장하지 않음&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_GITHUB_CLIENT_ID&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;github&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드 || GitHub OAuth App의 client ID&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_GITHUB_CLIENT_SECRET&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;github&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드 || GitHub OAuth App의 client secret. 커밋하지 않음&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_GITHUB_ALLOWED_USERS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 선택 || 허용할 GitHub 사용자명. 쉼표로 구분&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_GITHUB_ALLOWED_ORGS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 선택 || 허용할 GitHub 조직명. 쉼표로 구분&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_GITHUB_ALLOW_ANY_USER&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아니요 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;false&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;; &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;true&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;면 모든 GitHub 계정을 허용&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_BIND_ADDRESS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아니요 || 도메인 프로필에서는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;127.0.0.1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;. 공개 진입점은 Caddy&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_HTTP_PORT&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아니요 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;8765&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;; MCP 컨테이너 8000번 포트에 연결한 loopback 진단 포트&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_ALLOWED_HOSTS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아니요 || 쉼표로 나눈 추가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Host&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 값. 공개 URL의 host는 자동 허용&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_ALLOWED_ORIGINS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아니요 || 쉼표로 나눈 추가 HTTP(S) Origin. 공개 URL의 origin은 자동 허용&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_ALLOW_INSECURE_HTTP&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아니요 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;false&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;; 클라이언트 주소는 HTTPS만 사용&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_PROXY_SECRET&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 예 || Caddy와 MCP만 공유하는 43~256자 URL-safe 값. 전달된 client IP 헤더를 인증하며 클라이언트에는 공개하지 않음&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_WRITE_MAX_CONCURRENCY&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아니요 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;8&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;; 동시에 MediaWiki 쓰기로 진입하는 MCP 호출 수, 1~64&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_WRITE_QUEUE_LIMIT&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아니요 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;64&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;; 동시 실행 밖에서 기다릴 쓰기 수, 0~1000&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_WRITE_QUEUE_TIMEOUT_SECONDS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아니요 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;2&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;; queue 대기 상한 0.05~30초. 넘으면 bounded busy 응답&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_WRITE_RATE_PER_MINUTE&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아니요 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;600&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;; principal별 지속 쓰기 token 보충률, 1~6000회/분&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_WRITE_RATE_BURST&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아니요 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;120&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;; principal별 즉시 burst token 상한, 1~1000&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_WRITE_PRINCIPAL_LIMIT&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아니요 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;10000&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;; 메모리에 둘 최근 rate bucket 수, 100~100000. 초과 시 가장 오래된 bucket 제거&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_PUBLIC_HOST&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아니요 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;s3wiki.yonsei.ac.kr&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;; Caddy 주소&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_PUBLIC_HTTP_PORT&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아니요 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;80&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;; 리디렉션과 인증서 발급용 공개 HTTP 포트&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_PUBLIC_HTTPS_PORT&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아니요 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;443&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;; 공개 HTTPS 포트&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;COMPOSE_PROFILES&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아니요 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;domain&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 Caddy, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;embedding&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 내부 GPU 임베딩 서비스를 실행. 쉼표로 함께 지정 가능&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MAX_OUTPUT_BYTES&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아니요 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;32768&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;; 도구 결과 제한 2048~65536바이트. 전체 JSON-RPC 응답은 별도 212992바이트 제한&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_SEARCH_SCAN_LIMIT&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아니요 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;200&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;; 후보 스캔 제한 20~1000페이지&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_WIKI_TIMEOUT_SECONDS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아니요 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;20&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;; MediaWiki 요청 제한 1~120초&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_AGENT_EDITS_PER_MINUTE&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 바꾼 뒤에는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;restart&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 아니라 MediaWiki 컨테이너를 다시 만들어야 새 환경 변수가 들어갑니다. 기존 MariaDB 컨테이너에 binary log 설정을 처음 적용한다면 재생성 전에 [[Help:S3 연구 메모리/백업과 복원#binary log를 처음 켜는 서버|최초 이전용 기준 백업]]을 먼저 만드세요. 일반 백업은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;log_bin=ON&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 강제합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose up -d --force-recreate mediawiki&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
저장소의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;LocalSettings.s3.php&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;도 함께 바뀐 배포에서는 이미지를 먼저 다시 만듭니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose up -d --build --force-recreate mediawiki&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
MediaWiki의 6000회/분은 모든 MCP 호출이 공유하는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;agent&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 계정의 마지막 편집 상한입니다. 그 앞에서 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 전체 동시 실행 8개와 대기 64개를 제한하고, principal별로 지속 600회/분·burst 120을 적용합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;open&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드의 principal은 내부 비밀값까지 맞는 Caddy가 덮어쓴 client IP, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;github&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 로그인 subject, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;token&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 공유 token 하나입니다. 따라서 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;token&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드에서는 모든 쓰기 클라이언트가 600회/분 bucket도 공유합니다. 인증 모드를 바꾸지 않고 처리량만 조정할 때는 먼저 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_WRITE_MAX_CONCURRENCY&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, queue 대기와 DB latency를 보고 필요한 값만 올립니다.&lt;br /&gt;
&lt;br /&gt;
BotPassword 값은 MediaWiki가 로그인에서 허용하는 32자리 문자 집합 안에서 만듭니다. 출력한 값은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_WIKI_BOT_PASSWORD&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 한 번만 옮기고 셸 기록, Git, 클라이언트 설정에는 넣지 않습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
openssl rand -hex 16&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Compose의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 내부 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;http://mediawiki/api.php&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;만 사용합니다. 이 주소와 위키 비밀번호는 클라이언트에 전달하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
==== 선택적 의미 검색 ====&lt;br /&gt;
&lt;br /&gt;
기본 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_EMBEDDING_PROVIDER=disabled&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 모델을 실행하지 않고 기존 전체 텍스트와 관계 검색을 사용합니다. 이 저장소에는 이 서버의 AMD Vega GPU에 맞춘 비공개 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;llama.cpp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; Vulkan 서비스가 포함되어 있습니다. 모델은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Qwen/Qwen3-Embedding-0.6B&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; F16이며, 결과는 1024차원입니다. [https://huggingface.co/Qwen/Qwen3-Embedding-0.6B-GGUF 공식 Qwen GGUF]와 [https://github.com/ggml-org/llama.cpp/tree/master/tools/server llama.cpp 서버]를 고정된 revision과 이미지 digest로 사용합니다.&lt;br /&gt;
&lt;br /&gt;
다음 두 값만 바꾸면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env.example&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 고정 모델 설정을 그대로 사용할 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
S3RM_EMBEDDING_PROVIDER=openai-compatible&lt;br /&gt;
COMPOSE_PROFILES=domain,embedding&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
고정 모델을 확인하고 서비스를 시작합니다. 첫 실행은 약 1.2GB 모델을 받습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
make embedding-up&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;embedding-model-init&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;만 인터넷에 연결됩니다. 이 작업은 모델 저장소 commit, 파일 크기와 SHA-256을 모두 확인한 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;embedding_models&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 볼륨에 원자적으로 게시합니다. 실제 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;embedding&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 서비스는 호스트 포트를 열지 않으며 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와만 공유하는 내부 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;embedding-backend&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 네트워크를 사용합니다. Vega의 render node 하나만 컨테이너에 전달합니다.&lt;br /&gt;
&lt;br /&gt;
실행 중인 모델, 차원과 MCP 연결 설정을 다시 확인할 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
make verify-embedding&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;s3rm-qwen3-retrieval-v1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; profile은 모델 artifact digest, 1024차원, last pooling, 16K token 입력 계약과 세 facet의 query instruction을 한 generation으로 묶습니다. document에는 instruction을 붙이지 않습니다. 희귀 CJK의 byte fallback도 16K를 넘지 않도록 각 document facet은 보수적으로 4,000자에서 자릅니다. 이 값 중 하나를 바꾸면 새 exact index를 처음부터 만듭니다. 서로 다른 generation의 벡터는 섞지 않습니다.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 변수 !! 필수 !! 기본값·범위&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_EMBEDDING_PROVIDER&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아니요 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;disabled&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;; &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;disabled&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;openai-compatible&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 중 하나&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_EMBEDDING_PROFILE&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 사용 시 || 번들 모델은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;s3rm-qwen3-retrieval-v1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;; 기존 제공자는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;legacy-v1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_EMBEDDING_API_URL&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 임베딩 사용 시 || 자격 증명·query·fragment가 없는 HTTP(S) embeddings endpoint&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_EMBEDDING_API_KEY&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 제공자에 따라 || 요청에만 쓰는 secret. 커밋하지 않음&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_EMBEDDING_MODEL&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 임베딩 사용 시 || 제공자의 고정 모델 식별자&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_EMBEDDING_MODEL_REVISION&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || profile에 따라 || generation에 넣는 immutable revision. 번들 모델은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;sha256:&amp;amp;lt;GGUF digest&amp;amp;gt;&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_EMBEDDING_DIMENSIONS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 임베딩 사용 시 || 벡터 차원 1~4096&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_EMBEDDING_MODEL_SOURCE_REVISION&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 번들 모델 || downloader가 사용하는 40자리 Hugging Face commit&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_EMBEDDING_MODEL_SHA256&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 번들 모델 || 내려받은 GGUF의 64자리 SHA-256&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_EMBEDDING_MODEL_SIZE_BYTES&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 번들 모델 || 내려받은 GGUF의 정확한 바이트 수&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_EMBEDDING_DRI_DEVICE&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 번들 모델 || 이 서버의 외장 Vega render node &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/dev/dri/renderD128&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_EMBEDDING_GPU_DEVICE&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 번들 모델 || 컨테이너에서 보이는 장치 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Vulkan0&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_RENDER_GID&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 번들 모델 || render node의 group ID. 이 서버는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;987&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_EMBEDDING_TIMEOUT_SECONDS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아니요 || 번들 모델은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;60&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;; 허용 범위 0.5~120초&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_EMBEDDING_BATCH_SIZE&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아니요 || 번들 모델은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;6&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;; 요청 한 번에 1~128개&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_EMBEDDING_MAX_CONCURRENCY&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아니요 || 번들 모델은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;; 동시 요청 1~16개&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_EMBEDDING_QUEUE_LIMIT&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아니요 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;32&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;; 대기 요청 1~1000개&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_EMBEDDING_QUEUE_TIMEOUT_SECONDS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아니요 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;; 대기 제한 0.05~30초&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_EMBEDDING_ALLOW_INSECURE_HTTP&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아니요 || 번들 내부 HTTP는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;true&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;; 외부 endpoint는 HTTPS 사용&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_EMBEDDING_INSECURE_HTTP_HOSTS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 내부 HTTP 사용 시 || 허용할 정확한 Host 목록. 번들 서비스는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;embedding:8080&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_SEMANTIC_CANDIDATE_LIMIT&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아니요 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;50&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;; facet별 후보 5~100개&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_SEMANTIC_REFRESH_SECONDS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아니요 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;300&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;; 주기 대조와 실패 후 재시도 간격 10~86400초. dirty hint는 즉시 갱신&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
의미 인덱스는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 메모리의 파생 후보 인덱스입니다. Semantic MediaWiki가 계속 기준 저장소이며, 제공자나 인덱스가 실패하면 제한된 기존 검색으로 대체합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_SEARCH_SCAN_LIMIT × 3 × S3RM_EMBEDDING_DIMENSIONS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 3,000,000개 벡터 구성요소를 넘을 수 없습니다. 이 조합 검사는 exact index의 메모리 사용을 제한합니다. 외부 OpenAI 호환 API를 대신 사용할 수도 있습니다. 이 경우 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;legacy-v1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 또는 별도로 검증한 profile을 사용하고 HTTPS endpoint를 설정합니다. Lesson 의미 검색 필드와 아직 저장하지 않은 query가 외부로 전송되므로 보존·학습 사용·region·비용 조건을 확인한 뒤 켜세요. 로컬 서비스 장애가 외부 API로 자동 전환되지는 않습니다.&lt;br /&gt;
&lt;br /&gt;
설정만으로 기본 검색 순서는 바뀌지 않습니다. 클라이언트가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;search_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 또는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;find_analogies&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;retrieval_mode=hybrid_v1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 명시해야 합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 시작할 때 단 하나의 백그라운드 작업으로 전체 Lesson exact index를 프리웜합니다. hybrid 요청은 이 작업을 기다리지 않습니다. 아직 snapshot이 없으면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;effective_mode=legacy&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;semantic_status=building&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 즉시 대체하고, 구축에 실패해 사용할 snapshot도 없으면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;unavailable&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 표시합니다.&lt;br /&gt;
&lt;br /&gt;
인덱스가 준비된 뒤에는 Lesson 저장 dirty hint가 즉시 같은 단일 백그라운드 갱신을 시작합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_SEMANTIC_REFRESH_SECONDS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 지나면 다음 hybrid 요청이 브라우저 편집까지 포함한 전체 대조를 시작합니다. 요청은 기존 snapshot을 계속 사용하며 갱신 중에는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;stale&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 갱신 실패 뒤에는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;degraded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 될 수 있습니다. 확인할 때는 작은 요청을 다시 보내 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;effective_mode=hybrid_v1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;semantic_status=ready&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;인지 봅니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
search_lessons(query=&amp;quot;단편화&amp;quot;, retrieval_mode=&amp;quot;hybrid_v1&amp;quot;, limit=1)&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
직접 HTTP 화면을 함께 열어야 한다면 다음 두 값을 같이 설정합니다. MediaWiki는 정확히 이 Host로 들어온 요청에만 직접 주소를 사용합니다. 임의의 Host 헤더를 신뢰하지 않으며 정식 주소와 MCP 주소는 HTTPS 도메인으로 유지합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
S3RM_WIKI_BIND_ADDRESS=0.0.0.0&lt;br /&gt;
S3RM_WIKI_DIRECT_URL=http://165.132.118.220:8080&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
이 설정은 Docker의 호스트 리스너만 엽니다. 학교 경계 보안정책에서 TCP 8080이 차단되어 있으면 외부 접속은 계속 timeout입니다. 직접 주소는 위키 화면에만 쓰며 MCP URL로 등록하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
진단 포트만 바꿀 때는 외부 브라우저 주소를 그대로 둡니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
S3RM_HTTP_PORT=8090&lt;br /&gt;
MW_SERVER_URL=https://s3wiki.yonsei.ac.kr&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== 도메인과 Caddy 프로필 ====&lt;br /&gt;
&lt;br /&gt;
도메인 스크립트는 여러 번 실행해도 됩니다. Docker를 시작하지 않으며, 기존 토큰과 해시가 일치하면 토큰을 바꾸지 않습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
./scripts/configure-public-domain.sh s3wiki.yonsei.ac.kr \&lt;br /&gt;
  --expected-ip 165.132.118.220&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;domain&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 프로필은 Caddy 컨테이너 하나를 시작합니다. Caddy는 TLS 인증서를 발급·갱신하고, HTTP를 HTTPS로 돌립니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/mcp/*&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 보내고 나머지 경로는 MediaWiki로 보냅니다. 공개 요청의 client IP와 서버 전용 proxy secret 헤더를 항상 덮어쓰며, MCP는 secret이 일치할 때만 IP별 제한에 그 값을 사용합니다. 직접 진단 포트로 보낸 같은 이름의 헤더는 무시합니다. MCP 경로를 다시 쓰거나 Lesson을 저장하지 않습니다. 동작 원리는 Caddy의 [https://caddyserver.com/docs/automatic-https 자동 HTTPS 안내]와 [https://caddyserver.com/docs/quick-starts/reverse-proxy 리버스 프록시 안내]를 참고하세요.&lt;br /&gt;
&lt;br /&gt;
정적 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Authorization&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 헤더를 보낼 수 있는 클라이언트는 공용 Bearer 토큰으로 변경 도구 다섯 개를 사용할 수 있습니다. 호스팅형 ChatGPT와 Claude 커넥터는 같은 URL에서 공개 조회 도구를 사용할 수 있습니다. 이 제품에서 변경 도구를 쓰려면 OAuth 진입점이 추가로 필요합니다. OAuth를 붙이더라도 MCP나 위키를 하나 더 만들지 않습니다.&lt;br /&gt;
&lt;br /&gt;
=== 시작 ===&lt;br /&gt;
&lt;br /&gt;
필수 변수와 Compose 모델을 출력 없이 확인합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose config --quiet&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
이미지를 빌드하고 서비스를 시작합니다. 아래 명령은 HTTPS 위키 API와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;도 확인합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
make domain-up&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
이 확인은 명령을 실행한 서버 기준입니다. 학교 밖에서 80/443이 열렸는지는 다른 네트워크의 브라우저에서도 확인해야 합니다. 이미 실행 중인 서버의 HTTPS와 MCP만 다시 확인할 때는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;make verify-domain&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 사용합니다.&lt;br /&gt;
&lt;br /&gt;
서비스 역할은 다음과 같습니다.&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;: MariaDB와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db_data&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, PITR용 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db_binlogs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 볼륨&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-install&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;: 새 볼륨에서만 관리자 secret을 읽어 CLI 설치를 하는 one-shot 작업. 기존 설정이 있으면 변경 없이 종료&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;: Apache/PHP, Semantic MediaWiki, Page Forms, 생성된 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;LocalSettings.php&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki_config&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;·&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki_images&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 볼륨&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;: 스키마·양식 페이지, MCP 기본 계정·&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;agent&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 그룹과 API 전용 BotPassword를 맞추고 실제 BotPassword 로그인·유효 권한을 확인하는 일회성 작업&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-job-state-init&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;: 공유 job 상태 볼륨을 UID/GID 33 전용 쓰기 경로로 준비하는 네트워크 없는 일회성 작업&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-jobs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;: &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;runJobs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 1 process를 한 번에 기본 25건·20초로 제한하고, UID/GID 33으로 실행하며 실패 시 최대 5분까지 지수 backoff하는 전용 runner&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;embedding-model-init&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;: 고정한 GGUF를 받아 크기와 SHA-256을 확인하는 일회성 작업&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;embedding&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;: 선택적 Vulkan 임베딩 런타임. 호스트 포트와 영구 벡터 저장소는 없음&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;: 공개 조회와 토큰 기반 변경을 제공하는 공용 Streamable HTTP 어댑터. Lesson DB나 영구 볼륨은 없음&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;caddy&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;: 위키와 MCP의 공개 HTTPS 진입점&lt;br /&gt;
첫 시작 상태를 확인합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose ps -a&lt;br /&gt;
docker compose logs -f mediawiki-install mediawiki bootstrap mediawiki-jobs mcp-http caddy&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-jobs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;caddy&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 healthy 상태이고, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-install&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;과 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 코드 0으로 종료되면 정상입니다. Caddy 인증서도 발급되어야 합니다. 두 one-shot 작업은 종료 상태가 정상입니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;docker compose up -d&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 다시 실행해도 관리 스키마와 양식을 맞추며 일반 Lesson과 변경 이력은 보존합니다. 새 설치에는 Lesson 페이지가 없습니다. 첫 메모를 저장하기 전에는 최근 목록과 검색 결과가 비어 있어도 정상입니다.&lt;br /&gt;
&lt;br /&gt;
&amp;amp;lt;!-- Contract marker: There is no browser installation step. --&amp;amp;gt; 브라우저 설치 단계는 없습니다. 저장된 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;LocalSettings.php&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 없으면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-install&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; one-shot이 Compose secret의 관리자 비밀번호를 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--passfile&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 넘겨 MediaWiki CLI 설치를 완료합니다. 그 다음 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; entrypoint가 DB 마이그레이션을 적용하고 Apache를 시작합니다. 장기 실행 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 컨테이너에는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;MW_ADMIN_PASSWORD&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 환경 변수나 secret으로 들어가지 않습니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/mw-config/&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 접속하거나, 브라우저 설치를 다시 시작하거나, 생성된 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;LocalSettings.php&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 컨테이너에 복사하지 마세요. 설치 화면이 나오거나 세션 만료 오류가 보이면 [[Help:S3 연구 메모리/문제 해결#브라우저에 설치 세션 만료가 표시됨|문제 해결 안내]]를 따릅니다.&lt;br /&gt;
&lt;br /&gt;
장기 실행 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 기본 계정의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_WIKI_PASSWORD&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 받지 않습니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;basic,createeditmovepage&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; grant만 가진 BotPassword를 만들거나 같은 app ID의 비밀번호를 갱신하고, 로그인 이름 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_WIKI_USERNAME@S3RM_WIKI_BOT_APP_ID&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 실제 API 로그인을 확인합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 이 API 전용 BotPassword만 받으며 기본 계정 primary 비밀번호는 받지 않습니다. read-only root filesystem과 환경 기반 Compose secret의 런타임 생성이 호환되지 않으므로 파일 secret으로 복사하지 않습니다. BotPassword grant는 계정 권한을 추가하지 않으며, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;agent&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 그룹의 제한과 저장 훅 검증이 계속 적용됩니다.&lt;br /&gt;
&lt;br /&gt;
[https://s3wiki.yonsei.ac.kr/]를 엽니다. 읽기와 검색은 로그인 없이 할 수 있습니다. 작성과 편집에는 이름이 있는 위키 계정이 필요합니다.&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/index.php/Special:Version&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;: Semantic MediaWiki, Page Forms, S3 Research Memory 확장 기능 확인&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/index.php/연구_메모리#create-a-lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;: 문서 ID를 정하고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Lesson:&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 메모 작성&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/index.php/Special:Search&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;: MediaWiki 전문 검색&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/index.php/Special:AllPages?namespace=3000&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;: 모든 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Lesson:&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 페이지&lt;br /&gt;
=== 브라우저 계정 만들기 ===&lt;br /&gt;
&lt;br /&gt;
자기 가입은 꺼져 있습니다. 설정에서 익명 그룹(&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;*&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;)의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;createaccount&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 권한을 제거했고, 관리자 그룹(&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;sysop&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;)만 계정을 만들 수 있습니다. 새 설치에서는 다음 순서로 일반 편집 계정을 만드세요.&lt;br /&gt;
&lt;br /&gt;
# &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;MW_ADMIN_USERNAME&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;과 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;MW_ADMIN_PASSWORD&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 로그인합니다.&lt;br /&gt;
# &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/index.php/Special:CreateAccount&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 엽니다.&lt;br /&gt;
# 사용할 사람의 이름을 알 수 있는 사용자 이름과 새 임시 비밀번호를 입력합니다.&lt;br /&gt;
# 계정을 만든 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/index.php/Special:ListUsers&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 확인합니다.&lt;br /&gt;
# &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;sysop&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bureaucrat&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;agent&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 그룹은 추가하지 않습니다. 로그인한 일반 계정은 자동으로 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;user&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 권한을 받아 양식으로 메모를 작성·편집할 수 있습니다.&lt;br /&gt;
# 새 계정으로 로그인해 비밀번호를 바꾸고 메모 양식이 열리는지 확인합니다.&lt;br /&gt;
관리자 계정이나 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_WIKI_USERNAME&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 계정을 일상 편집에 함께 쓰지 마세요. 계정과 권한 기준은 [[Help:S3 연구 메모리/보안|&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;security.md&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;]]에 정리되어 있습니다.&lt;br /&gt;
&lt;br /&gt;
=== 설치 확인 ===&lt;br /&gt;
&lt;br /&gt;
로컬 테스트와 Compose 검사를 실행합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
make test&lt;br /&gt;
make check-compose&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;make test&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 Compose 없이 메모리 내 위키 어댑터로 스키마, 에이전트 권한, 관계, 페이지네이션, 직렬화 출력 제한을 확인합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;make check-compose&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 테스트용 필수 값을 넣은 뒤 Compose 배포 모델을 확인합니다.&lt;br /&gt;
&lt;br /&gt;
브라우저에서는 다음을 확인합니다.&lt;br /&gt;
&lt;br /&gt;
# 로그아웃 상태에서 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Special:Version&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 검색, Lesson 페이지가 열리고, 작성·편집은 로그인을 요구하는지 확인합니다.&lt;br /&gt;
# 새 설치라면 Lesson 네임스페이스의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Special:AllPages&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 비어 있어도 정상입니다.&lt;br /&gt;
# 로그인한 일반 계정으로 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/index.php/연구_메모리#create-a-lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 열고 작성 양식이 보이는지 확인합니다. 확인용 가짜 메모는 저장하지 않습니다.&lt;br /&gt;
# 첫 실제 메모를 저장할 때 검색에 바로 나오는지, 필드와 근거 인용이 보이는지, &#039;&#039;&#039;역사 보기&#039;&#039;&#039;에 변경 이력이 생겼는지 확인합니다.&lt;br /&gt;
==== 클라이언트를 연결하기 전 공용 MCP 확인 ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_PUBLIC_URL&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;과 같은 주소를 설정합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
export S3RM_MCP_URL=&#039;https://s3wiki.yonsei.ac.kr/mcp&#039;&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
서버에서 프로세스 상태와 전체 쓰기 경로 readiness를 차례로 확인합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_HTTP_PORT&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 바꿨다면 8765도 같이 바꿉니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/livez&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 MCP 프로세스만, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/readyz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 BotPassword 로그인·필요한 위키 권한과 bounded job runner의 최근 성공 상태까지 확인합니다. Compose 운영에서는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;canonical_store=ok&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;job_runner=ok&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 모두 필요합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
curl --fail --silent --show-error http://127.0.0.1:8765/livez&lt;br /&gt;
curl --fail --silent --show-error http://127.0.0.1:8765/readyz&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
이어서 토큰 없이 MCP 초기화를 보냅니다. HTTP나 JSON 오류가 있으면 0이 아닌 코드로 끝납니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
python3 - &amp;lt;&amp;lt;&#039;PY&#039;&lt;br /&gt;
import json&lt;br /&gt;
import os&lt;br /&gt;
import urllib.request&lt;br /&gt;
&lt;br /&gt;
payload = json.dumps({&lt;br /&gt;
    &amp;quot;jsonrpc&amp;quot;: &amp;quot;2.0&amp;quot;,&lt;br /&gt;
    &amp;quot;id&amp;quot;: 1,&lt;br /&gt;
    &amp;quot;method&amp;quot;: &amp;quot;initialize&amp;quot;,&lt;br /&gt;
    &amp;quot;params&amp;quot;: {&lt;br /&gt;
        &amp;quot;protocolVersion&amp;quot;: &amp;quot;2025-11-25&amp;quot;,&lt;br /&gt;
        &amp;quot;capabilities&amp;quot;: {},&lt;br /&gt;
        &amp;quot;clientInfo&amp;quot;: {&amp;quot;name&amp;quot;: &amp;quot;s3rm-setup-smoke&amp;quot;, &amp;quot;version&amp;quot;: &amp;quot;1&amp;quot;},&lt;br /&gt;
    },&lt;br /&gt;
}).encode()&lt;br /&gt;
request = urllib.request.Request(&lt;br /&gt;
    os.environ[&amp;quot;S3RM_MCP_URL&amp;quot;],&lt;br /&gt;
    data=payload,&lt;br /&gt;
    headers={&lt;br /&gt;
        &amp;quot;Accept&amp;quot;: &amp;quot;application/json, text/event-stream&amp;quot;,&lt;br /&gt;
        &amp;quot;Content-Type&amp;quot;: &amp;quot;application/json&amp;quot;,&lt;br /&gt;
    },&lt;br /&gt;
    method=&amp;quot;POST&amp;quot;,&lt;br /&gt;
)&lt;br /&gt;
with urllib.request.urlopen(request, timeout=20) as response:&lt;br /&gt;
    result = json.load(response)&lt;br /&gt;
assert result.get(&amp;quot;result&amp;quot;, {}).get(&amp;quot;serverInfo&amp;quot;, {}).get(&amp;quot;name&amp;quot;) == &amp;quot;S3 Research Memory&amp;quot;&lt;br /&gt;
print(&amp;quot;anonymous MCP initialize: ok&amp;quot;)&lt;br /&gt;
PY&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
토큰이 없는 클라이언트도 초기화, 도구 목록, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;search_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;get_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;audit_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;find_related_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;find_analogies&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 사용할 수 있습니다. [[Help:S3 연구 메모리/MCP 클라이언트|&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-clients.md&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;]]에 따라 연결하고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;search_lessons(limit=1)&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 실행합니다. 다른 PC의 클라이언트에서도 같은 결과가 나오는지 확인하세요.&lt;br /&gt;
&lt;br /&gt;
변경 도구가 필요한 클라이언트에는 플랫폼 설정으로 공용 토큰을 불러옵니다. 이 토큰으로 생성, 관계, 근거 첨부·검증과 조건부 수정을 모두 호출합니다. 연결 확인만 하려고 가짜 메모를 만들지 않습니다. 쓰기 권한 경계는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;make test&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 확인하고, 실제 첫 메모를 남길 때 MediaWiki 이력, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;audit_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 검색 결과를 확인합니다.&lt;br /&gt;
&lt;br /&gt;
=== 평소 시작과 종료 ===&lt;br /&gt;
&lt;br /&gt;
설치가 끝난 서비스를 시작합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose up -d&lt;br /&gt;
docker compose ps -a&lt;br /&gt;
make verify-domain&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
마지막 명령은 서버에서 본 HTTPS 위키와 MCP를 확인합니다. 다른 PC에서 접속되는지는 그 PC의 브라우저나 MCP 클라이언트로 따로 확인합니다.&lt;br /&gt;
&lt;br /&gt;
볼륨을 남기고 종료합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose down&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
MediaWiki만 다시 시작합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose restart mediawiki&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
공용 MCP 프로세스만 다시 시작합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose restart mcp-http&lt;br /&gt;
docker compose ps mcp-http&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
의도적으로 설정을 바꾼 뒤 스키마·계정을 다시 맞춥니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose up -d --force-recreate bootstrap&lt;br /&gt;
docker compose logs bootstrap&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
일반 운영 중에는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;docker compose down --volumes&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;docker volume rm&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 수동 SQL 삭제를 실행하지 마세요. 위키의 기준 데이터가 사라집니다.&lt;br /&gt;
&lt;br /&gt;
=== 자격 증명 변경 ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 값만 바꿔도 MariaDB나 MediaWiki에 이미 저장된 자격 증명은 바뀌지 않습니다. 해당 제품의 계정 관리 절차로 먼저 바꾸고, 같은 작업 시간에 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 관련 서비스를 함께 갱신합니다.&lt;br /&gt;
&lt;br /&gt;
* 공용 MCP 토큰: &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;./scripts/configure-mcp-token.sh --rotate&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 실행하고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 다시 만듭니다. 변경 경로를 확인한 뒤 새 handoff를 전달하고 클라이언트를 재연결합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--rotate&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 없으면 일치하는 기존 토큰을 보존합니다. 공개 조회는 계속되지만 이전·새 쓰기 토큰을 동시에 받을 수는 없습니다.&lt;br /&gt;
* revision HMAC 키: 새 URL-safe 키를 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 넣고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 같은 작업 시간에 다시 만듭니다. 둘 중 하나만 이전 키를 쓰는 동안에는 revision을 실행하지 않습니다. 이 키는 어떤 클라이언트에도 전달하지 않습니다.&lt;br /&gt;
* MCP 기본 계정: &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_WIKI_PASSWORD&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 one-shot 계정 수렴에만 씁니다. 값을 바꾸면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 다시 실행하되, 장기 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에는 이 값을 추가하지 마세요.&lt;br /&gt;
* MCP BotPassword: 새 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;openssl rand -hex 16&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 값을 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_WIKI_BOT_PASSWORD&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 넣고 아래 순서로 전환합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 같은 app ID를 갱신하고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;basic,createeditmovepage&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; grant와 로그인·권한을 확인합니다. ```bash docker compose stop mcp-http docker compose run --rm bootstrap docker compose up -d --build --force-recreate mcp-http curl --fail --silent --show-error http://127.0.0.1:8765/healthz \ | jq --exit-status \ &#039;.canonical_store == &amp;quot;ok&amp;quot; and .job_runner == &amp;quot;ok&amp;quot;&#039; ``` 새 자격 증명이 확인되기 전에는 이전 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 안전한 복구 사본을 지우지 마세요. app ID를 바꾸면 이전 app ID의 BotPassword는 자동으로 삭제되지 않습니다. 새 경로를 확인한 뒤 관리 화면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Special:BotPasswords&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 이전 app ID 하나만 폐기합니다.&lt;br /&gt;
* 일반 위키 계정: MediaWiki 화면에서 비밀번호를 바꿉니다. DB 테이블을 직접 편집하지 마세요.&lt;br /&gt;
* MariaDB 계정: MariaDB 공식 계정 관리 절차로 서버 계정을 바꾼 뒤, 같은 작업 시간에 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 갱신합니다.&lt;br /&gt;
먼저 백업하고, 변경 뒤 조회를 확인합니다. 공용 토큰 절차는 [[Help:S3 연구 메모리/백업과 복원#공용 MCP 토큰 교체|&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;operations.md&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;]]에 있습니다.&lt;br /&gt;
&lt;br /&gt;
=== 업데이트 ===&lt;br /&gt;
&lt;br /&gt;
MediaWiki 기본 이미지는 정확한 버전으로 고정되어 있습니다. PHP 의존성은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;docker/mediawiki/composer.lock&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, Python 의존성은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp/uv.lock&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 사용합니다. MariaDB 이미지는 호환 태그를 사용합니다.&lt;br /&gt;
&lt;br /&gt;
# MediaWiki, Semantic MediaWiki, Page Forms, MariaDB, MCP 의존성의 릴리스 안내와 호환표를 확인합니다.&lt;br /&gt;
# [[Help:S3 연구 메모리/백업과 복원|&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;operations.md&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;]]에 따라 전체 백업을 만들고 확인합니다.&lt;br /&gt;
# 버전 고정을 추적 가능한 커밋에서 바꿉니다. 실제 서버에서 검증하지 않은 floating 버전을 쓰지 마세요.&lt;br /&gt;
# &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;make test&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;make check-compose&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 실행합니다.&lt;br /&gt;
# 별도 볼륨에 빌드하고 복원 시험을 합니다.&lt;br /&gt;
# 확인 뒤 실제 서버 이미지를 다시 만들고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 마이그레이션·동기화를 완료합니다.&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose build --pull mediawiki mcp-http&lt;br /&gt;
docker compose up -d&lt;br /&gt;
docker compose logs mediawiki bootstrap mcp-http caddy&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
새 MediaWiki에서 만든 DB를 더 오래된 MediaWiki로 복원하지 마세요. 호환성은 [https://www.semantic-mediawiki.org/wiki/Help:Installation Semantic MediaWiki 설치 안내], [https://www.mediawiki.org/wiki/Extension:Page_Forms/Download_and_installation Page Forms 설치 안내], [https://www.mediawiki.org/wiki/Manual:Upgrading MediaWiki 업그레이드 안내]를 기준으로 확인합니다.&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC/%EB%AC%B8%EC%A0%9C_%ED%95%B4%EA%B2%B0&amp;diff=1723</id>
		<title>도움말:S3 연구 메모리/문제 해결</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC/%EB%AC%B8%EC%A0%9C_%ED%95%B4%EA%B2%B0&amp;diff=1723"/>
		<updated>2026-07-18T10:09:46Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 저장소 문서 동기화&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;!-- S3RM managed repository document; edit the Markdown source, not this page. --&amp;gt;&lt;br /&gt;
&#039;&#039;이 페이지는 저장소 문서에서 자동으로 동기화됩니다. 위키에서 직접 편집하지 마세요.&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
실패한 상태를 보존하고, 출력 길이를 제한한 진단부터 실행합니다. 원인을 찾는 동안 볼륨을 지우거나, DB 테이블을 직접 고치거나, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;LocalSettings.php&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 다시 만들거나, 백업의 유일한 사본으로 복원을 반복하지 마세요.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose config --quiet&lt;br /&gt;
docker compose ps -a&lt;br /&gt;
docker compose logs --tail=200 db mediawiki-install mediawiki bootstrap \&lt;br /&gt;
  mediawiki-jobs mcp-http caddy&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
출력을 전달할 때는 비밀번호, session cookie, Bearer 토큰, 전체 환경 dump를 지웁니다. Lesson 내용은 공개 대상이지만 운영 자격 증명은 공개하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
=== Compose에서 필수 변수가 없다고 나옴 ===&lt;br /&gt;
&lt;br /&gt;
예상 메시지:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
required variable MARIADB_PASSWORD is missing a value&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
파일 이름이 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;compose.yaml&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 옆의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;인지 확인하고, 필수 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;change-me&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 비밀번호를 모두 바꿉니다. 토큰을 출력하지 않고 공용 MCP 설정을 마칩니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_WIKI_BOT_PASSWORD&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 빠졌다면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;openssl rand -hex 16&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 정확히 32자를 만듭니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_WIKI_USERNAME&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에는 기본 계정만 쓰며 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;@mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 직접 붙이지 않습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
test -f .env&lt;br /&gt;
grep -n &#039;change-me&#039; .env&lt;br /&gt;
./scripts/configure-public-domain.sh s3wiki.yonsei.ac.kr \&lt;br /&gt;
  --expected-ip 165.132.118.220&lt;br /&gt;
docker compose config --quiet&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
저장소 밖에서 Compose를 실행한다면 절대 프로젝트 경로를 적습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose --project-directory /ABS/PATH/TO/s3wiki config --quiet&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
해석된 설정에는 비밀번호와 토큰 해시가 있으므로 일반 확인에는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;config --quiet&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 사용합니다.&lt;br /&gt;
&lt;br /&gt;
=== 호스트 포트 8765를 이미 사용 중임 ===&lt;br /&gt;
&lt;br /&gt;
8765는 도메인 배포의 loopback 진단 포트입니다. Caddy는 Compose 네트워크에서 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http:8000&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 연결하므로 호스트 진단 포트를 바꿔도 클라이언트 주소는 그대로입니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
S3RM_MCP_PUBLIC_URL=https://s3wiki.yonsei.ac.kr/mcp&lt;br /&gt;
S3RM_MCP_BIND_ADDRESS=127.0.0.1&lt;br /&gt;
S3RM_MCP_HTTP_PORT=18765&lt;br /&gt;
S3RM_MCP_ALLOW_INSECURE_HTTP=false&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
공용 MCP를 다시 만들고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;127.0.0.1:18765/healthz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 프로세스를 확인합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose up -d --force-recreate mcp-http&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
클라이언트 주소는 계속 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;https://s3wiki.yonsei.ac.kr/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;입니다. 새 진단 포트를 외부에 열거나 로컬 대체 MCP를 시작하지 마세요.&lt;br /&gt;
&lt;br /&gt;
=== 호스트 포트 8080을 이미 사용 중임 ===&lt;br /&gt;
&lt;br /&gt;
사용하지 않는 포트를 고르고 외부 URL은 그대로 둡니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
S3RM_HTTP_PORT=8090&lt;br /&gt;
MW_SERVER_URL=https://s3wiki.yonsei.ac.kr&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
MediaWiki를 다시 만듭니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose up -d --force-recreate mediawiki bootstrap&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
이 포트는 직접 진단용입니다. 브라우저 링크와 리디렉션은 계속 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;https://s3wiki.yonsei.ac.kr/&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 사용해야 합니다.&lt;br /&gt;
&lt;br /&gt;
=== 호스트 포트 80 또는 443을 이미 사용 중임 ===&lt;br /&gt;
&lt;br /&gt;
공개 배포에서는 Caddy가 두 포트를 모두 사용해야 합니다. 관련 없는 서비스를 바로 멈추지 말고 리스너부터 확인합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
ss -ltnp &#039;( sport = :80 or sport = :443 )&#039;&lt;br /&gt;
docker compose ps caddy&lt;br /&gt;
docker compose logs --tail=200 caddy&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
충돌하는 서비스를 옮기거나 설정을 바꾼 뒤 Caddy를 다시 만듭니다. 공개 포트를 다른 번호로 우회하면 일반 HTTPS URL과 인증서 검증이 실패합니다.&lt;br /&gt;
&lt;br /&gt;
=== DNS는 맞지만 루트 주소가 연결 재설정 또는 거부로 열리지 않음 ===&lt;br /&gt;
&lt;br /&gt;
브라우저에서 루트 주소가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;ERR_CONNECTION_RESET&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 또는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Connection refused&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 끝나고 DNS가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;165.132.118.220&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 가리킨다면 실제 리스너부터 확인합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
getent ahostsv4 s3wiki.yonsei.ac.kr&lt;br /&gt;
ss -ltn &#039;( sport = :80 or sport = :443 or sport = :8080 or sport = :8765 )&#039;&lt;br /&gt;
docker compose ps -a&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
80/443은 없고 8080/8765만 보이면 MediaWiki와 MCP의 진단 포트만 열린 상태입니다. Caddy가 든 Compose &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;domain&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 프로필이 시작되지 않은 것이므로 방화벽이나 커널 설정을 먼저 바꾸지 마세요.&lt;br /&gt;
&lt;br /&gt;
기본 도메인 설정에서는 8080을 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;127.0.0.1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에만 게시하므로 다른 컴퓨터에서 접속할 수 없습니다. 서버 담당자가 직접 HTTP 화면도 열기로 했다면 다음 두 값을 함께 설정하고 MediaWiki를 다시 만듭니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
S3RM_WIKI_BIND_ADDRESS=0.0.0.0&lt;br /&gt;
S3RM_WIKI_DIRECT_URL=http://165.132.118.220:8080&lt;br /&gt;
docker compose up -d --no-deps --force-recreate mediawiki&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
이후 서버에서 직접 주소가 자기 주소로 이동하는지 확인합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
curl --silent --show-error --head http://165.132.118.220:8080/&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Location&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 같은 IP의 8080을 가리키면 도메인과 직접 주소가 분리된 것입니다. 이 설정은 Docker 리스너만 엽니다. 외부 여러 지역에서 계속 timeout이면 학교 경계에서 TCP 8080이 차단된 상태이므로 보안정책에 8080을 별도로 추가해야 합니다. 직접 HTTP 주소를 MCP에 등록하지 마세요. MCP는 계속 HTTPS 도메인 하나만 사용합니다.&lt;br /&gt;
&lt;br /&gt;
Docker socket 접근이 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;permission denied&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 실패하고 sudo 암호가 필요한 서버에서는 대화형 셸에서 다음 한 줄을 실행합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
cd /home/mrcha033/Projects/s3wiki; sudo -v; make domain-up COMPOSE=&#039;sudo docker compose&#039;&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;sudo -v&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 자격을 먼저 확인합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;COMPOSE&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에만 sudo를 붙이면 저장소 파일과 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 현재 사용자 소유로 남습니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;sudo make domain-up&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 실행하거나 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/var/run/docker.sock&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 mode &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;666&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 바꾸지 마세요. 이 증상 때문에 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;sysctl&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 바꾸지 않습니다.&lt;br /&gt;
&lt;br /&gt;
실행 뒤 서비스, Caddy 로그, 공개 포트, HTTPS 응답을 차례로 확인합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
sudo docker compose --profile domain ps -a&lt;br /&gt;
sudo docker compose --profile domain logs --tail=200 caddy&lt;br /&gt;
ss -ltn &#039;( sport = :80 or sport = :443 )&#039;&lt;br /&gt;
curl --fail --silent --show-error --head https://s3wiki.yonsei.ac.kr/&lt;br /&gt;
curl --fail --silent --show-error \&lt;br /&gt;
  &#039;https://s3wiki.yonsei.ac.kr/api.php?action=query&amp;amp;meta=siteinfo&amp;amp;format=json&#039; \&lt;br /&gt;
  &amp;gt;/dev/null&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;caddy&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 실행 중이고 80/443이 리슨하며 두 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;curl&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 성공해야 외부 브라우저에서 확인합니다. 실패하면 Caddy 로그의 인증서 또는 포트 오류를 먼저 해결합니다.&lt;br /&gt;
&lt;br /&gt;
=== HTTPS 인증서가 발급되지 않음 ===&lt;br /&gt;
&lt;br /&gt;
DNS가 이 서버를 가리키고 두 공개 포트가 서버까지 들어오는지 확인합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
getent ahostsv4 s3wiki.yonsei.ac.kr&lt;br /&gt;
docker compose ps caddy&lt;br /&gt;
docker compose logs --tail=300 caddy&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
예상 IPv4는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;165.132.118.220&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;입니다. DNS가 맞다면 캠퍼스·호스트 방화벽과 포트 소유 프로세스를 확인합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;caddy_data&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 지워도 DNS나 라우팅은 고쳐지지 않으며 불필요한 인증서 요청만 늘어날 수 있습니다. 외부 연결을 고친 뒤 Caddy만 다시 만들고 다른 네트워크에서 확인합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose up -d --force-recreate caddy&lt;br /&gt;
curl --fail --silent --show-error --head https://s3wiki.yonsei.ac.kr/&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Caddy가 80/443을 리슨하고 서버 안에서는 HTTP 308이 나오는데 로그에 다음 문구가 있다면 애플리케이션 문제가 아닙니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
Timeout during connect (likely firewall problem)&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Let’s Encrypt의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;http-01&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;과 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;tls-alpn-01&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 모두 이 오류라면 외부에서 서버의 TCP 80과 443에 들어오지 못하는 상태입니다. 연세대학교 정보통신처의 [https://yis.yonsei.ac.kr/ics/service/PolicyApplyInfo.do 보안정책서비스 안내]에 따라 [https://yis.yonsei.ac.kr/ics/service/policyApplyRegister.do 보안정책 신청]을 합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;네트워크 PORT 신청&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 벽면 네트워크 포트 설치 절차이므로 이 문제와 다릅니다. 신청 화면에는 다음 값을 적습니다. 소속, 성명, 연락처, 이메일은 신청자가 직접 입력합니다. 개인 연락처나 계정 정보는 이 저장소에 남기지 않습니다.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 신청 항목 !! 입력값&lt;br /&gt;
|-&lt;br /&gt;
| 소속 || 신청자 소속 직접 입력&lt;br /&gt;
|-&lt;br /&gt;
| 성명 || 신청자 성명 직접 입력&lt;br /&gt;
|-&lt;br /&gt;
| 연락처 || 신청자 연락처 직접 입력&lt;br /&gt;
|-&lt;br /&gt;
| 이메일 || 신청자 연세 이메일 직접 입력&lt;br /&gt;
|-&lt;br /&gt;
| 시작일 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;2026-07-16&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| 종료일 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;2029-07-15&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| 출발지 IP || 공개 웹서비스 80/443 예외 적용 요청. 출발지는 공개 웹서비스이므로 특정할 수 없습니다.&lt;br /&gt;
|-&lt;br /&gt;
| 목적지 IP || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;165.132.118.220&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| 프로토콜 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;TCP&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;만 선택&lt;br /&gt;
|-&lt;br /&gt;
| 사용포트 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;80, 443&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| 요청사유 및 특이사항 || 아래 문안 붙여넣기&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
정책 기간은 최대 3년이므로 위 종료일은 시작일로부터 3년 이내입니다. 출발지 IP 칸이 숫자 주소만 받는다면 임의의 IP를 넣지 말고 보안정책 담당자 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;02-2123-6419&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 공개 웹서비스 예외 입력 방법을 확인합니다.&lt;br /&gt;
&lt;br /&gt;
요청사유 및 특이사항에는 다음 내용을 붙여넣습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
S3 연구실에서 함께 사용하는 위키(s3wiki.yonsei.ac.kr)를 운영하려고 합니다.&lt;br /&gt;
DNS는 165.132.118.220으로 등록되어 있고 서버도 80/443 포트에서 대기 중이지만,&lt;br /&gt;
현재 외부 연결이 차단되어 HTTPS 인증서 검증이 실패합니다.&lt;br /&gt;
&lt;br /&gt;
HTTPS 접속과 인증서 자동 발급·갱신을 위해 목적지&lt;br /&gt;
165.132.118.220의 TCP 80, 443에 대한 외부 인바운드 허용을 요청드립니다.&lt;br /&gt;
출발지는 공개 웹서비스이므로 특정할 수 없습니다. 80은 인증서 검증과 HTTPS 전환,&lt;br /&gt;
443은 위키와 MCP 접속에 사용합니다. 그 밖의 포트 개방은 요청하지 않습니다.&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
공식 안내에는 필요한 웹서비스의 80/443은 출발지 IP 특정 조건의 예외라고 적혀 있습니다. 보안정책 문의는 정보통신처 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;02-2123-6419&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 인터넷 문의는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;02-2123-3411&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;입니다.&lt;br /&gt;
&lt;br /&gt;
포트가 열리면 Caddy가 인증서 발급을 자동으로 다시 시도합니다. Caddy 로그의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;certificate obtained successfully&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 서버의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;make verify-domain&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 확인한 뒤 학교 밖 네트워크의 브라우저를 다시 여세요. 인증서 볼륨을 지우거나 자체 서명 인증서로 바꾸지 마세요.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose --profile domain logs --follow caddy&lt;br /&gt;
make verify-domain&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/etc/group&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에는 이미 사용자가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;docker&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 그룹에 있는데 현재 셸만 Docker 권한을 읽지 못한다면 로그아웃한 뒤 다시 로그인합니다. 바로 새 셸에서 작업하려면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;newgrp docker&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 실행한 뒤 위 명령을 사용합니다.&lt;br /&gt;
&lt;br /&gt;
=== MariaDB가 unhealthy 상태임 ===&lt;br /&gt;
&lt;br /&gt;
관련 상태와 로그 끝부분만 확인합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose ps db&lt;br /&gt;
docker compose logs --tail=200 db&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
주요 원인은 비어 있거나 잘못된 필수 비밀번호, 디스크 부족, 볼륨 소유권 문제, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db_data&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 초기화 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 DB 비밀번호만 바꾼 경우입니다. 환경 변수만 바꿔도 기존 MariaDB 계정은 바뀌지 않습니다. 이전에 맞던 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 복구하거나 계획된 DB 계정 교체를 수행하세요. 오류를 없애려고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db_data&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 지우지 마세요.&lt;br /&gt;
&lt;br /&gt;
binary log 설정 오류가 보이면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db_binlogs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 볼륨과 현재 master 상태를 읽기 전용으로 확인합니다. 출력에는 비밀번호가 없지만 DB 이름·파일 번호는 운영 정보로 취급합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker volume ls | grep db_binlogs&lt;br /&gt;
docker compose exec -T db sh -c \&lt;br /&gt;
  &#039;MYSQL_PWD=&amp;quot;$MARIADB_ROOT_PASSWORD&amp;quot; mariadb -uroot -h127.0.0.1 \&lt;br /&gt;
   --batch --skip-column-names -e &amp;quot;SHOW MASTER STATUS&amp;quot;&#039;&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
결과가 비어 있거나 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mariadb-bin.000001&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 형식이 아니면 새 백업을 만들지 마세요. Compose 명령과 볼륨 mount를 먼저 고치고 DB를 계획된 작업 시간에 다시 만듭니다. 기존 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db_binlogs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 임의로 지우면 연속 PITR 구간을 잃습니다.&lt;br /&gt;
&lt;br /&gt;
=== MediaWiki가 unhealthy 상태임 ===&lt;br /&gt;
&lt;br /&gt;
첫 빌드와 마이그레이션에는 몇 분이 걸릴 수 있습니다. 의존 서비스와 로컬 상태 경로를 확인합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose ps -a&lt;br /&gt;
docker compose logs --tail=300 mediawiki&lt;br /&gt;
curl --fail --silent --show-error \&lt;br /&gt;
  &#039;https://s3wiki.yonsei.ac.kr/api.php?action=query&amp;amp;meta=siteinfo&amp;amp;format=json&#039;&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
DB 인증 불일치, 호환되지 않는 고정 확장 기능, Composer 빌드 실패, 디스크 부족, 잘못된 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;MW_SERVER_URL&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 확인합니다. 로그의 원인을 고치고 서비스를 다시 만드세요. 생성된 설정 볼륨을 직접 편집하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
=== 브라우저에 설치 세션 만료가 표시됨 ===&lt;br /&gt;
&lt;br /&gt;
이 메시지는 MediaWiki의 대화형 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/mw-config/&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 설치 화면에서 나옵니다. 이 배포는 해당 화면을 사용하지 않습니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-install&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; one-shot이 Apache와 분리된 secret 경계에서 CLI로 설치하고, 생성된 설정은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki_config&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 볼륨에 저장합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; entrypoint는 설정이 없으면 설치를 시도하지 않고 종료합니다. 세션 만료는 지원하지 않는 경로를 연 증상이지 원인이 아닙니다.&lt;br /&gt;
&lt;br /&gt;
&amp;amp;lt;!-- Contract marker: Do not increase &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;session.gc_maxlifetime&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; --&amp;amp;gt; &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;session.gc_maxlifetime&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 늘리거나, 브라우저 설치를 다시 시작하거나, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;LocalSettings.php&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 내려받거나, 볼륨을 지우지 마세요. 세션 수명만 늘려도 CLI 설정은 고쳐지지 않습니다.&lt;br /&gt;
&lt;br /&gt;
설치 탭을 닫고 상태와 로그를 확인합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose ps -a&lt;br /&gt;
docker compose logs --tail=300 mediawiki-install mediawiki bootstrap&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 실행 중이면 설정 내용을 출력하지 않고 영구 설정과 이미지의 symlink를 확인합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose exec mediawiki \&lt;br /&gt;
  sh -c &#039;test -s /var/www/data/LocalSettings.php &amp;amp;&amp;amp;&lt;br /&gt;
    test &amp;quot;$(readlink -f /var/www/html/LocalSettings.php)&amp;quot; = /var/www/data/LocalSettings.php &amp;amp;&amp;amp;&lt;br /&gt;
    stat -c &amp;quot;%a %U %G %n&amp;quot; /var/www/data /var/www/data/LocalSettings.php \&lt;br /&gt;
      /opt/s3wiki/LocalSettings.s3.php /var/www/html/LocalSettings.php&#039;&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
이미지는 시작할 때마다 다음 권한을 맞춥니다. 설정 디렉터리는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;750 root:www-data&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 생성된 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;LocalSettings.php&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;640 root:www-data&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, web root symlink는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;www-data&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 소유입니다. 비밀 정보가 없는 이미지 설정 파일은 Apache가 읽을 수 있습니다. upstream web root가 sticky이고 Linux의 보호된 symlink 동작이 있으므로 symlink 소유권도 필요합니다. API healthcheck는 설치 화면의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;200 OK&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; HTML이 아니라 MediaWiki JSON을 확인합니다.&lt;br /&gt;
&lt;br /&gt;
위 검사가 성공하면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 healthy, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 코드 0이 될 때까지 기다린 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/mw-config/&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 아니라 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;MW_SERVER_URL&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 엽니다.&lt;br /&gt;
&lt;br /&gt;
기준 데이터가 한 번도 없었던 새 시험 스택이라면 이미지와 서비스를 다시 만들어 CLI 설치를 실행합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose up -d --build --force-recreate mediawiki bootstrap&lt;br /&gt;
docker compose logs -f mediawiki-install mediawiki bootstrap&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
운영하던 배포에서 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;LocalSettings.php&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 잃었다면 여기서 진단을 멈춥니다. 기존 DB에 다시 설치하거나 볼륨을 지우지 마세요. [[Help:S3 연구 메모리/백업과 복원|&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;operations.md&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;]]에 따라 같은 시점의 DB, 설정, 업로드 백업을 복원합니다.&lt;br /&gt;
&lt;br /&gt;
=== &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-install&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 0이 아닌 코드로 끝남 ===&lt;br /&gt;
&lt;br /&gt;
새 볼륨의 첫 설치에서만 이 one-shot이 실제 설치를 합니다. 기존 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;LocalSettings.php&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 있으면 코드 0으로 아무것도 바꾸지 않는 것이 정상입니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose logs --tail=200 mediawiki-install&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
secret 파일 없음·빈 값·여러 줄 오류면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;MW_ADMIN_PASSWORD&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 Compose secret 선언을 확인합니다. DB 연결 오류면 먼저 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; health를 고칩니다. 설치 실패 뒤 브라우저 설치로 우회하거나 부분 생성된 설정을 직접 편집하지 마세요. 새 시험 볼륨이라 데이터가 없다는 사실을 확인한 경우에만 실패 원인을 고친 뒤 같은 one-shot을 다시 실행합니다. 운영 볼륨이면 복구 절차로 전환합니다.&lt;br /&gt;
&lt;br /&gt;
=== &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Exited (0)&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;임 ===&lt;br /&gt;
&lt;br /&gt;
정상입니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 daemon이 아니라 일회성 동기화 작업입니다. 평소에는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-jobs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 healthy이고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-install&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;·&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 코드 0으로 종료됩니다.&lt;br /&gt;
&lt;br /&gt;
=== &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 0이 아닌 코드로 끝남 ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose logs --tail=300 bootstrap&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
주요 원인은 다음과 같습니다.&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;administrator login failed&#039;&#039;&#039;: &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;MW_ADMIN_PASSWORD&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 설치된 위키와 다릅니다. 이 값은 최초 설치 때만 관리자 계정을 만듭니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;만 바꿔도 위키 비밀번호는 바뀌지 않습니다.&lt;br /&gt;
* &#039;&#039;&#039;manifest source is missing&#039;&#039;&#039;: 이미지 또는 저장소가 불완전합니다. 온전한 checkout에서 다시 빌드합니다.&lt;br /&gt;
* &#039;&#039;&#039;edit failed/permission denied&#039;&#039;&#039;: 관리자 권한이 없어졌거나 관리 페이지가 예상과 다르게 보호되어 있습니다.&lt;br /&gt;
* &#039;&#039;&#039;account provisioning failed&#039;&#039;&#039;: &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_WIKI_USERNAME&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 확인합니다. DB 테이블을 직접 고치지 말고 MediaWiki 절차로 전용 계정을 교체합니다.&lt;br /&gt;
* &#039;&#039;&#039;BotPassword secret/validation failed&#039;&#039;&#039;: &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_WIKI_BOT_PASSWORD&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;openssl rand -hex 16&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 만든 정확히 32자인지, app ID가 안전한 ASCII 1~32자인지 확인합니다. 로그에 secret을 붙이지 마세요.&lt;br /&gt;
* &#039;&#039;&#039;BotPassword login/effective rights failed&#039;&#039;&#039;: &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 grant를 저장한 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;&amp;amp;lt;기본 계정&amp;amp;gt;@&amp;amp;lt;app-id&amp;amp;gt;&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 실제 로그인 또는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;agent&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 유효 권한 확인에 실패했습니다. primary 비밀번호를 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 넣어 우회하지 말고 아래 전환 절차를 따릅니다.&lt;br /&gt;
원인을 고친 뒤 다시 실행합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose up -d --build --force-recreate bootstrap&lt;br /&gt;
docker compose logs --tail=200 bootstrap&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 관리 스키마와 양식을 맞추지만 일반 Lesson revision을 덮어쓰지 않습니다. 동기화 경고가 반복되면 우회하지 말고 원인을 확인하세요.&lt;br /&gt;
&lt;br /&gt;
=== 관리자 비밀번호가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 맞지 않음 ===&lt;br /&gt;
&lt;br /&gt;
첫 설치 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;MW_ADMIN_PASSWORD&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;만 바꿔도 MediaWiki에 저장된 비밀번호는 바뀌지 않습니다. 기존 비밀번호를 알고 있다면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 값을 다시 맞춥니다. 알 수 없다면 권한이 있는 서버 담당자가 고정된 MediaWiki 버전의 명령 도움말을 확인한 뒤 재설정합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose exec mediawiki \&lt;br /&gt;
  php maintenance/run.php resetPassword --help&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
현재 이미지가 출력한 문법만 사용합니다. 복구 작업을 기록하고, 추적하지 않는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 갱신한 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 다시 실행합니다. 새 비밀번호를 셸 기록에 직접 넣지 마세요.&lt;br /&gt;
&lt;br /&gt;
=== 기존 설치를 BotPassword로 전환하거나 BotPassword를 교체함 ===&lt;br /&gt;
&lt;br /&gt;
기존 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_WIKI_USERNAME&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;·&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_WIKI_PASSWORD&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 기본 계정을 유지하기 위한 one-shot 값입니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 다음 두 값을 추가합니다. 사용자 이름에는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;@app-id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 붙이지 않습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
S3RM_WIKI_BOT_APP_ID=mcp-http&lt;br /&gt;
S3RM_WIKI_BOT_PASSWORD=&amp;lt;openssl-rand-hex-16-output&amp;gt;&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
primary 비밀번호가 장기 서비스에 남는 시간을 만들지 않도록 다음 순서를 사용합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose stop mcp-http&lt;br /&gt;
docker compose run --rm bootstrap&lt;br /&gt;
docker compose up -d --build --force-recreate mcp-http&lt;br /&gt;
curl --fail --silent --show-error http://127.0.0.1:8765/healthz \&lt;br /&gt;
  | jq --exit-status \&lt;br /&gt;
      &#039;.canonical_store == &amp;quot;ok&amp;quot; and .job_runner == &amp;quot;ok&amp;quot;&#039;&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 로그에는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3ResearchAgent@mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 같은 로그인 이름만 나와야 하며 비밀번호는 나오지 않아야 합니다. health가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;canonical_store=ok&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이면 장기 MCP가 BotPassword로 실제 위키 로그인을 완료한 것입니다. 실패하면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 멈춘 채 이전 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 안전한 사본으로 되돌리거나 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 로그를 확인합니다.&lt;br /&gt;
&lt;br /&gt;
app ID를 바꾸면 새 app ID가 추가되고 이전 app ID는 자동 삭제되지 않습니다. 새 서비스 확인 후 관리 화면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Special:BotPasswords&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 이전 app ID만 폐기합니다. 기본 계정 자체나 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;agent&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 그룹, DB row를 직접 지우지 마세요.&lt;br /&gt;
&lt;br /&gt;
=== 양식이 없거나 Lesson이 template 원문으로 표시됨 ===&lt;br /&gt;
&lt;br /&gt;
# &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 코드 0으로 끝났는지 확인합니다.&lt;br /&gt;
# &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/index.php/Special:Version&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 Semantic MediaWiki, Page Forms, S3 Research Memory가 로드되었는지 확인합니다.&lt;br /&gt;
# &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Form:Lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;과 기준 template이 있는지 확인합니다.&lt;br /&gt;
# &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 다시 실행하고 로그를 봅니다.&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose up -d --force-recreate bootstrap&lt;br /&gt;
docker compose logs --tail=200 bootstrap&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
관리 양식이나 template 페이지를 직접 고치지 마세요. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 이미지의 원본으로 맞춥니다.&lt;br /&gt;
&lt;br /&gt;
=== &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-jobs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 unhealthy 상태임 ===&lt;br /&gt;
&lt;br /&gt;
runner는 한 process에서 batch당 기본 25건·20초만 처리합니다. 마지막 성공 batch가 기본 120초보다 오래되면 unhealthy가 되며, 반복 실패는 15초부터 최대 5분까지 지수 backoff합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose ps mediawiki-jobs&lt;br /&gt;
docker compose logs --tail=200 mediawiki-jobs&lt;br /&gt;
docker compose exec mediawiki-jobs cat /run/s3rm-jobs/status.env&lt;br /&gt;
docker compose exec mediawiki php maintenance/run.php showJobs --group&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;runner_generation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;consecutive_failures&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;queue_depth&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;updated_epoch&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 보고 DB·확장 기능·디스크 문제를 먼저 해결합니다. 상태 파일에는 secret이나 Lesson 본문이 없어야 합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_JOBS_HEALTH_STALE_SECONDS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_JOBS_MAX_SECONDS + S3RM_JOBS_IDLE_SECONDS + 30&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 이상이어야 합니다. 잘못된 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_JOBS_*&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 값은 이 관계와 각 허용 범위 안으로 고치고 컨테이너를 다시 만듭니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose up -d --force-recreate mediawiki-job-state-init mediawiki-jobs&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
health만 통과시키기 위해 stale 상한을 크게 늘리거나 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--nothrottle&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 무제한 job 수, 다중 runner를 사용하지 마세요. backlog가 정상 부하로 계속 늘 때만 처리 시간과 건수를 작게 올리고 CPU·DB latency를 함께 관찰합니다.&lt;br /&gt;
&lt;br /&gt;
=== Lesson ID 또는 양식 검증이 실패함 ===&lt;br /&gt;
&lt;br /&gt;
기준 ID 형식은 다음과 같습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
Lesson:&amp;lt;lowercase_slug&amp;gt;&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
slug는 소문자, 숫자, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;_&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;-&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;만 사용할 수 있습니다. 첫 글자는 문자나 숫자이고 최대 128자입니다. 화면 제목인 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;title&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 별도 필드이며 일반적인 대문자를 쓸 수 있습니다. 필수 연구 필드는 비울 수 없습니다. confidence는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;low&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;medium&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;high&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 중 하나입니다.&lt;br /&gt;
&lt;br /&gt;
=== 새 Lesson이 검색되지 않음 ===&lt;br /&gt;
&lt;br /&gt;
페이지를 직접 열어 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Lesson:&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 네임스페이스인지 먼저 확인합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/index.php/Special:Search&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 제목이나 본문의 구별되는 정확한 단어로 검색합니다. 먼저 bounded job runner와 대기 queue를 확인합니다. 색인이 손상되었거나 DB 복원 뒤 누락된 경우에만 검색 색인을 다시 만듭니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose ps mediawiki-jobs&lt;br /&gt;
docker compose exec mediawiki-jobs cat /run/s3rm-jobs/status.env&lt;br /&gt;
docker compose exec mediawiki php maintenance/run.php showJobs --group&lt;br /&gt;
docker compose exec mediawiki \&lt;br /&gt;
  php maintenance/run.php rebuildtextindex --quiet&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-jobs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 unhealthy이면 [[#mediawiki-jobs가 unhealthy 상태임|&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-jobs가-unhealthy-상태임&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;]]을 먼저 해결합니다. queue를 빨리 비우려고 무제한 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;runJobs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;나 여러 process를 동시에 실행하지 마세요.&lt;br /&gt;
&lt;br /&gt;
기본 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_EMBEDDING_PROVIDER=disabled&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서는 띄어쓰기와 단어 기반 검색이므로 비슷한 뜻의 다른 단어까지 찾는다고 보장하지 않습니다. 도메인을 넘는 연결은 관계나 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;find_analogies&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 사용합니다. 의미 검색을 켰더라도 클라이언트가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;retrieval_mode=hybrid_v1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 명시해야 합니다. 서비스 시작 시 단 하나의 백그라운드 작업이 전체 파생 인덱스를 프리웜합니다. hybrid 요청은 구축을 기다리지 않으며, 아직 snapshot이 없으면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;semantic_status=building&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;과 함께 즉시 기존 검색으로 대체됩니다. MCP Lesson 저장의 dirty hint는 즉시 백그라운드 대조를 시작합니다. 설정한 간격이 지나면 다음 hybrid 요청이 브라우저 편집까지 포함한 대조를 시작하며 기존 snapshot을 계속 사용합니다. 최종 결과는 MediaWiki 최신 revision으로 다시 확인하므로 오래된 의미 후보가 새 본문으로 반환되지는 않습니다.&lt;br /&gt;
&lt;br /&gt;
MCP 검색만 실패한다면 어댑터와 namespace ID를 확인하고 Action API 요청에 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;srwhat=text&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 포함되는지 확인합니다. 조회에는 Bearer 토큰이 필요하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
=== 의미 검색이 시작되지 않거나 기존 검색으로 대체됨 ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 시작 로그에 임베딩 설정 오류가 있으면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 다음 항목을 먼저 확인합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
S3RM_EMBEDDING_PROVIDER=openai-compatible&lt;br /&gt;
S3RM_EMBEDDING_API_URL=https://embedding.example/v1/embeddings&lt;br /&gt;
S3RM_EMBEDDING_MODEL=multilingual-embedding-model&lt;br /&gt;
S3RM_EMBEDDING_DIMENSIONS=768&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
provider 값은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;disabled&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 또는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;openai-compatible&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;만 받습니다. 사용 시 URL, 모델, 차원이 모두 필요합니다. URL에는 사용자 이름, 비밀번호, query와 fragment를 넣을 수 없습니다. 비-loopback HTTP는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_EMBEDDING_ALLOW_INSECURE_HTTP=true&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 명시하지 않으면 거부합니다.&lt;br /&gt;
&lt;br /&gt;
설정을 고친 뒤 서비스만 다시 만듭니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose config --quiet&lt;br /&gt;
docker compose up -d --build --force-recreate mcp-http&lt;br /&gt;
docker compose logs --tail=200 mcp-http&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
재시작 직후 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;semantic_status=building&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 기존 검색이 반환되는 것은 정상입니다. 잠시 뒤 새 첫 페이지 요청에서 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;ready&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;인지 확인합니다. 사용할 기존 snapshot이 있는 동안 백그라운드 갱신 중이면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;stale&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 갱신에 실패하면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;degraded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 hybrid 결과를 계속 제공할 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;unavailable&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 계속되거나 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;hybrid_v1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 요청이 반복해서 기존 검색으로 대체되면 제공자 timeout, 대기열 포화, 응답 차원 불일치 또는 0·비정상 벡터일 수 있습니다. 제공자의 모델 차원이 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_EMBEDDING_DIMENSIONS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 같은지 확인합니다. 제한을 무조건 키우기 전에 endpoint 상태와 모델 설정을 확인하세요. 의미 제공자 장애만으로 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/healthz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 실패시키지는 않으며 전체 텍스트와 관계 검색은 계속 제공합니다.&lt;br /&gt;
&lt;br /&gt;
진단을 공유할 때 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_EMBEDDING_API_KEY&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, Authorization 헤더, 원문 query, Lesson 본문과 전체 환경 dump를 지웁니다. API key를 명령행 인자나 임시 Lesson에 넣어 연결을 시험하지 마세요.&lt;br /&gt;
&lt;br /&gt;
=== MCP 클라이언트가 끊겼거나 도구가 보이지 않음 ===&lt;br /&gt;
&lt;br /&gt;
모든 클라이언트는 같은 공용 Streamable HTTP URL을 사용해야 합니다. 서버에서 자격 증명을 출력하지 않고 전체 상태와 프로세스 경로를 확인합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose ps -a caddy mcp-http mediawiki bootstrap db&lt;br /&gt;
curl --fail --silent --show-error http://127.0.0.1:8765/livez&lt;br /&gt;
curl --fail --silent --show-error http://127.0.0.1:8765/readyz&lt;br /&gt;
docker compose logs --tail=200 mcp-http caddy&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/livez&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 MCP 프로세스 실행만 확인합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/healthz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/readyz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 BotPassword로 canonical MediaWiki store까지 확인하며 연결이나 로그인이 실패하면 503을 반환합니다. 다른 PC에서 [[Help:S3 연구 메모리/설치와 검증#클라이언트를 연결하기 전 공용 MCP 확인|&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;setup.md&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;]]의 토큰 없는 초기화를 정확한 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;https://s3wiki.yonsei.ac.kr/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 주소로 실행합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Authorization&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 없이 초기화되어야 하며 도구 목록과 다섯 조회 도구도 공개되어야 합니다.&lt;br /&gt;
&lt;br /&gt;
다음 값을 확인합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
S3RM_MCP_PUBLIC_URL=https://s3wiki.yonsei.ac.kr/mcp&lt;br /&gt;
S3RM_MCP_WRITE_AUTH_MODE=token&lt;br /&gt;
S3RM_MCP_BIND_ADDRESS=127.0.0.1&lt;br /&gt;
S3RM_MCP_ALLOW_INSECURE_HTTP=false&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
먼저 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Authorization&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 없이 공개 조회를 시험합니다. 로컬 health만 성공하고 원격 초기화가 실패하면 Caddy 로그, TLS, DNS, 80/443을 확인합니다. 클라이언트를 localhost에 연결하거나 별도 MCP 프로세스를 만들지 마세요.&lt;br /&gt;
&lt;br /&gt;
클라이언트 주소는 정확히 공용 URL이어야 합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;token&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드에서 변경 도구를 쓸 때만 공용 Bearer 토큰을 불러옵니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;open&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드에서는 조회와 변경 도구 다섯 개에 인증이 필요하지 않습니다. 클라이언트 사용자에게 Docker 명령, MediaWiki API URL, 위키 자격 증명을 전달하지 마세요. 플랫폼별 확인은 [[Help:S3 연구 메모리/MCP 클라이언트|&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-clients.md&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;]]를 봅니다.&lt;br /&gt;
&lt;br /&gt;
=== MCP &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/healthz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 실패함 ===&lt;br /&gt;
&lt;br /&gt;
서버에서 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Connection refused&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 나오면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 설정된 bind·port에 리슨하지 않는 상태입니다. timeout은 호스트 방화벽이나 멈춘 프로세스일 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose ps mcp-http&lt;br /&gt;
docker compose logs --tail=200 mcp-http&lt;br /&gt;
docker compose up -d --force-recreate mcp-http&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
주요 시작 오류는 잘못된 쓰기 인증 모드, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;token&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드에서 잘못되거나 빠진 64자리 공용 토큰 해시, 43~256자 URL-safe revision HMAC 키 누락, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;github&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드에서 빠진 client ID·secret·allowlist, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 끝나지 않는 공개 URL, 명시적인 허용 없이 쓴 비-loopback HTTP, 이미 사용 중인 bind port입니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 고치고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;docker compose config --quiet&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 실행한 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;만 다시 만듭니다.&lt;br /&gt;
&lt;br /&gt;
=== GitHub OAuth 연결이 실패함 ===&lt;br /&gt;
&lt;br /&gt;
GitHub OAuth App callback URL이 다음 값과 정확히 같은지 확인합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
https://s3wiki.yonsei.ac.kr/oauth/github/callback&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_GITHUB_ALLOWED_USERS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_GITHUB_ALLOWED_ORGS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 GitHub 로그인 이름을 쉼표로 구분합니다. private 조직 membership을 확인하려면 사용자가 GitHub 승인 화면에서 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;read:org&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 허용해야 합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 다시 만들면 메모리에 있던 OAuth client와 token이 사라지므로 ChatGPT 앱 인증을 다시 진행합니다. 일반 설정은 URL &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;https://s3wiki.yonsei.ac.kr/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, bind &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;127.0.0.1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, allow flag &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;false&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;입니다. 같은 endpoint의 다섯 변경 도구에는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp:write&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; scope가 필요합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/livez&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 성공하지만 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/healthz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;·&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/readyz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 실패하면 프로세스는 살아 있으나 위키 연결·BotPassword 로그인 또는 bounded job runner가 준비되지 않은 상태입니다. 응답의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;canonical_store&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;job_runner&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 확인하고, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;job_runner=unavailable&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이면 [[#mediawiki-jobs가 unhealthy 상태임|&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki-jobs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 unhealthy 상태임]]을 따릅니다. 세 경로가 성공하는데 공용 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;만 실패하면 직접 IP 경로와 아래 HTTP 상태 항목을 확인합니다.&lt;br /&gt;
&lt;br /&gt;
=== MCP 변경 도구에서 인증 오류가 남 ===&lt;br /&gt;
&lt;br /&gt;
먼저 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_WRITE_AUTH_MODE&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 확인합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;open&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;인데 인증 오류가 남는다면 설정 변경 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 다시 만들지 않은 상태일 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose up -d --build --force-recreate mcp-http&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
아래 토큰 진단은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;token&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드에만 적용합니다.&lt;br /&gt;
&lt;br /&gt;
이 오류가 있어도 익명 초기화, 도구 목록, 조회는 동작해야 합니다. 다섯 변경 도구의 인증 오류는 Bearer 헤더가 없거나 형식이 틀리거나 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_TOKEN_SHA256&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;과 맞지 않는다는 뜻입니다. 비교할 때 어느 값도 출력하지 마세요. GitHub 모드에서는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp:write&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; scope가 필요합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;open&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드라면 다섯 도구에 별도 인증이 없습니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;revise_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 요청이 위키 저장 경계에서 거부되면 서버 내부의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_WIKI_REVISION_HMAC_KEY&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mediawiki&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 같은 값으로 읽는지 확인하되 값을 출력하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
서버에서 기존 HTTPS 토큰과 해시를 바꾸거나 출력하지 않고 확인합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
./scripts/configure-mcp-token.sh \&lt;br /&gt;
  --public-url https://s3wiki.yonsei.ac.kr/mcp&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
비공개 클라이언트 토큰이 없거나 맞지 않는다고 나오면 기존 전달 사본에서 원문을 복구합니다. 모든 쓰기 클라이언트를 함께 바꿀 수 있을 때만 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--rotate&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 사용하세요. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 다시 만들면 이전 토큰이 무효가 됩니다.&lt;br /&gt;
&lt;br /&gt;
쓰기 클라이언트에서는 시작 전에 지원하는 secret 또는 환경 설정으로 토큰을 불러옵니다. 추적되는 JSON·TOML이나 공개 진단에 넣지 마세요. 정적 헤더를 저장하지 못하는 호스팅 커넥터에는 OAuth 진입점이 필요합니다.&lt;br /&gt;
&lt;br /&gt;
=== MCP가 HTTP 403을 반환함 ===&lt;br /&gt;
&lt;br /&gt;
이 파일럿에서 403은 보통 요청 Origin이 정확히 허용되지 않았다는 뜻입니다. 민감한 값을 지운 요청 Origin을 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_PUBLIC_URL&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_ALLOWED_ORIGINS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 비교합니다. 의도한 정확한 HTTP(S) Origin만 쉼표로 추가하고 서비스를 다시 만듭니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose up -d --force-recreate mcp-http&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;*&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 추가하거나 Origin 검사를 끄지 마세요. Origin은 전체 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; URL이 아닙니다. CLI 클라이언트는 보통 Origin을 생략하지만 호스팅형·브라우저형 클라이언트는 보낼 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
=== 여러 변경 작업이 rate limit에서 멈춤 ===&lt;br /&gt;
&lt;br /&gt;
모든 MCP 클라이언트는 같은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_WIKI_USERNAME&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 저장하므로 계정별 편집 한도를 함께 사용합니다. 기본 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;agent&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 한도는 6000회/분이며, 일반 로그인 계정의 90회/분과 편집 이외 작업의 제한은 그대로입니다. 현재 컨테이너에 들어간 값은 비밀값을 출력하지 않고 확인할 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
printf &#039;%s\n&#039; &#039;var_export( $wgRateLimits[&amp;quot;edit&amp;quot;][&amp;quot;agent&amp;quot;] );&#039; \&lt;br /&gt;
  | docker compose exec -T mediawiki php maintenance/run.php eval.php&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
결과가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;6000, 60&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 아니면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_AGENT_EDITS_PER_MINUTE&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 확인하고 MediaWiki 컨테이너를 다시 만듭니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;restart&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;만 하면 기존 컨테이너 환경이 유지됩니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose up -d --force-recreate mediawiki&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
저장소의 MediaWiki 설정 파일을 갱신한 첫 배포에는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--build&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;도 붙입니다. 한도를 조정할 때 일반 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;user&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 값을 바꾸거나 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;agent&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;noratelimit&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 권한을 주지 마세요.&lt;br /&gt;
&lt;br /&gt;
오류 code가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;write_rate_limited&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 또는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;server_busy&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이면 MediaWiki보다 앞의 MCP admission입니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/healthz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 비밀값 없는 현재 active·waiting·principal 수와 설정된 상한을 확인합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
curl --fail --silent --show-error http://127.0.0.1:8765/healthz \&lt;br /&gt;
  | jq &#039;.write_admission&#039;&lt;br /&gt;
docker compose exec mcp-http sh -c \&lt;br /&gt;
  &#039;env | sort | grep &amp;quot;^S3RM_MCP_WRITE_&amp;quot;&#039;&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
기본은 동시 8개, queue 64개·2초, principal별 지속 600회/분·burst 120입니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;open&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드는 client IP별, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;github&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 subject별, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;token&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 공유 token 전체가 한 bucket입니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;retry_after_seconds&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 따르고, queue가 계속 차면 MariaDB latency와 job queue를 먼저 확인합니다. 필요한 경우 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_WRITE_MAX_CONCURRENCY&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;나 queue 값을 작게 조정한 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--force-recreate&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;합니다. rate와 burst를 높이면 오작동·오용 범위도 늘며, MediaWiki 6000회/분 공유 상한을 넘을 수는 없습니다.&lt;br /&gt;
&lt;br /&gt;
공개 요청의 IP별 bucket이 하나로 합쳐진다면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;configure-public-domain.sh&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 다시 실행해 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 서버 전용 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_PROXY_SECRET&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 준비하고 Caddy와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 함께 다시 만듭니다. 값 자체를 출력하거나 caller 헤더를 허용 목록처럼 등록하지 마세요.&lt;br /&gt;
&lt;br /&gt;
=== MCP가 HTTP 421을 반환함 ===&lt;br /&gt;
&lt;br /&gt;
421은 정확한 Host 헤더가 공개 URL host 또는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_ALLOWED_HOSTS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 항목과 맞지 않는다는 뜻입니다. 프록시가 Host를 바꾸거나, 클라이언트가 이전 IP URL을 쓰거나, 공개 URL의 포트가 틀린 경우가 많습니다.&lt;br /&gt;
&lt;br /&gt;
클라이언트 주소를 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_PUBLIC_URL&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;과 맞춥니다. 저장소의 Caddyfile은 공개 Host를 보존하므로 Caddy를 통과한 421은 보통 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;나 클라이언트가 이전 주소를 쓰는 경우입니다. 도메인 스크립트를 다시 실행하고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 Caddy를 다시 만듭니다. 등록된 공용 URL 대신 localhost나 IP를 쓰지 마세요.&lt;br /&gt;
&lt;br /&gt;
=== MCP HTTP는 열리지만 도구가 위키 인증에 실패함 ===&lt;br /&gt;
&lt;br /&gt;
초기화는 성공하지만 조회와 변경 도구에서 위키 인증 오류가 나오면 기본 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_WIKI_USERNAME&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_WIKI_BOT_APP_ID&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 또는 32자 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_WIKI_BOT_PASSWORD&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;bootstrap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 만든 BotPassword와 맞지 않을 수 있습니다. primary &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_WIKI_PASSWORD&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 전달되지 않는 것이 정상입니다. 전용 credential을 one-shot에서 맞추고 공용 어댑터를 다시 만듭니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose stop mcp-http&lt;br /&gt;
docker compose run --rm bootstrap&lt;br /&gt;
docker compose logs --tail=100 bootstrap&lt;br /&gt;
docker compose up -d --build --force-recreate mcp-http&lt;br /&gt;
curl --fail --silent --show-error http://127.0.0.1:8765/healthz \&lt;br /&gt;
  | jq --exit-status \&lt;br /&gt;
      &#039;.canonical_store == &amp;quot;ok&amp;quot; and .job_runner == &amp;quot;ok&amp;quot;&#039;&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Compose 내부 API 주소는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;http://mediawiki/api.php&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;입니다. 호스트의 localhost 주소가 아닙니다. 관리자 자격 증명을 넣거나, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;api.php&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 클라이언트에 직접 열거나, MCP 변경 인증을 끄는 방식으로 우회하지 마세요.&lt;br /&gt;
&lt;br /&gt;
=== Cursor가 잘못되었거나 요청과 맞지 않음 ===&lt;br /&gt;
&lt;br /&gt;
Cursor는 불투명한 값이며, 이를 만든 도구, query, filter, 선택 field, citation detail, analogy threshold에 묶입니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;get_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 하위 cursor와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;audit_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; cursor는 읽은 revision에도 묶입니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;next_cursor&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 바꾸지 말고 그대로 넘깁니다. parameter나 대상 revision이 달라졌다면 이전 cursor를 버리고 첫 페이지부터 시작합니다. Cursor는 Lesson ID가 아니며 해독하거나 편집하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
=== 결과가 잘렸거나 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;limit&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;보다 적음 ===&lt;br /&gt;
&lt;br /&gt;
모든 직렬화 결과에는 크기 제한이 있습니다. 목록 페이지에는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;page_limited_by_output&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 모든 결과에는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;output_truncated&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 표시될 수 있습니다. field 수나 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;limit&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 줄이고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;next_cursor&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 이어서 사용합니다. 목록 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;limit&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 1~20이며 기본값은 5입니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MAX_OUTPUT_BYTES&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 기본값은 32768이고 2048~65536만 받습니다. 그보다 높은 hard limit으로 올릴 수 없습니다. SDK 검증 오류를 포함한 전체 JSON-RPC HTTP body에는 별도 212992바이트 제한이 있습니다.&lt;br /&gt;
&lt;br /&gt;
큰 결과를 한 번에 받으려고 제한을 높이지 마세요. 먼저 검색 요약을 받고, 추린 결과에 대해 선택 field로 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;get_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 호출합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;citation_detail=full&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 근거 한 항목이 한도에 맞지 않으면 note를 조용히 자르지 않고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;output_budget_error&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 반환합니다. 필요한 근거를 더 작은 페이지로 요청하되, 한 항목 자체가 너무 크면 서버의 허용된 근거 길이와 출력 설정을 확인합니다.&lt;br /&gt;
&lt;br /&gt;
=== 의미 검색이 켜지지 않음 ===&lt;br /&gt;
&lt;br /&gt;
번들 GPU 모델을 쓸 때는 제공자와 Compose profile을 함께 켭니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
S3RM_EMBEDDING_PROVIDER=openai-compatible&lt;br /&gt;
COMPOSE_PROFILES=domain,embedding&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
설정 뒤 고정 모델 확인, 런타임 시작, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 재생성과 연결 검사를 한 번에 실행합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
make embedding-up&lt;br /&gt;
make verify-embedding&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
응답의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;semantic_status=disabled&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 실행 오류가 아니라 제공자가 꺼진 상태입니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;building&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 첫 exact index를 만드는 중이며 이때 기존 검색으로 즉시 대체합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;unavailable&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이면 아래 런타임 로그와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 로그를 확인합니다. 외부 임베딩 API로 자동 전환되지는 않습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;effective_mode=hybrid_v1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;인데 계속 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;semantic_status=stale&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이면 전체 Lesson 수가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_SEARCH_SCAN_LIMIT&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;보다 많은지 확인합니다. 현재 수보다 조금 큰 값으로 올리되 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;scan limit × 3 × dimensions&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 3,000,000개 벡터 구성요소를 넘지 않게 합니다. 설정을 바꾼 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 다시 만듭니다.&lt;br /&gt;
&lt;br /&gt;
=== GPU 임베딩 서비스가 시작되지 않음 ===&lt;br /&gt;
&lt;br /&gt;
모델 확인 작업과 런타임을 나눠 봅니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker compose --profile embedding ps -a embedding-model-init embedding&lt;br /&gt;
docker compose --profile embedding logs --tail=200 embedding-model-init embedding&lt;br /&gt;
ls -l /dev/dri/renderD128&lt;br /&gt;
vulkaninfo --summary&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;embedding-model-init&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 checksum 오류는 모델 파일, source commit, 크기, SHA-256, generation revision 중 일부가 서로 다른 상태입니다. 값을 임의로 완화하지 말고 같은 artifact 기준으로 모두 맞춥니다. 부분 파일은 다음 실행에서 이어받고, 잘못된 완성 파일은 init 작업이 제거한 뒤 다시 받습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;embedding&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 render node 권한 오류가 나면 호스트의 실제 장치와 group ID를 확인해 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_EMBEDDING_DRI_DEVICE&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_RENDER_GID&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 맞춥니다. 이 서버의 기본값은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/dev/dri/renderD128&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;987&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;입니다. 장치 번호를 바꾼 뒤 컨테이너 안의 별칭은 계속 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Vulkan0&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;인지 로그에서 확인합니다. 호스트 포트 8080을 열어 우회하지 마세요.&lt;br /&gt;
&lt;br /&gt;
긴 입력에서 timeout이나 GPU 메모리 오류가 반복되면 먼저 기본 동시성 1과 batch 6을 유지하고 query 길이를 줄입니다. 16K token보다 큰 입력은 같은 generation 계약에서 지원하지 않습니다. 실패 중에도 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;legacy&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 검색은 계속 사용할 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
=== 관계 추가가 거부됨 ===&lt;br /&gt;
&lt;br /&gt;
양쪽 Lesson이 이미 있어야 하고 기준 ID 형식이어야 합니다. 자기 자신으로 향하는 관계, 같은 edge 중복, 일곱 개 어휘 밖의 관계 이름은 거부합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;analogous_to&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 양방향으로 조회하지만 한 번만 저장합니다. 양끝을 바꾼 edge를 따로 만들 필요가 없습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supersedes&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 관계를 추가해도 연결만 기록합니다. 두 Lesson의 호환 상태는 바꾸지 않습니다.&lt;br /&gt;
&lt;br /&gt;
=== 근거 첨부가 거부됨 ===&lt;br /&gt;
&lt;br /&gt;
다음을 확인합니다.&lt;br /&gt;
&lt;br /&gt;
* 대상 Lesson이 있습니다.&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;citation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 추적 가능한 3~500자의 사람이 읽을 수 있는 참고문헌입니다.&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;kind&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;paper&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;code&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;dataset&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;log&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;benchmark&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;other&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 중 하나입니다.&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;verification_basis&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;full_text&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;official_abstract&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;partial_source&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;metadata_only&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 중 하나를 명시합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;not_recorded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 이전 자료를 읽는 호환값이며 새 evidence에는 쓸 수 없습니다.&lt;br /&gt;
* 선택 사항인 evidence ID는 해당 Lesson에서 고유하고 영문·숫자·&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;._:-&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;만 사용합니다.&lt;br /&gt;
* 선택 URL은 HTTP(S)이며 사용자 이름과 비밀번호가 없습니다.&lt;br /&gt;
* note는 2000자 이하입니다.&lt;br /&gt;
확인 범위는 신뢰도가 아니라 실제 읽은 자료 범위입니다. 서지정보만 확인했다면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;metadata_only&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 표시하고 기술 내용을 채우지 않습니다. 검증을 없애거나 관리자 자격 증명을 넣지 마세요. 브라우저 양식은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;not_recorded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 기본으로 선택하지 않으며 새 근거에 쓸 수 없습니다. high confidence Lesson을 새로 만들 때는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;full_text&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 또는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;official_abstract&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supports&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;인 초기 검증이 observation, interpretation, reusable_lesson 전부를 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;claim_fields&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 연결해야 합니다.&lt;br /&gt;
&lt;br /&gt;
=== 근거 검증 기록이 거부됨 ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;get_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 현재 revision, evidence &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;content_digest&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 기존 검증 기록을 다시 읽습니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;record_evidence_verification&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 다음 조건을 모두 요구합니다.&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;expected_revision_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 현재 Lesson revision과 같습니다.&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;expected_evidence_digest&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 대상 evidence의 현재 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;content_digest&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 같습니다.&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;verification_basis&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 이번에 실제로 검사한 범위이고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;not_recorded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 아닙니다.&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;source_identity&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;source_locator&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;coverage&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 실제 식별자·위치·범위가 있으며 문장부호만 입력하지 않았습니다.&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;source_sha256&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 실제로 검사한 64자 소문자 SHA-256입니다.&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;outcome&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supports&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;contradicts&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;inconclusive&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 중 하나이고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;claim_fields&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 중복 없이 실제로 확인한 주장만 가리킵니다.&lt;br /&gt;
원본 파일을 검사했다면 그 파일 바이트를 해시합니다. 저장·정규화한 텍스트를 검사했다면 그 정확한 텍스트의 UTF-8 바이트를 해시하고, 변환 방법과 보존한 산출물을 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;source_identity&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;coverage&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 명시합니다. 이전 기록은 수정·삭제할 수 없으므로 판정을 바꾸려면 새 기록을 추가합니다. MCP 호출에서는 서비스가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;verified_by&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 시각을 만듭니다. 브라우저·raw 저장은 MediaWiki revision actor·timestamp가 권위 출처이며, 하위 actor가 현재 저장 계정과 정확히 같고 시각이 저장 시각 전후 5분 안이며 monotonic인지 훅이 확인합니다. 양식을 5분 넘게 열어 두었다면 작성 내용을 안전하게 복사한 뒤 새로고침하거나, 편집 가능한 시각 필드를 현재 UTC로 갱신한 뒤 저장합니다. 숨겨진 시각은 직접 우회하지 말고 양식을 새로고침합니다.&lt;br /&gt;
&lt;br /&gt;
revision이나 evidence digest가 바뀌었다면 자동 병합하지 말고 현재 내용으로 검증을 다시 계산합니다. 같은 Lesson에서 같은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;operation_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 일반 쓰기, revision, 검증 기록의 다른 종류나 digest에 쓰면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;idempotency_conflict&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;입니다. 정확히 같은 종류·요청 digest를 재시도할 때만 같은 ID를 사용합니다.&lt;br /&gt;
&lt;br /&gt;
=== Lesson revision이 거부됨 ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;revise_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 현재 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;expected_revision_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 다른 값이 있는 핵심 필드, 고유한 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;operation_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 이유와 이미 붙은 basis evidence 1~8개가 필요합니다.&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;revision_conflict&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;: 다른 변경이 먼저 저장됐습니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;get_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 다시 읽고 변경안을 재계산합니다. 자동 병합하거나 이전 revision으로 반복하지 않습니다.&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;missing_basis_evidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;: 근거를 먼저 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;attach_evidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 붙이고, 응답의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;current_revision_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 새 기준으로 사용합니다.&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;no_changes&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;: 현재 내용과 같은 요청입니다. 새 Technical Review Lesson을 만들지 않습니다.&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;idempotency_conflict&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;: 같은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;operation_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 다른 요청에 재사용했습니다. 성공 여부가 불분명한 재시도는 원래 요청을 그대로 보내고, 새 작업에는 새 ID를 사용합니다.&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;write_outcome_unknown&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;: 원래 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;operation_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 요청을 그대로 재시도합니다. 적용 여부를 확인하기 전에 새 ID로 같은 수정을 보내지 않습니다.&lt;br /&gt;
operation replay와 충돌 검사는 해당 Lesson의 최신 revision부터 최대 10000개를 확인합니다.&lt;br /&gt;
&lt;br /&gt;
observation, interpretation, reusable_lesson을 바꾸고 저장 후 결과 confidence가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;medium&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 또는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;high&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;인 revision은 사용한 basis evidence의 최신 검증 기록이 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;full_text&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 또는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;official_abstract&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supports&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이고 바꾸는 필드를 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;claim_fields&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 연결해야 합니다. confidence를 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;high&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 하면 세 필드 모두를 뒷받침해야 합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;contradicts&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;inconclusive&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 이 조건을 충족하지 않습니다. 결과 confidence가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;low&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;인 교정은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;medium&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;/&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;high&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 낮추거나 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;low&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 유지하는 경우 모두 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;not_recorded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 아닌 basis evidence로 할 수 있습니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;write_outcome_unknown&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 즉시 같은 요청으로 재시도하고, 오래 보관할 작업 ID는 다시 쓰지 않습니다.&lt;br /&gt;
&lt;br /&gt;
같은 주장이나 같은 논문의 보강은 같은 Lesson ID의 revision으로 남깁니다. 출처만 추가할 때는 evidence, 독립된 새 주장이 이전 주장을 실제로 대신할 때만 새 Lesson과 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supersedes&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 사용합니다.&lt;br /&gt;
&lt;br /&gt;
=== Lesson 내용 정기 감사가 실패함 ===&lt;br /&gt;
&lt;br /&gt;
먼저 종료 코드와 Lesson 본문을 포함하지 않는 최근 로그를 확인합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
systemctl status s3rm-content-audit.service&lt;br /&gt;
sudo journalctl -u s3rm-content-audit.service --since &#039;8 days ago&#039;&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
종료 코드 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;2&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 감사를 완료했지만 확인할 Lesson이 있다는 뜻입니다. 로그의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;lesson_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;revision_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;codes&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 현재 Lesson과 근거를 확인합니다. 감사 작업은 Lesson을 자동으로 바꾸지 않습니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;verification_timestamp_order_invalid&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 같거나 거꾸로 된 검증 시각 때문에 append 순서의 최신 판정을 안전하게 정할 수 없다는 뜻입니다. 감사는 저장 구조·digest·검증 수령 기록의 일관성만 확인하며 외부 자료의 사실 진위를 판정하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
종료 코드 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 감사를 완료하지 못했다는 뜻입니다. 중앙 MCP와 HTTPS 상태를 먼저 확인합니다. 감사 중 Lesson이 바뀌었다면 현재 revision으로 다시 실행합니다. 목록이 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;scan_limit&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 잘렸다면 누락된 Lesson이 있으므로 성공으로 처리하지 마세요. 전체 Lesson 수에 맞춰 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_SEARCH_SCAN_LIMIT&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;과 서비스의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--max-lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 맞추되 각 값은 최대 1000을 넘기지 않습니다. 1000개가 넘으면 현재 작업으로 전수 감사했다고 표시하지 말고, 한도가 있는 분할 조회를 먼저 설계합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp-http&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 다시 만든 뒤 수동 감사를 확인합니다. [[Help:S3 연구 메모리/백업과 복원#Lesson 내용 정기 감사|&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;operations.md&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;]]를 참고하세요.&lt;br /&gt;
&lt;br /&gt;
=== 백업이 실패함 ===&lt;br /&gt;
&lt;br /&gt;
완료되지 않은 출력은 진단을 위해 보존하되 복구 지점으로 표시하지 않습니다. 디스크 여유 공간, 대상 권한, 서비스 상태, 마지막 오류를 확인합니다. 이전 정상 백업을 지우지 마세요. 원인을 고친 뒤 새 시각의 디렉터리로 다시 실행합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;SHOW MASTER STATUS&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, embedded coordinate 또는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;database-binlogs.tar.gz&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 오류는 PITR 구간이 없거나 dump와 좌표가 다르다는 뜻입니다. binary log 검증을 빼거나 manifest 좌표를 손으로 고치지 마세요. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 binlog 설정·볼륨·보존 기간을 고친 뒤 앱 쓰기를 멈춘 새 백업을 만듭니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;repository_untracked=present_not_archived&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 untracked 파일이 있었다는 상태만 기록하며 그 이름·내용이나 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 백업하지 않은 것이 정상입니다.&lt;br /&gt;
&lt;br /&gt;
=== 복원 스크립트가 백업을 거부함 ===&lt;br /&gt;
&lt;br /&gt;
checksum, manifest, archive, version 검증 실패는 현재 기준 데이터를 보호합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;--yes&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 반복해서 붙이거나, manifest를 고치거나, 다른 시각의 파일을 섞거나, SQL만 직접 가져오지 마세요. 복구 세트의 작업 사본에서 출처나 전송 손상을 조사하고, 가장 최근의 온전하고 호환되는 백업을 사용합니다. [[Help:S3 연구 메모리/백업과 복원|&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;operations.md&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;]]를 참고하세요.&lt;br /&gt;
&lt;br /&gt;
기준 restore는 dump import를 binary log에 다시 쓰지 않고, DB를 멈춘 뒤 기존의 더 새로운 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db_binlogs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; timeline을 비워 새 timeline을 시작합니다. 이 단계에서 중단되면 클라이언트를 계속 멈추고 restore 로그를 보존하세요. backup의 archived binlog는 자동 replay되지 않으므로 목표 시각 PITR이 필요하면 격리 환경에서 별도 검증합니다.&lt;br /&gt;
&lt;br /&gt;
격리 검증이 project 이름, 운영 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, loopback bind, 보조 checksum 또는 PITR 좌표를 거부했다면 guard를 끄지 마세요. mode &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;0600&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 별도 복원 env와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;s3rm-restore-...&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 이름, 사용하지 않는 loopback 포트를 새로 정합니다. 실패한 격리 볼륨은 진단을 위해 남으므로 다음 시도 전에 출력된 정확한 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;down --volumes&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 명령으로 그 project만 정리합니다. 운영 project나 이름이 비슷한 volume을 지우지 마세요.&lt;br /&gt;
&lt;br /&gt;
=== 컨테이너가 OOM 종료되거나 PID 상한에 닿음 ===&lt;br /&gt;
&lt;br /&gt;
health와 함께 실제 종료 원인, 사용량, 재시작 수를 확인합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
docker stats --no-stream&lt;br /&gt;
docker inspect --format \&lt;br /&gt;
  &#039;{{.Name}} oom={{.State.OOMKilled}} exit={{.State.ExitCode}} restarts={{.RestartCount}}&#039; \&lt;br /&gt;
  $(docker compose ps -q)&lt;br /&gt;
docker compose logs --tail=200 SERVICE&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
무한 재시작, 큰 요청, job backlog, DB query와 임베딩 batch를 먼저 확인합니다. 단순히 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mem_limit&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;·&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;pids_limit&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 없애면 호스트 전체 장애 범위가 커집니다. 정상 peak와 호스트 여유를 측정한 뒤 해당 서비스 상한만 조정하고 재발 여부를 봅니다. Docker json-file 로그는 파일당 10MB·5개로 회전하므로 오래된 진단이 필요하면 사전에 접근이 제한된 외부 로그 보관을 설정합니다.&lt;br /&gt;
&lt;br /&gt;
=== 디스크 사용량이 갑자기 늘어남 ===&lt;br /&gt;
&lt;br /&gt;
DB, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;db_binlogs&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, upload, build cache, log, 보관 백업을 나눠 확인합니다. 이름이 있는 볼륨을 지우지 마세요. 업로드 근거 기준과 백업 보관 정책을 확인합니다. 다시 만들 수 있는 Docker build cache·image만 서버 담당자의 확인 뒤 정리합니다. 큰 원본 파일은 정해진 object store로 옮기고 Lesson에는 오래 유지되는 인용을 남깁니다.&lt;br /&gt;
&lt;br /&gt;
그래도 문제가 남으면 버전, 정확한 명령, 종료 코드, 민감한 값을 지운 로그 끝부분, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;docker compose ps -a&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 비파괴 재시작 뒤에도 재현되는지를 기록합니다.&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC/%EB%A9%94%EB%AA%A8_%EC%82%AC%EC%9A%A9%EB%B2%95&amp;diff=1722</id>
		<title>도움말:S3 연구 메모리/메모 사용법</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC/%EB%A9%94%EB%AA%A8_%EC%82%AC%EC%9A%A9%EB%B2%95&amp;diff=1722"/>
		<updated>2026-07-18T10:09:46Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 저장소 문서 동기화&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;!-- S3RM managed repository document; edit the Markdown source, not this page. --&amp;gt;&lt;br /&gt;
&#039;&#039;이 페이지는 저장소 문서에서 자동으로 동기화됩니다. 위키에서 직접 편집하지 마세요.&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
S3 연구 메모리는 프로젝트 일지나 실험 원본을 모으는 곳이 아닙니다. 다른 작업에서도 다시 참고할 만한 시행착오를 짧은 메모로 남깁니다.&lt;br /&gt;
&lt;br /&gt;
=== 먼저 검색하기 ===&lt;br /&gt;
&lt;br /&gt;
브라우저에서는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Special:Search&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 에이전트에서는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;search_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 사용합니다. 프로젝트 이름보다 현상과 원인을 나타내는 말로 찾는 편이 잘 맞습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
메모리 단편화, retry storm, lock contention, cold start&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
같은 내용이 이미 있으면 새 메모를 만들지 않습니다. 기존 메모에 자료를 더하거나, 내용이 다른 이유를 관계로 연결하면 됩니다.&lt;br /&gt;
&lt;br /&gt;
=== 브라우저에서 메모 쓰기 ===&lt;br /&gt;
&lt;br /&gt;
로그인한 뒤 다음 화면을 엽니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
/index.php/연구_메모리#create-a-lesson&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
문서 ID에는 영문 소문자, 숫자, 밑줄, 하이픈만 씁니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Lesson:&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 입력하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
gpu_allocator_fragmentation&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
문서 ID는 에이전트가 메모를 인용할 때도 쓰므로 날짜, 사람 이름, 프로젝트에서만 통하는 줄임말은 피하세요. 화면에 보이는 제목은 나중에 바꿀 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
입력란에는 다음 내용을 적습니다.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 화면 !! 필드 !! 적을 내용&lt;br /&gt;
|-&lt;br /&gt;
| 제목 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;title&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 메모의 결론이 드러나는 짧은 제목&lt;br /&gt;
|-&lt;br /&gt;
| 궁금했던 점 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;question&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 시도를 시작한 이유&lt;br /&gt;
|-&lt;br /&gt;
| 해본 것 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;attempt&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 실제로 바꿨거나 비교한 것&lt;br /&gt;
|-&lt;br /&gt;
| 당시 조건 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;context&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 장비, 버전, 설정, 워크로드 등 결과에 영향을 준 조건&lt;br /&gt;
|-&lt;br /&gt;
| 실제 결과 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;observation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 직접 확인한 수치나 동작&lt;br /&gt;
|-&lt;br /&gt;
| 왜 그랬는지 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;interpretation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 가능한 원인과 아직 확실하지 않은 부분&lt;br /&gt;
|-&lt;br /&gt;
| 다음에 기억할 것 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;reusable_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 다른 작업에서도 써먹을 한 문장&lt;br /&gt;
|-&lt;br /&gt;
| 언제 맞는지 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;applicability&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 맞는 조건과 예외&lt;br /&gt;
|-&lt;br /&gt;
| 신뢰도 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;confidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 자료의 질에 따라 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;low&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;medium&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;high&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 중 선택&lt;br /&gt;
|-&lt;br /&gt;
| 관련 자료 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;evidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 아래에 연결할 자료를 짧게 설명&lt;br /&gt;
|-&lt;br /&gt;
| 자료 출처 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;record_origin&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 직접 얻은 내용은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;lab&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 외부 자료를 정리했으면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;imported&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
관찰과 해석은 나눠 쓰세요. 예를 들어 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;동시 compaction 16개에서 p99가 18 ms에서 71 ms로 늘었다&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 관찰입니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;공유 writeback 제한 때문에 queue가 묶였다&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 해석이며, 새 자료가 나오면 달라질 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
저장한 메모는 바로 검색되고 메인 화면의 최근 메모에도 나타납니다.&lt;br /&gt;
&lt;br /&gt;
=== 자료 연결하기 ===&lt;br /&gt;
&lt;br /&gt;
메모마다 자료를 하나 이상 연결합니다. 원본 전체를 위키에 복사하지 말고 나중에 다시 찾을 수 있는 위치를 적으세요.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 입력란 !! 내용&lt;br /&gt;
|-&lt;br /&gt;
| 자료 ID || 메모 안에서 겹치지 않는 짧은 ID&lt;br /&gt;
|-&lt;br /&gt;
| 출처 || 저장소, 커밋, 실행 ID, 문서 절, 시간 구간 등&lt;br /&gt;
|-&lt;br /&gt;
| URL || 바로 열 수 있는 주소가 있을 때만 입력&lt;br /&gt;
|-&lt;br /&gt;
| 자료 종류 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;paper&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;code&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;dataset&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;log&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;benchmark&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;other&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 중 선택&lt;br /&gt;
|-&lt;br /&gt;
| 확인 범위 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;full_text&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;official_abstract&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;partial_source&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;metadata_only&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 중 선택&lt;br /&gt;
|-&lt;br /&gt;
| 메모 || 이 자료에서 확인할 부분과 한계&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
git yonsei-s3/kernel-probes@4f19a2e, results/lockstat/run-031/summary.json&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
확인 범위는 자료에서 실제로 읽은 범위이며 Lesson의 종합 신뢰도인 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;confidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 다릅니다. 서지정보만 확인했다면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;metadata_only&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 표시하고 기술 내용을 추측하지 않습니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;not_recorded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 이전 근거에 값이 없을 때 표시되는 호환값이므로 새 근거에는 선택하지 않습니다. 브라우저 양식에도 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;not_recorded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 기본값이 없고 새 근거에 선택할 수 없습니다. MCP로 새 근거를 붙일 때는 나머지 네 값 중 하나를 명시해야 합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;latest&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;대시보드&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 작성자만 아는 로컬 경로처럼 시간이 지나면 찾기 어려운 표현은 피합니다. 비밀번호, 토큰, 개인 정보, 공개할 수 없는 대화, 대용량 원시 데이터도 본문에 넣지 않습니다.&lt;br /&gt;
&lt;br /&gt;
=== 근거를 검증한 기록 남기기 ===&lt;br /&gt;
&lt;br /&gt;
자료를 실제로 읽어 Lesson 주장을 확인했다면 기존 evidence를 고치지 말고 새 검증 기록을 붙입니다. 같은 evidence를 나중에 다시 확인했다면 이전 기록을 수정·삭제하지 않고 새 기록을 append-only로 붙입니다. evidence별 마지막 기록이 현재 판정입니다.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 입력란 !! 내용&lt;br /&gt;
|-&lt;br /&gt;
| evidence ID || 검사한 기존 근거 ID&lt;br /&gt;
|-&lt;br /&gt;
| evidence digest || 읽기 시점의 evidence 내용 digest&lt;br /&gt;
|-&lt;br /&gt;
| 확인 범위 || 이번에 실제로 검사한 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;verification_basis&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| 자료 식별자 || 파일, 커밋, 데이터 버전, 문서 버전 등 고정 식별자&lt;br /&gt;
|-&lt;br /&gt;
| 자료 SHA-256 || 실제로 검사한 정확한 바이트의 SHA-256&lt;br /&gt;
|-&lt;br /&gt;
| 위치 || 페이지, 절, 라인, 실험 구간 등 주장을 확인한 위치&lt;br /&gt;
|-&lt;br /&gt;
| 확인 범위와 한계 || 읽은 범위와 제외한 부분&lt;br /&gt;
|-&lt;br /&gt;
| 판정 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supports&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;contradicts&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;inconclusive&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 중 하나&lt;br /&gt;
|-&lt;br /&gt;
| 연결 주장 || 자료가 실제로 확인한 Lesson 필드&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
원본 파일을 검사했다면 그 파일 바이트를 해시합니다. 원본 대신 저장·정규화한 텍스트를 검사했다면 그 정확한 텍스트의 UTF-8 바이트를 해시합니다. 이 경우 변환 방법과 보존한 산출물을 자료 식별자와 확인 범위에 명시합니다. MediaWiki revision의 저장 계정과 시각이 권위 출처입니다. 검증 기록의 출처는 현재 저장 계정과 정확히 같아야 하고, 기록 시각은 revision 저장 시각 전후 5분 안이며 이전 기록보다 늦어야 하는 보조 필드입니다. 기존 출처와 시각은 수정할 수 없습니다.&lt;br /&gt;
&lt;br /&gt;
high confidence는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;full_text&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 또는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;official_abstract&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 기반으로 한 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supports&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 검증이 observation, interpretation, reusable_lesson 세 필드를 모두 뒷받침할 때만 사용합니다. observation, interpretation, reusable_lesson을 수정한 후 결과 confidence가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;medium&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 또는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;high&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이면 실제로 바꾸는 주장과 연결한 같은 강도의 검증이 필요합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;contradicts&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;나 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;inconclusive&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 지지 근거로 세지 않습니다. 결과 confidence가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;low&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;인 교정은 기존 confidence를 낮추는 경우를 포함해 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;not_recorded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 아닌 basis evidence로 할 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
=== 다른 메모와 연결하기 ===&lt;br /&gt;
&lt;br /&gt;
관계는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;출발 메모 관계값 대상 메모&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 순서로 읽습니다.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 관계 !! 뜻&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supports&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 대상 메모와 맞는 자료나 원인을 보탬&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;contradicts&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 대상 메모의 주요 주장과 관찰이 충돌함&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;analogous_to&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 분야는 달라도 같은 원인이나 실패 형태를 보임&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;failed_because&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 대상 메모에 적힌 이유로 시도가 실패함&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;works_when&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 대상 메모의 조건에서 방법이 잘 작동함&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;suggests&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 다음 가설이나 시도를 제안함&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supersedes&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 새 메모가 이전 안내를 대신함. 상태는 자동으로 바뀌지 않음&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
연결 이유에는 두 메모가 닮거나 충돌하는 지점을 한두 문장으로 씁니다. 관련성이 애매하면 억지로 연결하지 않아도 됩니다.&lt;br /&gt;
&lt;br /&gt;
=== 메모 고치기 ===&lt;br /&gt;
&lt;br /&gt;
메모 화면에서 &#039;&#039;&#039;메모 편집&#039;&#039;&#039;을 누릅니다. 잘못 적은 수치나 해석을 고치고, 수정 요약에 이유를 짧게 남기세요. 이전 내용은 MediaWiki 이력에서 확인할 수 있습니다. 에이전트가 고칠 때는 현재 revision과 근거를 다시 읽은 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;revise_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 사용합니다. 같은 논문의 보강 내용을 evidence note나 새 Technical Review 메모에 숨기지 않습니다. 새 근거를 먼저 붙였다면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;attach_evidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 응답의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;current_revision_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 다음 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;revise_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;expected_revision_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 사용합니다. 그 사이 다른 변경이 있었다면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;get_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 다시 읽고 변경안을 재계산합니다.&lt;br /&gt;
&lt;br /&gt;
=== MCP로 찾기 ===&lt;br /&gt;
&lt;br /&gt;
처음에는 결과를 3~5개만 받고 필요한 필드만 요청하는 편이 빠릅니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
S3 Research Memory에서 tail latency와 관련된 메모를 최대 5개 찾아 줘.&lt;br /&gt;
id, title, reusable_lesson, applicability, confidence, citations만 반환해.&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
후보를 고른 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;get_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 자세히 읽고, 관계는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;find_related_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 다른 분야의 비슷한 사례는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;find_analogies&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 찾습니다. 근거 note와 추가 정보를 확인하려면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;citation_detail=full&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 지정합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;get_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 revision ID는 이어지는 조건부 수정과 감사에 사용합니다. 답변에 메모를 사용했다면 반환된 자료 인용도 같이 보관하세요.&lt;br /&gt;
&lt;br /&gt;
서버에서 의미 검색을 켠 경우에는 표현이 다른 문제도 찾도록 다음처럼 요청할 수 있습니다. 기본 검색은 계속 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;legacy&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이므로 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;hybrid_v1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 직접 지정합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
search_lessons를 retrieval_mode=hybrid_v1로 실행해,&lt;br /&gt;
문제의 제약과 해결 원리가 비슷한 메모를 최대 5개 찾아 줘.&lt;br /&gt;
effective_mode와 semantic_status도 알려 줘.&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
의미 유사도는 저장된 관계나 사실이 아닙니다. 후보의 적용 조건과 근거를 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;get_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 확인한 뒤 실제 관계를 남길지 판단하세요.&lt;br /&gt;
&lt;br /&gt;
=== 에이전트에게 작성을 맡길 때 ===&lt;br /&gt;
&lt;br /&gt;
쓰기 토큰이 있는 에이전트는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;create_lesson_draft&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;link_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;attach_evidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;record_evidence_verification&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;revise_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 쓸 수 있습니다. 에이전트가 만든 메모와 revision은 저장 즉시 검색됩니다. 상태 변경, 삭제, 이동, 관리자 작업은 MCP에서 할 수 없습니다. 다섯 쓰기 도구는 모두 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;operation_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 필요합니다. 같은 Lesson 안에서 일반 쓰기, revision, 검증 기록은 ID 충돌 범위를 공유합니다. 같은 종류·요청의 정확한 재시도일 때만 같은 ID와 같은 요청을 보내고, 다른 작업에는 새 ID를 사용합니다. Lesson 생성에는 구조화된 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;initial_evidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 필수이며 Lesson 본문과 같은 revision에 저장됩니다.&lt;br /&gt;
&lt;br /&gt;
자료 위치와 확인한 사실을 먼저 제공하고 다음을 요청하세요.&lt;br /&gt;
&lt;br /&gt;
* 같은 메모가 있는지 먼저 검색&lt;br /&gt;
* 관찰과 해석을 분리&lt;br /&gt;
* 맞는 조건과 예외를 명시&lt;br /&gt;
* 자료를 추측하지 않기&lt;br /&gt;
* 검증 기록이 있다면 자료 digest·위치·범위·판정·연결 주장을 함께 확인&lt;br /&gt;
* 작업이 끝나면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Lesson:&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; ID 반환&lt;br /&gt;
* 여러 건을 바꿨다면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;audit_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 revision·내용·근거·관계를 확인&lt;br /&gt;
반환된 ID를 브라우저에서 열어 내용과 자료 위치를 확인합니다. 틀린 부분은 같은 ID의 새 revision으로 고칩니다. 출처만 추가할 때는 evidence, 독립된 새 주장일 때만 새 Lesson과 관계를 사용합니다. 감사는 저장된 구조, digest, 검증 수령 기록의 일관성을 확인하며 외부 자료의 사실 진위를 자동으로 판정하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
도구 입력과 응답 형식은 [[Help:S3 연구 메모리/MCP 클라이언트|MCP 클라이언트 안내]], 필드와 관계의 기계 계약은 [[Help:S3 연구 메모리/아키텍처|아키텍처]]에 있습니다.&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC/MCP_%ED%81%B4%EB%9D%BC%EC%9D%B4%EC%96%B8%ED%8A%B8&amp;diff=1721</id>
		<title>도움말:S3 연구 메모리/MCP 클라이언트</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC/MCP_%ED%81%B4%EB%9D%BC%EC%9D%B4%EC%96%B8%ED%8A%B8&amp;diff=1721"/>
		<updated>2026-07-18T10:09:46Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 저장소 문서 동기화&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;!-- S3RM managed repository document; edit the Markdown source, not this page. --&amp;gt;&lt;br /&gt;
&#039;&#039;이 페이지는 저장소 문서에서 자동으로 동기화됩니다. 위키에서 직접 편집하지 마세요.&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
연구실에서 운영하는 MCP 서버는 하나입니다. 모든 플랫폼은 서버 담당자가 알려 준 동일한 중앙 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 주소를 사용하며, 그 뒤에는 하나의 Semantic MediaWiki가 있습니다. 클라이언트 설정은 중앙 주소를 기억시키는 용도일 뿐 서버나 데이터 저장소를 새로 만들지 않습니다.&lt;br /&gt;
&lt;br /&gt;
=== 먼저 운영체제별 연결 ===&lt;br /&gt;
&lt;br /&gt;
공용 쓰기 토큰을 받은 뒤 운영체제에 맞는 문서에서 토큰 저장과 기본 연결을 마치세요.&lt;br /&gt;
&lt;br /&gt;
* [[Help:S3 연구 메모리/Linux 연결|Linux에서 연결]]&lt;br /&gt;
* [[Help:S3 연구 메모리/macOS 연결|macOS에서 연결]]&lt;br /&gt;
* [[Help:S3 연구 메모리/Windows 연결|Windows에서 연결]]&lt;br /&gt;
각 문서는 토큰 입력, 에이전트에게 연결 맡기기, 직접 연결과 확인 순서로 되어 있습니다. 이 문서는 웹 커넥터와 제품별 제약을 확인할 때 사용합니다.&lt;br /&gt;
&lt;br /&gt;
각 PC에는 이 저장소, Docker, 위키 비밀번호가 필요하지 않습니다. 사용자용 연결 주소로 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;localhost&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;127.0.0.1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, SSH port forwarding, generic MediaWiki &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;api.php&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 사용하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
=== 지원 상태 ===&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 플랫폼 !! 중앙 연결 방식 !! 공개 읽기 !! 현재 메모 쓰기&lt;br /&gt;
|-&lt;br /&gt;
| ChatGPT 웹 || 조직 앱 게시 또는 사용자 설정의 개발자 모드 초안 || 가능 || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;open&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드의 Business/Enterprise/Edu에서 가능&lt;br /&gt;
|-&lt;br /&gt;
| ChatGPT desktop app의 Codex (macOS/Windows) || Codex 공용 MCP 설정 || 가능 || Bearer 환경변수&lt;br /&gt;
|-&lt;br /&gt;
| Codex CLI || Codex 공용 MCP 설정 || 가능 || Bearer 환경변수&lt;br /&gt;
|-&lt;br /&gt;
| VS Code Codex 확장 || 같은 Codex 설정 || 가능 || Bearer 환경변수&lt;br /&gt;
|-&lt;br /&gt;
| Claude 웹 || 중앙 HTTPS 커넥터 || 가능 || OAuth 추가 뒤 가능&lt;br /&gt;
|-&lt;br /&gt;
| Claude Desktop || 같은 Claude 계정 커넥터 || 가능 || OAuth 추가 뒤 가능&lt;br /&gt;
|-&lt;br /&gt;
| Claude CLI / Claude Code || 중앙 HTTPS URL 직접 등록 || 가능 || 공용 Bearer 헤더&lt;br /&gt;
|-&lt;br /&gt;
| VS Code Claude 확장 || 같은 Claude Code 설정 || 가능 || 공용 Bearer 헤더&lt;br /&gt;
|-&lt;br /&gt;
| Cursor || 사용자 전역 원격 MCP 설정 || 가능 || Bearer 환경변수 헤더&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== ChatGPT 웹 ===&lt;br /&gt;
&lt;br /&gt;
조직에서 함께 쓸 때는 워크스페이스 관리자가 중앙 MCP를 앱으로 등록하고 게시합니다. 사용자 설정의 개발자 모드에서는 같은 URL을 초안으로 먼저 시험할 수 있습니다. ChatGPT는 각 PC의 Codex 설정을 읽지 않습니다.&lt;br /&gt;
&lt;br /&gt;
==== 워크스페이스 관리자가 한 번만 설정 ====&lt;br /&gt;
&lt;br /&gt;
# 사용자 계정에서 developer mode를 사용할 수 있는지 확인한 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Workspace settings → Apps → Create&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 엽니다.&lt;br /&gt;
# 이름은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3 Research Memory&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, MCP URL은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;https://s3wiki.yonsei.ac.kr/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 입력합니다.&lt;br /&gt;
# &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Scan tools&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 표시된 열 도구와 read/write annotation을 확인합니다.&lt;br /&gt;
# &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Create&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 누릅니다.&lt;br /&gt;
# &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Workspace settings → Apps → Drafts&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 항목을 열고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Publish&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 누릅니다. 표시되면 접근 범위와 action control도 정합니다.&lt;br /&gt;
# 공개 읽기 도구를 확인합니다. 서버가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;open&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드라면 변경 도구 다섯 개도 인증 없이 동작하고, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;token&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드라면 거부됩니다.&lt;br /&gt;
ChatGPT 앱은 처음 스캔한 도구와 스키마를 자동으로 갱신하지 않습니다. 서버 업데이트 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;audit_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;나 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;revise_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 보이지 않으면 게시 전 Draft에서 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Scan tools&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 다시 실행합니다. Enterprise/Edu에 게시한 앱은 앱 설정의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Refresh&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 새 action을 불러와 검토하고 필요한 action을 허용합니다. Business에 게시한 앱은 현재 새 앱으로 다시 만들어 게시해야 합니다. 갱신 뒤 읽기 다섯 개와 쓰기 다섯 개, 모두 열 도구인지 확인합니다. 자세한 제한은 [https://help.openai.com/en/articles/12584461-developer-mode-apps-and-full-mcp-connectors-in-chatgpt-beta ChatGPT 사용자 정의 MCP 안내]를 따릅니다.&lt;br /&gt;
&lt;br /&gt;
공개 읽기는 OAuth 없이 됩니다. full MCP 쓰기는 Business, Enterprise, Edu에서만 지원됩니다. 서버가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;open&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드면 OAuth 없이도 변경 도구 다섯 개를 쓸 수 있습니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;token&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드에서 웹 쓰기를 열려면 OAuth를 추가해야 합니다. Pro는 read/fetch만 쓸 수 있습니다. Agent mode는 사용자 정의 앱을 사용하지 않고 Deep Research도 read/fetch만 사용합니다.&lt;br /&gt;
&lt;br /&gt;
서버가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;github&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드라면 앱 인증 방식으로 OAuth를 선택합니다. ChatGPT는 MCP의 discovery 문서를 읽고 GitHub 로그인으로 이동합니다. GitHub OAuth App callback은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;https://s3wiki.yonsei.ac.kr/oauth/github/callback&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이어야 합니다. 이 모드에서는 검색을 포함한 MCP 연결 전체가 로그인 대상입니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mcp:write&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; scope는 생성, 관계, 근거 첨부·검증 기록과 조건부 수정에 함께 적용됩니다.&lt;br /&gt;
&lt;br /&gt;
==== 사용자 설정에서 초안 시험 ====&lt;br /&gt;
&lt;br /&gt;
# &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Settings → Apps → Advanced settings&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Developer mode&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 켭니다.&lt;br /&gt;
# &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Settings → Apps&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 돌아가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Create&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 누릅니다.&lt;br /&gt;
# 같은 이름과 중앙 URL을 입력하고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Scan tools&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 열 도구를 확인합니다.&lt;br /&gt;
# 앱을 만든 뒤 새 대화에서 공개 검색을 시험합니다.&lt;br /&gt;
이 메뉴는 요금제와 워크스페이스 정책에 따라 보이지 않을 수 있습니다. 사용자 설정에서 만든 항목은 조직에 게시된 앱이 아니므로, 모두에게 배포하려면 위 관리자 절차를 따릅니다.&lt;br /&gt;
&lt;br /&gt;
==== 사용자 설정 ====&lt;br /&gt;
&lt;br /&gt;
# 새 ChatGPT 대화를 엽니다.&lt;br /&gt;
# 앱 또는 도구 메뉴에서 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3 Research Memory&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 선택합니다.&lt;br /&gt;
# 공개 읽기 연결을 켜고 검색 요청으로 확인합니다.&lt;br /&gt;
ChatGPT 웹은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;~/.codex/config.toml&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 읽지 않습니다. 자세한 현재 절차는 [https://help.openai.com/en/articles/12584461-developer-mode-apps-and-full-mcp-connectors-in-chatgpt-beta ChatGPT 사용자 정의 MCP 안내], [https://developers.openai.com/apps-sdk/deploy/connect-chatgpt ChatGPT app 연결]과 [https://developers.openai.com/apps-sdk/build/auth 인증 안내]를 따릅니다.&lt;br /&gt;
&lt;br /&gt;
=== ChatGPT desktop app의 Codex, Codex CLI, VS Code Codex 확장 ===&lt;br /&gt;
&lt;br /&gt;
세 클라이언트는 같은 머신에서 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;~/.codex/config.toml&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 공유합니다. 한 번 등록하면 그 PC에서는 앱, CLI, 확장에 따로 같은 서버를 만들지 않습니다.&lt;br /&gt;
&lt;br /&gt;
==== 연구실 기본 설정 ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_TOKEN&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 현재 프로세스가 읽을 수 있는 환경에 넣고 다음 명령을 한 번 실행합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
codex mcp add s3-research-memory \&lt;br /&gt;
  --url https://s3wiki.yonsei.ac.kr/mcp \&lt;br /&gt;
  --bearer-token-env-var S3RM_MCP_TOKEN&lt;br /&gt;
codex mcp list&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
CLI 명령 직후에는 URL과 환경변수 이름이 저장됩니다. 실제 토큰 값은 들어가지 않습니다. 그 서버 블록에 승인 모드 한 줄을 직접 병합합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
[mcp_servers.s3-research-memory]&lt;br /&gt;
url = &amp;quot;https://s3wiki.yonsei.ac.kr/mcp&amp;quot;&lt;br /&gt;
bearer_token_env_var = &amp;quot;S3RM_MCP_TOKEN&amp;quot;&lt;br /&gt;
default_tools_approval_mode = &amp;quot;auto&amp;quot;&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;codex mcp add&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;default_tools_approval_mode&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 줄을 자동으로 만들지 않습니다. ChatGPT desktop app의 Codex에서 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Settings → Configuration → Open config.toml&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 누르거나 편집기를 열어 위 한 줄만 같은 서버 블록에 추가합니다. 다른 MCP 블록을 덮어쓰지 않습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;auto&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서는 Codex가 도구 annotation에 따라 승인 필요 여부를 판단합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;revise_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;처럼 destructive로 표시된 도구는 확인을 요청할 수 있습니다. 서버는 쓰기를 메모 작성, 관계 연결, 근거 첨부·검증과 조건부 본문 수정으로 제한합니다.&lt;br /&gt;
&lt;br /&gt;
* ChatGPT desktop app의 Codex(macOS/Windows): &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Settings → MCP servers&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 항목을 확인하고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Restart&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;합니다.&lt;br /&gt;
* Codex CLI: &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;codex mcp list&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 대화형 화면에서는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 사용합니다.&lt;br /&gt;
* VS Code Codex 확장: 톱니바퀴의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;MCP servers&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 확인한 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Restart extension&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 누릅니다.&lt;br /&gt;
GUI 앱은 셸에서 임시로 export한 환경변수를 받지 못할 수 있습니다. 이때는 운영체제 사용자 환경에 토큰을 넣고 앱과 확장을 완전히 종료했다가 다시 실행합니다.&lt;br /&gt;
&lt;br /&gt;
==== OAuth를 추가한 경우 ====&lt;br /&gt;
&lt;br /&gt;
사용자별 계정이 필요해 OAuth를 추가했다면 앱이나 확장의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Authenticate&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 로그인할 수 있습니다. 공용 토큰을 쓰는 현재 기본 설정에는 필요하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
codex mcp add s3-research-memory --url https://s3wiki.yonsei.ac.kr/mcp&lt;br /&gt;
codex mcp login s3-research-memory&lt;br /&gt;
codex mcp list&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
공식 설정 항목과 세 클라이언트의 공유 방식은 [https://developers.openai.com/codex/mcp Codex MCP 문서]를 기준으로 했습니다.&lt;br /&gt;
&lt;br /&gt;
=== Claude 웹과 Claude Desktop ===&lt;br /&gt;
&lt;br /&gt;
==== Team/Enterprise 관리자가 한 번만 설정 ====&lt;br /&gt;
&lt;br /&gt;
# Claude 웹에서 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Organization settings → Connectors → Add → Custom → Web&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 엽니다.&lt;br /&gt;
# 이름을 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3 Research Memory&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, URL을 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;https://s3wiki.yonsei.ac.kr/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 입력합니다.&lt;br /&gt;
# &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;open&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드는 인증을 추가하지 않습니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;github&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드는 OAuth를 선택해 GitHub로 로그인합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;token&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드는 웹 커넥터 쓰기에 사용하지 않습니다.&lt;br /&gt;
# 사용할 수 있는 계정 범위를 정합니다.&lt;br /&gt;
웹과 Desktop의 사용자 정의 커넥터에는 임의의 공용 Bearer header를 넣을 수 있다고 가정하지 않습니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;open&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 또는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;github&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드를 사용하세요.&lt;br /&gt;
&lt;br /&gt;
==== 사용자 설정 ====&lt;br /&gt;
&lt;br /&gt;
Claude 웹이나 Desktop에서 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Customize → Connectors&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 열고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3 Research Memory&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 연결합니다. 채팅에서는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;+ → Connectors&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 활성화합니다.&lt;br /&gt;
&lt;br /&gt;
Claude Desktop은 같은 Claude 계정의 원격 커넥터를 사용합니다. 별도 Desktop 설정 파일을 만들지 않고, 같은 계정으로 로그인한 뒤 채팅에서 connector를 켭니다. Hosted connector의 요청은 사용자 PC가 아니라 Anthropic cloud에서 중앙 MCP로 들어옵니다. 그래서 모든 플랫폼이 같은 공개 HTTPS 주소를 사용합니다.&lt;br /&gt;
&lt;br /&gt;
개인 Free/Pro/Max 계정은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Customize → Connectors → + → Add custom connector&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 같은 URL을 등록할 수 있습니다. Free 계정은 사용자 정의 커넥터 한 개까지 쓸 수 있습니다. Team/Enterprise에서는 소유자가 등록한 connector를 사용합니다. [https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp Anthropic remote connector 문서]에서 현재 절차를 확인할 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
=== Claude CLI / Claude Code와 VS Code Claude 확장 ===&lt;br /&gt;
&lt;br /&gt;
Claude Code에는 연구실 중앙 HTTPS 주소와 공용 토큰 헤더를 사용자 범위에 한 번 등록합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
claude mcp add --transport http --scope user \&lt;br /&gt;
  s3-research-memory https://s3wiki.yonsei.ac.kr/mcp \&lt;br /&gt;
  --header &#039;Authorization: Bearer ${S3RM_MCP_TOKEN}&#039;&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
작은따옴표를 유지하면 셸이 토큰을 먼저 펼치지 않습니다. Claude 설정에는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;${S3RM_MCP_TOKEN}&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 참조가 저장되고, 실행할 때 사용자 환경에서 값을 읽습니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;claude&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 시작한 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 연결을 확인합니다. 같은 PC의 VS Code Claude 확장도 이 설정을 사용합니다. VS Code 확장을 쓸 때는 확장 프로세스도 토큰 환경변수를 읽을 수 있게 한 뒤 완전히 다시 시작합니다.&lt;br /&gt;
&lt;br /&gt;
Claude.ai에 HTTPS connector를 등록한 조직에서는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;claude.ai&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 영역에 나타나는 같은 중앙 커넥터를 대신 켤 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
[https://code.claude.com/docs/en/mcp Claude Code MCP 문서]와 [https://code.claude.com/docs/en/ide-integrations VS Code 통합 문서]가 claude.ai connector 동기화, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, OAuth와 remote HTTP의 기준입니다.&lt;br /&gt;
&lt;br /&gt;
=== Cursor ===&lt;br /&gt;
&lt;br /&gt;
Cursor의 사용자 전역 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;~/.cursor/mcp.json&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 중앙 HTTPS 주소와 공용 토큰 헤더를 등록하면 모든 프로젝트와 Cursor CLI가 같은 지식을 읽고 메모도 남길 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;mcpServers&amp;quot;: {&lt;br /&gt;
    &amp;quot;s3-research-memory&amp;quot;: {&lt;br /&gt;
      &amp;quot;url&amp;quot;: &amp;quot;https://s3wiki.yonsei.ac.kr/mcp&amp;quot;,&lt;br /&gt;
      &amp;quot;headers&amp;quot;: {&lt;br /&gt;
        &amp;quot;Authorization&amp;quot;: &amp;quot;Bearer ${env:S3RM_MCP_TOKEN}&amp;quot;&lt;br /&gt;
      }&lt;br /&gt;
    }&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Cursor의 환경변수 문법은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;${env:NAME}&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;입니다. 토큰을 운영체제의 사용자 환경에 넣고 Cursor를 완전히 종료했다가 다시 실행합니다.&lt;br /&gt;
&lt;br /&gt;
Cursor 설치 링크 (&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;cursor://anysphere.cursor-deeplink/mcp/install?name=s3-research-memory&amp;amp;amp;config=eyJ1cmwiOiJodHRwczovL3Mzd2lraS55b25zZWkuYWMua3IvbWNwIiwiaGVhZGVycyI6eyJBdXRob3JpemF0aW9uIjoiQmVhcmVyICR7ZW52OlMzUk1fTUNQX1RPS0VOfSJ9fQ%3D%3D&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;)는 위 server object를 Cursor 설정 화면에 추가합니다. 링크에는 중앙 URL과 토큰 환경변수 이름이 들어 있지만 실제 토큰 값은 없습니다. 수동 설정 뒤에는 Cursor 사이드바의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Customize → MCP&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 활성 상태를 확인합니다. Cursor CLI에서는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;agent mcp list&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;agent mcp list-tools s3-research-memory&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 사용합니다. 명령 형식은 [https://cursor.com/docs/cli/reference/parameters Cursor CLI 공식 문서]를 따릅니다.&lt;br /&gt;
&lt;br /&gt;
Cursor Teams/Enterprise 관리자는 Dashboard의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Integrations &amp;amp;amp; MCP&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 같은 설정을 Team Marketplace에 올리고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Default On&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 또는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Required&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 배포할 수 있습니다. 공식 스키마와 팀 배포 방식은 [https://cursor.com/docs/mcp Cursor MCP]와 [https://cursor.com/docs/plugins Cursor plugin 배포]를 따릅니다.&lt;br /&gt;
&lt;br /&gt;
VS Code의 일반 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.vscode/mcp.json&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;과 Cursor의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.cursor/mcp.json&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 스키마가 다릅니다. 또한 일반 VS Code MCP 설정이 Codex 또는 Claude 확장에 자동 적용된다는 공식 근거가 없으므로, 이 문서에서는 각 공급자의 설정만 사용합니다.&lt;br /&gt;
&lt;br /&gt;
=== 도구와 권한 ===&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 도구 !! 용도 !! 쓰기 여부&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;search_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 전체 텍스트 검색과 필터 || 읽기&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;get_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 지정한 메모와 필드 조회 || 읽기&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;audit_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 정확한 ID의 revision·내용·근거·관계 검사 || 읽기&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;find_related_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 들어오고 나가는 관계 탐색 || 읽기&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;find_analogies&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 명시된 관계와 후보 유사 사례 탐색 || 읽기&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;create_lesson_draft&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 검증된 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Lesson:&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 페이지 생성 || 메모 작성&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;link_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 허용된 관계 하나 추가 || 관계 추가&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;attach_evidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 인용 가능한 근거 하나 추가 || 근거 추가&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;record_evidence_verification&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || evidence의 append-only 검증 수령 기록 추가 || 검증 추가&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;revise_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || 같은 Lesson의 근거 기반 핵심 내용 수정 || 메모 수정&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
일반 위키 조작, 승인, 상태 전이, 삭제, 이동, 관리자 도구는 제공하지 않습니다. 메모를 만들 때 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;review_state&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;reviewer&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, timestamp를 에이전트가 정할 수도 없습니다. 같은 주장이나 같은 원천의 보강은 같은 Lesson의 revision으로 남깁니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supersedes&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 관계는 독립된 새 주장이 기존 주장을 실제로 대신할 때만 사용하며 대상 메모의 호환 상태값을 바꾸지 않습니다.&lt;br /&gt;
&lt;br /&gt;
=== 요청 형식 ===&lt;br /&gt;
&lt;br /&gt;
모든 요청은 알 수 없는 필드를 거부합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;fields&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 쓸 수 있는 값은 다음과 같습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
id, title, question, attempt, context, observation, interpretation,&lt;br /&gt;
reusable_lesson, applicability, confidence, evidence, record_origin,&lt;br /&gt;
author, reviewer, review_state, created_at, updated_at, citations,&lt;br /&gt;
verifications, relations&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 생략해도 결과에는 자동으로 들어갑니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;citations&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 짧은 구조화 근거이고, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;evidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 메모 본문의 근거 설명입니다.&lt;br /&gt;
&lt;br /&gt;
읽기 도구의 주요 인자는 다음과 같습니다.&lt;br /&gt;
&lt;br /&gt;
* `search_lessons(query=&amp;quot;&amp;quot;, filters=null, fields=null, retrieval_mode=&amp;quot;legacy&amp;quot;, limit=5, cursor=null)`: 검색어는 최대 500자이며 빈 검색어는 필터에 맞는 메모를 나열합니다.&lt;br /&gt;
* `get_lesson(lesson_id, fields=null, citation_limit=5, citation_cursor=null, citation_detail=&amp;quot;compact&amp;quot;, verification_limit=5, verification_cursor=null, relation_limit=5, relation_cursor=null)`: 기본값은 요약 필드입니다. 현재 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;revision_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 항상 반환합니다. 근거, 검증 기록과 관계는 각각 최대 20개씩 독립적으로 페이지를 넘길 수 있습니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;citation_detail=full&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 근거 note, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;added_by&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;added_at&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;까지 반환합니다. 본문은 필요한 필드를 지정해 가져옵니다.&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;audit_lessons(expectations, limit=20, cursor=null)&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;: 기대값은 고유 Lesson ID 최대 50개입니다. 선택적으로 revision, Lesson 내용 digest, evidence ID·digest·URL·확인 범위, relation을 검사합니다. evidence와 relation 기대값은 Lesson마다 각각 최대 8개이며 결과 index는 입력 배열의 위치입니다. 이 도구는 저장된 기대값의 일치만 확인하며 외부 자료의 사실 진위를 판정하지 않습니다.&lt;br /&gt;
* `find_related_lessons(lesson_id, relations=null, direction=&amp;quot;both&amp;quot;, filters=null, fields=null, limit=5, cursor=null)&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;: &amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;direction&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;은 &amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;outgoing`, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;incoming&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;both&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 중 하나입니다. 저장된 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;analogous_to&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 양방향으로 보입니다.&lt;br /&gt;
* `find_analogies(lesson_id, filters=null, fields=null, retrieval_mode=&amp;quot;legacy&amp;quot;, min_score=0.08, limit=5, cursor=null)`: 명시된 유사 관계는 score 1입니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;legacy&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 나머지 후보는 제한된 단어 겹침과 최대 8개 공통어를 반환합니다. 후보는 저장된 관계가 아닙니다.&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;retrieval_mode&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;legacy&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;hybrid_v1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 중 하나입니다. 기본 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;legacy&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 기존 순서와 점수를 유지합니다. 서버에서 의미 검색을 켠 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;hybrid_v1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 명시하면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;search_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 전체 텍스트와 상황·시도·해결 원리 facet을, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;find_analogies&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 저장된 관계와 단어 겹침 및 상황·해결 원리 facet을 결합합니다. analogy의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;hybrid_v1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 점수는 raw cosine이 아니라 0~1 결합 점수이며 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;min_score&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;도 이 점수에 적용됩니다. analogy의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;basis&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;explicit_relation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;lexical_overlap&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;semantic_similarity&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;hybrid&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 중 하나입니다. 의미 경로가 포함되면 선택 항목인 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;matched_facets&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;situation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;approach&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;mechanism&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 중 실제로 맞은 facet이 들어갈 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;filters&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;review_states&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;confidences&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;authors&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;created_from&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;created_to&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;updated_from&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;updated_to&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;has_evidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;record_origins&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 쓸 수 있습니다. 출처는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;lab&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;imported&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;synthetic&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 중 하나입니다. timestamp 필터에는 시간대가 필요하고 서버는 UTC로 정규화합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;limit&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 최댓값은 20입니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;review_states&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 이전 페이지에 저장된 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;review_state&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 호환값으로 결과를 좁히는 필터일 뿐입니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;review_state&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;reviewer&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 기존 페이지와 클라이언트를 계속 읽기 위해 남겨 둔 변경 불가 필드입니다. 값에 상관없이 저장된 메모는 바로 공개되고 검색되며, 승인·노출·수정 권한이나 시스템 동작에는 영향을 주지 않습니다.&lt;br /&gt;
&lt;br /&gt;
쓰기 입력은 더 좁습니다.&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;create_lesson_draft(draft)&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 고유한 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;operation_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;title&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;question&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;attempt&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;context&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;observation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;interpretation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;reusable_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;applicability&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;confidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;evidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 구조화된 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;initial_evidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 선택적 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;lesson_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;만 받습니다. 초기 evidence는 Lesson과 같은 revision에 저장됩니다. 실제 자료를 읽은 경우 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;initial_evidence.verification&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 검증 기록을 같이 보냅니다. 초기 evidence와 검증의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;verification_basis&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 같아야 합니다. high confidence에서는 이 검증이 필수입니다.&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;link_lessons(from_lesson, relation, to_lesson, operation_id, note=&amp;amp;quot;&amp;amp;quot;)&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 존재하는 서로 다른 두 ID와 고유한 작업 ID가 필요합니다.&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;attach_evidence(lesson_id, evidence)&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;citation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;과 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;kind&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 필수입니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;kind&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;paper&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;code&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;dataset&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;log&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;benchmark&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;other&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 중 하나이고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;url&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;note&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;evidence_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 선택이고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;operation_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 필수입니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;verification_basis&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;full_text&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;official_abstract&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;partial_source&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;metadata_only&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 중 하나를 반드시 명시합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;not_recorded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 이전 자료를 읽을 때만 나타나며 새 evidence에는 허용하지 않습니다. 이는 확인한 자료 범위이며 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;confidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 다릅니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;added_by&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;added_at&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 서버가 넣습니다. 브라우저 양식은 새 evidence에 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;not_recorded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 기본으로 넣거나 선택할 수 없습니다.&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;record_evidence_verification(lesson_id, verification)&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 최신 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;expected_revision_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;evidence_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 정확한 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;expected_evidence_digest&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 고유한 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;operation_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 요구합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;verification_basis&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;source_identity&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;source_sha256&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;source_locator&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;coverage&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;outcome&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;claim_fields&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 실제 자료 확인 범위와 판정을 남깁니다. 이 기록은 evidence를 바꾸지 않으며 수정·삭제할 수 없습니다. 같은 evidence를 다시 확인하면 새 기록을 붙이고 마지막 기록을 현재 판정으로 사용합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;source_sha256&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 실제로 검사한 원본 파일 바이트를 해시합니다. 저장·정규화한 텍스트를 검사했다면 그 정확한 텍스트의 UTF-8 바이트를 해시하고, 변환 방법과 보존한 산출물을 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;source_identity&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;coverage&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 명시합니다.&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;revise_lesson(revision)&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;lesson_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 현재 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;expected_revision_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 고유한 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;operation_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 3~300자의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;reason&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 변경할 핵심 필드, 이미 붙은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;basis_evidence_ids&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 1~8개를 받습니다. relation, evidence와 검증 기록 목록, ID, 출처, 최초 작성자·생성 시각, 호환 필드는 바꿀 수 없습니다. 근거를 먼저 붙였다면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;attach_evidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 응답의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;current_revision_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;expected_revision_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 사용합니다. 충돌 시 최신 Lesson을 다시 읽고 변경안을 재계산합니다. 같은 operation과 같은 요청의 재시도는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;replayed&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;입니다. operation ID와 이유를 합친 MediaWiki revision 요약이 500자를 넘으면 이유를 자르지 않고 요청을 거부합니다.&lt;br /&gt;
다섯 쓰기 도구는 같은 idempotency 규칙을 따릅니다. 같은 Lesson에서 S3W 일반 쓰기, S3R revision, S3V 검증 기록은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;operation_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 충돌 범위를 공유합니다. 같은 ID·종류·요청 digest의 정확한 재시도만 replay하고 다른 종류나 digest면 충돌합니다. 다른 Lesson의 같은 ID는 서로 간섭하지 않습니다. 성공 여부가 불분명하면 같은 도구·대상·&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;operation_id&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;·요청을 그대로 재시도하고 새 작업에는 새 ID를 사용합니다. replay 응답은 적용 revision과 현재 최신 revision을 구분합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;not_recorded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; evidence는 수정 근거가 될 수 없습니다. observation·interpretation·reusable_lesson의 수정 후 결과 confidence가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;medium&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 또는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;high&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이면 최신 기록 중 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;full_text&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 또는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;official_abstract&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;supports&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이며 변경한 필드를 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;claim_fields&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 연결한 근거가 필요합니다. confidence를 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;high&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 하면 세 필드 모두를 뒷받침해야 합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;contradicts&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;inconclusive&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 지지 근거로 세지 않습니다. 결과 confidence가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;low&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;인 교정은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;medium&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;/&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;high&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 낮추는 경우와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;low&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 유지하는 경우 모두 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;not_recorded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 아닌 basis evidence로 할 수 있습니다.&lt;br /&gt;
&lt;br /&gt;
검색 목록 응답에는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;items&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;returned&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;has_more&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;next_cursor&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;partial&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;scanned&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;scan_limit&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 들어갑니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;partial=true&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 후보 탐색 한도에 닿았다는 뜻입니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;hybrid_v1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 요청하면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;effective_mode&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;semantic_status&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;ranking_version&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, 선택 항목인 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;retrieval_generation&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;도 들어갑니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;semantic_status&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;disabled&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;building&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;ready&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;stale&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;degraded&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;unavailable&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 중 하나입니다. 의미 검색이 꺼졌거나 첫 페이지에서 실패하면 기존 검색으로 대체하고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;effective_mode=legacy&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 반환합니다. 이미 받은 hybrid cursor를 쓰는 중에 mode나 generation이 달라지면 순서를 바꾸지 않고 cursor 오류를 반환하므로 첫 페이지부터 다시 조회합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;page_limited_by_output&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;은 byte 한도 때문에 해당 페이지가 짧아졌다는 뜻이고, 긴 문자열이 잘리면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;output_truncated&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;가 표시됩니다. cursor는 다른 요청 인자에 묶여 있으므로 그대로 이어 쓰거나 처음부터 새 검색을 시작합니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;get_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 인용·검증·관계 cursor와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;audit_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; cursor는 읽은 revision에도 묶입니다. 페이지 사이에 대상이 바뀌면 이전 cursor를 버리고 첫 페이지부터 다시 검사합니다. full citation 한 항목이 출력 제한에 맞지 않으면 내용을 자르지 않고 오류를 반환합니다.&lt;br /&gt;
&lt;br /&gt;
각 도구는 닫힌 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;outputSchema&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 공개합니다. 성공 결과는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;structuredContent&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 들어가며, 기존 클라이언트용 compact JSON text도 같은 내용으로 유지합니다.&lt;br /&gt;
&lt;br /&gt;
=== 적은 token으로 찾는 순서 ===&lt;br /&gt;
&lt;br /&gt;
목록 도구는 기본적으로 ID, 제목, 한 줄 메모, 신뢰도와 짧은 근거만 돌려줍니다.&lt;br /&gt;
&lt;br /&gt;
# 검색어와 상태·신뢰도 필터를 좁히고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;limit=3~5&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 시작합니다.&lt;br /&gt;
# 지금 판단에 필요한 필드만 요청합니다.&lt;br /&gt;
# 다음 묶음은 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;limit&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 키우지 말고 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;next_cursor&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 받습니다.&lt;br /&gt;
# 후보를 고른 뒤에만 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;get_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 호출합니다.&lt;br /&gt;
# 답변에 메모를 썼다면 반환된 근거 인용도 함께 보존합니다.&lt;br /&gt;
# 여러 건을 썼거나 고쳤다면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;audit_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;get_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 적용 revision과 근거·검증·관계를 확인합니다.&lt;br /&gt;
도구 결과의 기본 직렬화 한도는 32 KiB(&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MAX_OUTPUT_BYTES=32768&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;), 절대 상한은 64 KiB입니다. JSON-RPC 포장과 검증 오류까지 포함한 HTTP 응답 본문도 212,992 bytes를 넘지 않습니다. 응답이 잘렸다면 한도를 우회하려 하지 말고 결과 수나 필드를 줄입니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
S3 Research Memory에서 tail latency와 관련된 메모를 최대 5개만 찾아 줘.&lt;br /&gt;
id, title, reusable_lesson, applicability, confidence, citations만 반환해.&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
S3 Research Memory의 search_lessons를 retrieval_mode=hybrid_v1로 실행해,&lt;br /&gt;
표현은 달라도 병목의 제약과 해결 원리가 비슷한 메모를 최대 5개 찾아 줘.&lt;br /&gt;
effective_mode와 semantic_status도 알려 줘.&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
Lesson:gpu_allocator_fragmentation의 궁금했던 점, 당시 조건, 실제 결과, 가능한 원인,&lt;br /&gt;
다음에 기억할 것, 언제 맞는지, 관련 자료를 읽고 다른 시스템 분야의 유사 사례를 최대&lt;br /&gt;
3개 찾아 줘.&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
이 결과를 메모로 남겨 줘. 근거를 추측하지 말고 제공된 보고서 경로와&lt;br /&gt;
checksum만 인용해. 서버가 관리하는 필드는 건드리지 말고 생성된 Lesson ID만 알려 줘.&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
이 Lesson의 같은 주장을 보강해야 한다면 현재 revision과 full citation을 먼저 읽어.&lt;br /&gt;
근거를 붙인 응답의 current_revision_id를 expected_revision_id로 써서 같은 ID를&lt;br /&gt;
revise_lesson으로 고치고,&lt;br /&gt;
audit_lessons로 적용 결과를 확인해. 중복 Technical Review Lesson은 만들지 마.&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 비밀과 연결 경계 ===&lt;br /&gt;
&lt;br /&gt;
* 서버의 실제 값은 Git에서 제외된 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;.env&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 mode &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;0600&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 둡니다.&lt;br /&gt;
* 중앙 URL은 공개해도 되며 읽기에 token이 필요하지 않습니다. 공용 쓰기 token은 연구실에서 공유하고 각 클라이언트의 사용자 설정이나 환경변수에 둡니다.&lt;br /&gt;
* 위키 revision HMAC 키는 서버 내부 값이며 어떤 MCP 클라이언트에도 전달하지 않습니다.&lt;br /&gt;
* 서버는 raw token이 아니라 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_TOKEN_SHA256&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; digest만 저장합니다.&lt;br /&gt;
* 위키 계정, DB 비밀번호, 관리자 계정과 raw token은 Git에 커밋하지 않습니다.&lt;br /&gt;
* 컨테이너 내부 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;http://mediawiki/api.php&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;는 MCP adapter만 사용합니다. 이를 클라이언트 주소로 바꾸거나 일반 MediaWiki API를 에이전트에 노출하지 않습니다.&lt;br /&gt;
* 모든 클라이언트는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;https://s3wiki.yonsei.ac.kr/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 하나와 공용 쓰기 주체를 공유합니다. 기본 연결은 HTTPS이며 native client는 공용 Bearer token을 사용합니다.&lt;br /&gt;
* ChatGPT 웹과 Claude 웹·Desktop의 쓰기가 필요해질 때만 OAuth를 추가합니다.&lt;br /&gt;
* 쓰기 권한이 있는 에이전트는 메모 작성, 관계 연결, 근거 첨부·검증과 기준 revision 및 근거가 있는 핵심 내용 수정을 할 수 있습니다. 상태 변경과 삭제 도구는 없습니다.&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC/Windows_%EC%97%B0%EA%B2%B0&amp;diff=1720</id>
		<title>도움말:S3 연구 메모리/Windows 연결</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC/Windows_%EC%97%B0%EA%B2%B0&amp;diff=1720"/>
		<updated>2026-07-18T10:09:46Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 저장소 문서 동기화&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;!-- S3RM managed repository document; edit the Markdown source, not this page. --&amp;gt;&lt;br /&gt;
&#039;&#039;이 페이지는 저장소 문서에서 자동으로 동기화됩니다. 위키에서 직접 편집하지 마세요.&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
서버 담당자에게 공용 쓰기 토큰을 받은 뒤 진행합니다. 중앙 주소는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;https://s3wiki.yonsei.ac.kr/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 하나만 사용합니다.&lt;br /&gt;
&lt;br /&gt;
=== 1. 토큰 저장 ===&lt;br /&gt;
&lt;br /&gt;
PowerShell 7에서 다음 블록 전체를 실행합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
$McpUrl = &#039;https://s3wiki.yonsei.ac.kr/mcp&#039;&lt;br /&gt;
$SecureToken = Read-Host &#039;S3RM_MCP_TOKEN&#039; -AsSecureString&lt;br /&gt;
$Token = [System.Net.NetworkCredential]::new(&#039;&#039;, $SecureToken).Password&lt;br /&gt;
&lt;br /&gt;
[Environment]::SetEnvironmentVariable(&#039;S3RM_MCP_URL&#039;, $McpUrl, &#039;User&#039;)&lt;br /&gt;
[Environment]::SetEnvironmentVariable(&#039;S3RM_MCP_TOKEN&#039;, $Token, &#039;User&#039;)&lt;br /&gt;
$env:S3RM_MCP_URL = $McpUrl&lt;br /&gt;
$env:S3RM_MCP_TOKEN = $Token&lt;br /&gt;
&lt;br /&gt;
Remove-Variable Token, SecureToken -ErrorAction SilentlyContinue&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
실행하면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_TOKEN:&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 표시됩니다. 그 뒤에 전달받은 토큰을 붙여넣고 Enter를 누르세요. 입력 중에는 토큰이 화면에 보이지 않습니다.&lt;br /&gt;
&lt;br /&gt;
현재 PowerShell에서 실행한 CLI는 바로 값을 읽습니다. ChatGPT desktop app의 Codex, VS Code의 Codex·Claude 확장과 Cursor는 완전히 종료한 뒤 다시 여세요. 그래도 적용되지 않으면 Windows에서 로그아웃했다가 다시 로그인합니다.&lt;br /&gt;
&lt;br /&gt;
토큰을 확인한다며 화면에 출력하거나 프로젝트 설정과 Git에 복사하지 마세요. 사용자 환경변수는 개인 PC의 개인 Windows 계정에만 저장합니다.&lt;br /&gt;
&lt;br /&gt;
=== 2. 에이전트에게 연결 맡기기 ===&lt;br /&gt;
&lt;br /&gt;
같은 PowerShell에서 Codex, Claude Code 또는 Cursor CLI를 시작한 뒤 다음 문장을 붙여넣으세요.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
현재 클라이언트를 연구실의 중앙 S3 Research Memory MCP에 쓰기 가능하게 연결해 줘.&lt;br /&gt;
&lt;br /&gt;
반드시 지킬 조건:&lt;br /&gt;
- 중앙 주소는 https://s3wiki.yonsei.ac.kr/mcp 하나만 사용한다.&lt;br /&gt;
- S3RM_MCP_TOKEN을 값으로 출력하지 말고 Bearer header에 사용한다.&lt;br /&gt;
- 로컬 MCP 서버, stdio 프로세스, Docker, 별도 위키나 DB를 만들거나 시작하지 않는다.&lt;br /&gt;
- localhost, 127.0.0.1, SSH tunnel, generic MediaWiki api.php를 등록하지 않는다.&lt;br /&gt;
- 현재 클라이언트의 공식 원격 Streamable HTTP 설정을 사용한다.&lt;br /&gt;
- 기존 MCP 설정을 먼저 읽고 다른 항목을 지우지 않은 채 안전하게 병합한다.&lt;br /&gt;
- Codex에서만 해당 서버 블록에 default_tools_approval_mode = &amp;quot;auto&amp;quot;를 넣는다.&lt;br /&gt;
- 토큰을 답변, 프로젝트 파일 또는 Git에 남기지 않는다.&lt;br /&gt;
- 재시작이 필요하면 정확히 어떤 앱이나 확장을 재시작할지 알려 준다.&lt;br /&gt;
- 재시작 뒤 MCP 도구 10개와 search_lessons(limit=1)를 확인하는 방법을 남긴다.&lt;br /&gt;
&lt;br /&gt;
먼저 Windows와 현재 클라이언트, 기존 설정을 확인한 뒤 직접 설정하고 검증해 줘.&lt;br /&gt;
실제 메모를 생성하는 쓰기 시험은 하지 마.&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 3. 직접 연결 ===&lt;br /&gt;
&lt;br /&gt;
==== Codex ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
codex mcp add s3-research-memory `&lt;br /&gt;
  --url https://s3wiki.yonsei.ac.kr/mcp `&lt;br /&gt;
  --bearer-token-env-var S3RM_MCP_TOKEN&lt;br /&gt;
codex mcp list&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
사용자 Codex &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;config.toml&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 생성된 서버 블록에 다음 줄을 병합합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
default_tools_approval_mode = &amp;quot;auto&amp;quot;&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
ChatGPT desktop app의 Codex, Codex CLI와 VS Code Codex 확장은 같은 설정을 공유합니다.&lt;br /&gt;
&lt;br /&gt;
==== Claude Code ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
claude mcp add --transport http --scope user `&lt;br /&gt;
  s3-research-memory https://s3wiki.yonsei.ac.kr/mcp `&lt;br /&gt;
  --header &#039;Authorization: Bearer ${S3RM_MCP_TOKEN}&#039;&lt;br /&gt;
claude mcp get s3-research-memory&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
VS Code Claude 확장도 같은 Claude Code 설정을 사용합니다.&lt;br /&gt;
&lt;br /&gt;
==== Cursor ====&lt;br /&gt;
&lt;br /&gt;
Cursor 사용자 전역 MCP 설정에 다음 내용을 병합합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;mcpServers&amp;quot;: {&lt;br /&gt;
    &amp;quot;s3-research-memory&amp;quot;: {&lt;br /&gt;
      &amp;quot;url&amp;quot;: &amp;quot;https://s3wiki.yonsei.ac.kr/mcp&amp;quot;,&lt;br /&gt;
      &amp;quot;headers&amp;quot;: {&lt;br /&gt;
        &amp;quot;Authorization&amp;quot;: &amp;quot;Bearer ${env:S3RM_MCP_TOKEN}&amp;quot;&lt;br /&gt;
      }&lt;br /&gt;
    }&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Cursor를 완전히 다시 시작한 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Customize → MCP&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 확인합니다. Cursor CLI에서는 다음 명령으로 확인합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
agent mcp list&lt;br /&gt;
agent mcp list-tools s3-research-memory&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 4. 연결 확인 ===&lt;br /&gt;
&lt;br /&gt;
새 대화에 다음 요청을 붙여넣으세요.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
S3 Research Memory의 search_lessons를 빈 검색어와 limit=1로 실행해 줘.&lt;br /&gt;
id, title, confidence만 반환하고, 메모가 아직 없어도 새로 만들지는 마.&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
정상이면 읽기 도구 다섯 개와 쓰기 도구 다섯 개, 모두 열 개가 표시됩니다. 이 문서의 공용 토큰으로 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;revise_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;도 쓸 수 있습니다.&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC/macOS_%EC%97%B0%EA%B2%B0&amp;diff=1719</id>
		<title>도움말:S3 연구 메모리/macOS 연결</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC/macOS_%EC%97%B0%EA%B2%B0&amp;diff=1719"/>
		<updated>2026-07-18T10:09:46Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 저장소 문서 동기화&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;!-- S3RM managed repository document; edit the Markdown source, not this page. --&amp;gt;&lt;br /&gt;
&#039;&#039;이 페이지는 저장소 문서에서 자동으로 동기화됩니다. 위키에서 직접 편집하지 마세요.&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
서버 담당자에게 공용 쓰기 토큰을 받은 뒤 진행합니다. 중앙 주소는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;https://s3wiki.yonsei.ac.kr/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 하나만 사용합니다.&lt;br /&gt;
&lt;br /&gt;
=== 1. 토큰 저장 ===&lt;br /&gt;
&lt;br /&gt;
Terminal 또는 iTerm에서 다음 블록 전체를 실행합니다. macOS 기본 zsh와 Bash를 자동으로 구분합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
config_dir=&amp;quot;${XDG_CONFIG_HOME:-$HOME/.config}/s3-research-memory&amp;quot;&lt;br /&gt;
client_env=&amp;quot;$config_dir/client.env&amp;quot;&lt;br /&gt;
mkdir -p &amp;quot;$config_dir&amp;quot;&lt;br /&gt;
chmod 700 &amp;quot;$config_dir&amp;quot;&lt;br /&gt;
&lt;br /&gt;
printf &#039;S3RM_MCP_TOKEN: &#039;&lt;br /&gt;
IFS= read -rs S3RM_MCP_TOKEN&lt;br /&gt;
printf &#039;\n&#039;&lt;br /&gt;
(umask 077 &amp;amp;&amp;amp; printf &#039;export S3RM_MCP_URL=%q\nexport S3RM_MCP_TOKEN=%q\n&#039; \&lt;br /&gt;
  &#039;https://s3wiki.yonsei.ac.kr/mcp&#039; &amp;quot;$S3RM_MCP_TOKEN&amp;quot; &amp;gt; &amp;quot;$client_env&amp;quot;)&lt;br /&gt;
unset S3RM_MCP_TOKEN&lt;br /&gt;
&lt;br /&gt;
case &amp;quot;${SHELL##*/}&amp;quot; in&lt;br /&gt;
  zsh) shell_rc=&amp;quot;$HOME/.zshrc&amp;quot; ;;&lt;br /&gt;
  bash) shell_rc=&amp;quot;$HOME/.bash_profile&amp;quot; ;;&lt;br /&gt;
  *)&lt;br /&gt;
    printf &#039;지원하지 않는 로그인 셸입니다: %s\n&#039; &amp;quot;${SHELL:-알 수 없음}&amp;quot; &amp;gt;&amp;amp;2&lt;br /&gt;
    printf &#039;%s\n&#039; &#039;client.env를 현재 셸의 시작 파일에서 직접 불러오세요.&#039; &amp;gt;&amp;amp;2&lt;br /&gt;
    return 1 2&amp;gt;/dev/null || exit 1&lt;br /&gt;
    ;;&lt;br /&gt;
esac&lt;br /&gt;
&lt;br /&gt;
load_line=&#039;source &amp;quot;${XDG_CONFIG_HOME:-$HOME/.config}/s3-research-memory/client.env&amp;quot;&#039;&lt;br /&gt;
grep -Fqx &amp;quot;$load_line&amp;quot; &amp;quot;$shell_rc&amp;quot; 2&amp;gt;/dev/null || \&lt;br /&gt;
  printf &#039;\n%s\n&#039; &amp;quot;$load_line&amp;quot; &amp;gt;&amp;gt; &amp;quot;$shell_rc&amp;quot;&lt;br /&gt;
source &amp;quot;$client_env&amp;quot;&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
실행하면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_TOKEN:&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 표시됩니다. 그 뒤에 전달받은 토큰을 붙여넣고 Enter를 누르세요. 입력 중에는 토큰이 화면에 보이지 않습니다.&lt;br /&gt;
&lt;br /&gt;
현재 터미널과 이후 여는 같은 셸의 터미널에 적용됩니다. Finder나 Dock에서 시작한 GUI 앱은 셸 환경을 자동으로 받지 않을 수 있습니다. 먼저 토큰을 저장한 터미널에서 CLI 연결을 마치고, GUI 앱이나 확장이 토큰을 읽지 못하면 해당 앱의 사용자 전용 MCP 설정을 확인한 뒤 완전히 다시 시작하세요.&lt;br /&gt;
&lt;br /&gt;
=== 2. 에이전트에게 연결 맡기기 ===&lt;br /&gt;
&lt;br /&gt;
토큰을 저장한 터미널에서 Codex, Claude Code 또는 Cursor CLI를 시작한 뒤 다음 문장을 붙여넣으세요.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
현재 클라이언트를 연구실의 중앙 S3 Research Memory MCP에 쓰기 가능하게 연결해 줘.&lt;br /&gt;
&lt;br /&gt;
반드시 지킬 조건:&lt;br /&gt;
- 중앙 주소는 https://s3wiki.yonsei.ac.kr/mcp 하나만 사용한다.&lt;br /&gt;
- S3RM_MCP_TOKEN을 값으로 출력하지 말고 Bearer header에 사용한다.&lt;br /&gt;
- 로컬 MCP 서버, stdio 프로세스, Docker, 별도 위키나 DB를 만들거나 시작하지 않는다.&lt;br /&gt;
- localhost, 127.0.0.1, SSH tunnel, generic MediaWiki api.php를 등록하지 않는다.&lt;br /&gt;
- 현재 클라이언트의 공식 원격 Streamable HTTP 설정을 사용한다.&lt;br /&gt;
- 기존 MCP 설정을 먼저 읽고 다른 항목을 지우지 않은 채 안전하게 병합한다.&lt;br /&gt;
- Codex에서만 해당 서버 블록에 default_tools_approval_mode = &amp;quot;auto&amp;quot;를 넣는다.&lt;br /&gt;
- 토큰을 답변, 프로젝트 파일 또는 Git에 남기지 않는다.&lt;br /&gt;
- 재시작이 필요하면 정확히 어떤 앱이나 확장을 재시작할지 알려 준다.&lt;br /&gt;
- 재시작 뒤 MCP 도구 10개와 search_lessons(limit=1)를 확인하는 방법을 남긴다.&lt;br /&gt;
&lt;br /&gt;
먼저 macOS와 현재 클라이언트, 기존 설정을 확인한 뒤 직접 설정하고 검증해 줘.&lt;br /&gt;
실제 메모를 생성하는 쓰기 시험은 하지 마.&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 3. 직접 연결 ===&lt;br /&gt;
&lt;br /&gt;
==== Codex ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
codex mcp add s3-research-memory \&lt;br /&gt;
  --url https://s3wiki.yonsei.ac.kr/mcp \&lt;br /&gt;
  --bearer-token-env-var S3RM_MCP_TOKEN&lt;br /&gt;
codex mcp list&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;~/.codex/config.toml&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 생성된 서버 블록에 다음 줄도 병합합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
default_tools_approval_mode = &amp;quot;auto&amp;quot;&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
ChatGPT desktop app의 Codex, Codex CLI, VS Code Codex 확장은 같은 설정을 공유합니다.&lt;br /&gt;
&lt;br /&gt;
==== Claude Code ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
claude mcp add --transport http --scope user \&lt;br /&gt;
  s3-research-memory https://s3wiki.yonsei.ac.kr/mcp \&lt;br /&gt;
  --header &#039;Authorization: Bearer ${S3RM_MCP_TOKEN}&#039;&lt;br /&gt;
claude mcp get s3-research-memory&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
VS Code Claude 확장도 같은 Claude Code 설정을 사용합니다.&lt;br /&gt;
&lt;br /&gt;
==== Cursor ====&lt;br /&gt;
&lt;br /&gt;
사용자 전역 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;~/.cursor/mcp.json&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 다음 내용을 병합합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;mcpServers&amp;quot;: {&lt;br /&gt;
    &amp;quot;s3-research-memory&amp;quot;: {&lt;br /&gt;
      &amp;quot;url&amp;quot;: &amp;quot;https://s3wiki.yonsei.ac.kr/mcp&amp;quot;,&lt;br /&gt;
      &amp;quot;headers&amp;quot;: {&lt;br /&gt;
        &amp;quot;Authorization&amp;quot;: &amp;quot;Bearer ${env:S3RM_MCP_TOKEN}&amp;quot;&lt;br /&gt;
      }&lt;br /&gt;
    }&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Cursor CLI에서는 다음 명령으로 확인합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
agent mcp list&lt;br /&gt;
agent mcp list-tools s3-research-memory&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* ChatGPT desktop app의 Codex는 Codex CLI와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;~/.codex/config.toml&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 공유합니다.&lt;br /&gt;
* VS Code Codex·Claude 확장은 각각 Codex CLI·Claude Code 설정을 사용합니다.&lt;br /&gt;
* Cursor는 사용자 전역 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;~/.cursor/mcp.json&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 사용합니다.&lt;br /&gt;
설정 뒤 앱과 확장을 완전히 종료했다가 다시 여세요.&lt;br /&gt;
&lt;br /&gt;
=== 4. 연결 확인 ===&lt;br /&gt;
&lt;br /&gt;
새 대화에 다음 요청을 붙여넣으세요.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
S3 Research Memory의 search_lessons를 빈 검색어와 limit=1로 실행해 줘.&lt;br /&gt;
id, title, confidence만 반환하고, 메모가 아직 없어도 새로 만들지는 마.&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
정상이면 읽기 도구 다섯 개와 쓰기 도구 다섯 개, 모두 열 개가 표시됩니다. 이 문서의 공용 토큰으로 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;revise_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;도 쓸 수 있습니다.&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC/Linux_%EC%97%B0%EA%B2%B0&amp;diff=1718</id>
		<title>도움말:S3 연구 메모리/Linux 연결</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC/Linux_%EC%97%B0%EA%B2%B0&amp;diff=1718"/>
		<updated>2026-07-18T10:09:46Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 저장소 문서 동기화&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;!-- S3RM managed repository document; edit the Markdown source, not this page. --&amp;gt;&lt;br /&gt;
&#039;&#039;이 페이지는 저장소 문서에서 자동으로 동기화됩니다. 위키에서 직접 편집하지 마세요.&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
서버 담당자에게 공용 쓰기 토큰을 받은 뒤 진행합니다. 중앙 주소는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;https://s3wiki.yonsei.ac.kr/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 하나만 사용합니다.&lt;br /&gt;
&lt;br /&gt;
=== 1. 토큰 저장 ===&lt;br /&gt;
&lt;br /&gt;
터미널에서 다음 블록 전체를 실행합니다. 현재 로그인 셸이 zsh면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;~/.zshrc&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, Bash면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;~/.bashrc&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;를 자동으로 선택합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
config_dir=&amp;quot;${XDG_CONFIG_HOME:-$HOME/.config}/s3-research-memory&amp;quot;&lt;br /&gt;
client_env=&amp;quot;$config_dir/client.env&amp;quot;&lt;br /&gt;
mkdir -p &amp;quot;$config_dir&amp;quot;&lt;br /&gt;
chmod 700 &amp;quot;$config_dir&amp;quot;&lt;br /&gt;
&lt;br /&gt;
printf &#039;S3RM_MCP_TOKEN: &#039;&lt;br /&gt;
IFS= read -rs S3RM_MCP_TOKEN&lt;br /&gt;
printf &#039;\n&#039;&lt;br /&gt;
(umask 077 &amp;amp;&amp;amp; printf &#039;export S3RM_MCP_URL=%q\nexport S3RM_MCP_TOKEN=%q\n&#039; \&lt;br /&gt;
  &#039;https://s3wiki.yonsei.ac.kr/mcp&#039; &amp;quot;$S3RM_MCP_TOKEN&amp;quot; &amp;gt; &amp;quot;$client_env&amp;quot;)&lt;br /&gt;
unset S3RM_MCP_TOKEN&lt;br /&gt;
&lt;br /&gt;
case &amp;quot;${SHELL##*/}&amp;quot; in&lt;br /&gt;
  zsh) shell_rc=&amp;quot;$HOME/.zshrc&amp;quot; ;;&lt;br /&gt;
  bash) shell_rc=&amp;quot;$HOME/.bashrc&amp;quot; ;;&lt;br /&gt;
  *)&lt;br /&gt;
    printf &#039;지원하지 않는 로그인 셸입니다: %s\n&#039; &amp;quot;${SHELL:-알 수 없음}&amp;quot; &amp;gt;&amp;amp;2&lt;br /&gt;
    printf &#039;%s\n&#039; &#039;client.env를 현재 셸의 시작 파일에서 직접 불러오세요.&#039; &amp;gt;&amp;amp;2&lt;br /&gt;
    return 1 2&amp;gt;/dev/null || exit 1&lt;br /&gt;
    ;;&lt;br /&gt;
esac&lt;br /&gt;
&lt;br /&gt;
load_line=&#039;source &amp;quot;${XDG_CONFIG_HOME:-$HOME/.config}/s3-research-memory/client.env&amp;quot;&#039;&lt;br /&gt;
grep -Fqx &amp;quot;$load_line&amp;quot; &amp;quot;$shell_rc&amp;quot; 2&amp;gt;/dev/null || \&lt;br /&gt;
  printf &#039;\n%s\n&#039; &amp;quot;$load_line&amp;quot; &amp;gt;&amp;gt; &amp;quot;$shell_rc&amp;quot;&lt;br /&gt;
source &amp;quot;$client_env&amp;quot;&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
실행하면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_TOKEN:&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 표시됩니다. 그 뒤에 서버 담당자에게 전달받은 토큰을 붙여넣고 Enter를 누르세요. 입력 중에는 토큰이 화면에 보이지 않습니다.&lt;br /&gt;
&lt;br /&gt;
현재 터미널과 이후 여는 같은 셸의 터미널에 적용됩니다. 토큰을 확인한다며 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;echo&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;printenv&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 등으로 출력하지 말고 프로젝트 파일이나 Git에 복사하지 마세요.&lt;br /&gt;
&lt;br /&gt;
=== 2. 에이전트에게 연결 맡기기 ===&lt;br /&gt;
&lt;br /&gt;
토큰을 저장한 터미널에서 Codex, Claude Code 또는 Cursor CLI를 시작한 뒤 다음 문장을 붙여넣으세요.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
현재 클라이언트를 연구실의 중앙 S3 Research Memory MCP에 쓰기 가능하게 연결해 줘.&lt;br /&gt;
&lt;br /&gt;
반드시 지킬 조건:&lt;br /&gt;
- 중앙 주소는 https://s3wiki.yonsei.ac.kr/mcp 하나만 사용한다.&lt;br /&gt;
- S3RM_MCP_TOKEN을 값으로 출력하지 말고 Bearer header에 사용한다.&lt;br /&gt;
- 로컬 MCP 서버, stdio 프로세스, Docker, 별도 위키나 DB를 만들거나 시작하지 않는다.&lt;br /&gt;
- localhost, 127.0.0.1, SSH tunnel, generic MediaWiki api.php를 등록하지 않는다.&lt;br /&gt;
- 현재 클라이언트의 공식 원격 Streamable HTTP 설정을 사용한다.&lt;br /&gt;
- 기존 MCP 설정을 먼저 읽고 다른 항목을 지우지 않은 채 안전하게 병합한다.&lt;br /&gt;
- Codex에서만 해당 서버 블록에 default_tools_approval_mode = &amp;quot;auto&amp;quot;를 넣는다.&lt;br /&gt;
- 토큰을 답변, 프로젝트 파일 또는 Git에 남기지 않는다.&lt;br /&gt;
- 재시작이 필요하면 정확히 어떤 앱이나 확장을 재시작할지 알려 준다.&lt;br /&gt;
- 재시작 뒤 MCP 도구 10개와 search_lessons(limit=1)를 확인하는 방법을 남긴다.&lt;br /&gt;
&lt;br /&gt;
먼저 현재 클라이언트와 기존 설정을 확인한 뒤 직접 설정하고 검증해 줘.&lt;br /&gt;
실제 메모를 생성하는 쓰기 시험은 하지 마.&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
GUI에서 시작한 편집기나 확장은 셸 환경을 받지 못할 수 있습니다. 이 경우 토큰을 저장한 터미널에서 앱을 시작하거나, 사용 중인 앱의 사용자 전용 MCP 설정에 환경변수 참조를 넣은 뒤 완전히 다시 시작하세요.&lt;br /&gt;
&lt;br /&gt;
=== 3. 직접 연결 ===&lt;br /&gt;
&lt;br /&gt;
에이전트에게 맡기지 않을 때는 사용하는 클라이언트만 설정합니다.&lt;br /&gt;
&lt;br /&gt;
==== Codex ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
codex mcp add s3-research-memory \&lt;br /&gt;
  --url https://s3wiki.yonsei.ac.kr/mcp \&lt;br /&gt;
  --bearer-token-env-var S3RM_MCP_TOKEN&lt;br /&gt;
codex mcp list&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;~/.codex/config.toml&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;의 생성된 서버 블록에 다음 줄도 병합합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
default_tools_approval_mode = &amp;quot;auto&amp;quot;&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
ChatGPT desktop app의 Codex, Codex CLI, VS Code Codex 확장은 같은 설정을 공유합니다.&lt;br /&gt;
&lt;br /&gt;
==== Claude Code ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
claude mcp add --transport http --scope user \&lt;br /&gt;
  s3-research-memory https://s3wiki.yonsei.ac.kr/mcp \&lt;br /&gt;
  --header &#039;Authorization: Bearer ${S3RM_MCP_TOKEN}&#039;&lt;br /&gt;
claude mcp get s3-research-memory&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
VS Code Claude 확장도 같은 Claude Code 설정을 사용합니다.&lt;br /&gt;
&lt;br /&gt;
==== Cursor ====&lt;br /&gt;
&lt;br /&gt;
사용자 전역 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;~/.cursor/mcp.json&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 다음 서버를 병합합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;mcpServers&amp;quot;: {&lt;br /&gt;
    &amp;quot;s3-research-memory&amp;quot;: {&lt;br /&gt;
      &amp;quot;url&amp;quot;: &amp;quot;https://s3wiki.yonsei.ac.kr/mcp&amp;quot;,&lt;br /&gt;
      &amp;quot;headers&amp;quot;: {&lt;br /&gt;
        &amp;quot;Authorization&amp;quot;: &amp;quot;Bearer ${env:S3RM_MCP_TOKEN}&amp;quot;&lt;br /&gt;
      }&lt;br /&gt;
    }&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Cursor를 완전히 다시 시작한 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Customize → MCP&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 확인합니다. Cursor CLI에서는 다음 명령으로 확인합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
agent mcp list&lt;br /&gt;
agent mcp list-tools s3-research-memory&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 4. 연결 확인 ===&lt;br /&gt;
&lt;br /&gt;
새 대화에서 다음처럼 요청합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
S3 Research Memory의 search_lessons를 빈 검색어와 limit=1로 실행해 줘.&lt;br /&gt;
id, title, confidence만 반환하고, 메모가 아직 없어도 새로 만들지는 마.&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
정상이면 읽기 도구 다섯 개와 쓰기 도구 다섯 개, 모두 열 개가 표시됩니다. 이 문서의 공용 토큰으로 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;revise_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;도 쓸 수 있습니다.&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC&amp;diff=1717</id>
		<title>도움말:S3 연구 메모리</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%EB%8F%84%EC%9B%80%EB%A7%90:S3_%EC%97%B0%EA%B5%AC_%EB%A9%94%EB%AA%A8%EB%A6%AC&amp;diff=1717"/>
		<updated>2026-07-18T10:09:46Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 저장소 문서 동기화&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;!-- S3RM managed repository document; edit the Markdown source, not this page. --&amp;gt;&lt;br /&gt;
&#039;&#039;이 페이지는 저장소 문서에서 자동으로 동기화됩니다. 위키에서 직접 편집하지 마세요.&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;onlyinclude&amp;gt;&lt;br /&gt;
S3 연구 메모리는 연구실이 함께 쓰는 지식 저장소입니다. 프로젝트가 끝난 뒤에도 시행착오를 다시 찾을 수 있게, 시도한 방법과 실제 결과를 짧게 기록합니다. 실험 원본이나 일정표는 여기 쌓지 않습니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;/onlyinclude&amp;gt;&lt;br /&gt;
&lt;br /&gt;
위키 본문과 MCP 검색은 공개될 수 있습니다. 에이전트는 연구실 공용 토큰으로 메모 작성, 관계 연결, 근거 첨부, 근거 검증 기록 추가와 Lesson의 조건부 수정을 할 수 있습니다. 수정에는 기준 리비전과 기존 근거가 필요합니다. 삭제, 이동과 위키 관리 기능은 제공하지 않습니다. 저장한 메모는 바로 검색되고 모든 수정은 MediaWiki 이력에 남습니다.&lt;br /&gt;
&lt;br /&gt;
서버가 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_WRITE_AUTH_MODE=open&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 운영되는 동안에는 다섯 가지 변경 도구를 토큰 없이 사용할 수 있습니다. &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;github&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 모드는 GitHub 로그인으로 MCP 전체를 보호합니다. 공개 시험이 끝나면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;token&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;으로 되돌립니다.&lt;br /&gt;
&lt;br /&gt;
=== 처음 사용하는 순서 ===&lt;br /&gt;
&lt;br /&gt;
# 서버 담당자에게 공용 쓰기 토큰을 받습니다.&lt;br /&gt;
# 아래에서 운영체제에 맞는 연결 문서를 열고 토큰을 먼저 저장합니다.&lt;br /&gt;
# 같은 문서의 에이전트 프롬프트를 사용하거나 클라이언트를 직접 연결합니다.&lt;br /&gt;
# 도구 열 개와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;search_lessons(limit=1)&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 응답을 확인합니다.&lt;br /&gt;
# [[#진행 중 프로젝트에서 메모 옮기기|진행 중 프로젝트에서 메모 옮기기]]를 사용합니다.&lt;br /&gt;
읽기 전용 연결을 기본 경로로 안내하지 않습니다. 메모를 남길 사람은 토큰까지 설정해 쓰기 가능한 상태로 연결하세요. 토큰은 채팅창, 프로젝트 파일과 Git에 붙여넣지 않습니다.&lt;br /&gt;
&lt;br /&gt;
=== 플랫폼별 연결 ===&lt;br /&gt;
&lt;br /&gt;
중앙 MCP 주소는 모든 플랫폼에서 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;https://s3wiki.yonsei.ac.kr/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;입니다. 각 PC에 저장소, Docker, 별도 위키나 로컬 MCP 서버를 만들지 않습니다.&lt;br /&gt;
&lt;br /&gt;
==== Linux ====&lt;br /&gt;
&lt;br /&gt;
[[Help:S3 연구 메모리/Linux 연결|Linux에서 연결]]&lt;br /&gt;
&lt;br /&gt;
==== macOS ====&lt;br /&gt;
&lt;br /&gt;
[[Help:S3 연구 메모리/macOS 연결|macOS에서 연결]]&lt;br /&gt;
&lt;br /&gt;
==== Windows ====&lt;br /&gt;
&lt;br /&gt;
[[Help:S3 연구 메모리/Windows 연결|Windows에서 연결]]&lt;br /&gt;
&lt;br /&gt;
Claude Code를 사용한다면 토큰을 저장한 뒤 다음 명령으로 사용자 범위에 등록합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
claude mcp add --transport http --scope user \&lt;br /&gt;
  s3-research-memory https://s3wiki.yonsei.ac.kr/mcp \&lt;br /&gt;
  --header &#039;Authorization: Bearer ${S3RM_MCP_TOKEN}&#039;&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
작은따옴표를 유지해야 토큰 값 대신 환경변수 참조가 저장됩니다. 등록 뒤 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;claude mcp get s3-research-memory&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; 또는 Claude Code의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;/mcp&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에서 확인하세요.&lt;br /&gt;
&lt;br /&gt;
각 문서는 다음 순서로 구성되어 있습니다.&lt;br /&gt;
&lt;br /&gt;
# 전달받은 토큰을 사용자 환경에 안전하게 저장&lt;br /&gt;
# 에이전트에게 현재 클라이언트 연결 맡기기&lt;br /&gt;
# Codex, Claude Code 또는 Cursor를 직접 설정&lt;br /&gt;
# 연결 확인&lt;br /&gt;
토큰 저장 블록을 실행하면 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;S3RM_MCP_TOKEN:&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;이 표시됩니다. 그 뒤에 서버 담당자에게 전달받은 토큰을 붙여넣고 Enter를 누르세요. 입력 중에는 토큰이 화면에 보이지 않습니다. Linux와 macOS 문서는 로그인 셸을 확인해 zsh 또는 Bash에 맞는 시작 파일을 자동으로 선택하므로 파일명을 직접 바꿀 필요가 없습니다.&lt;br /&gt;
&lt;br /&gt;
ChatGPT 웹, Claude 웹·Desktop과 조직 커넥터처럼 별도 인증 제약이 있는 제품은 [[Help:S3 연구 메모리/MCP 클라이언트|MCP 클라이언트 참고]]를 확인하세요.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
연구실에서 쓰는 여러 AI 클라이언트&lt;br /&gt;
              │&lt;br /&gt;
              ▼&lt;br /&gt;
   중앙 S3 Research Memory MCP&lt;br /&gt;
              │&lt;br /&gt;
              ▼&lt;br /&gt;
   하나의 Semantic MediaWiki&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 연결 확인 ===&lt;br /&gt;
&lt;br /&gt;
새 대화에서 다음처럼 요청합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
S3 Research Memory의 search_lessons를 빈 검색어와 limit=1로 실행해 줘.&lt;br /&gt;
id, title, confidence만 반환하고, 메모가 아직 없어도 새로 만들지는 마.&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
정상이라면 다음 열 개 도구가 보입니다.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! 읽기 !! 쓰기&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;search_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;create_lesson_draft&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;get_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;link_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;audit_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;attach_evidence&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;find_related_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;record_evidence_verification&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;find_analogies&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;revise_lesson&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
검색 기본값은 기존 전체 텍스트·단어 겹침 방식인 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;legacy&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;입니다. 서버 담당자가 의미 검색을 켠 환경에서는 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;search_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;나 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;find_analogies&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;에 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;retrieval_mode=hybrid_v1&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;을 지정해 표현이 다른 후보도 함께 찾을 수 있습니다. 결과의 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;effective_mode&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;와 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;semantic_status&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 실제 적용 여부를 확인하세요. 이 서버의 GPU에서 선택적으로 실행하는 번들 모델과 설정 방법은 [[Help:S3 연구 메모리/설치와 검증#선택적 의미 검색|설치·설정의 의미 검색]]에 있습니다.&lt;br /&gt;
&lt;br /&gt;
=== 진행 중 프로젝트에서 메모 옮기기 ===&lt;br /&gt;
&lt;br /&gt;
쓰기 연결을 마친 클라이언트에서 아래 프롬프트를 붙여넣으세요. 현재 확인할 수 있는 문제와 해결 방법만 골라 최대 다섯 건씩 옮깁니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
현재 대화, 이 클라이언트가 제공한 저장 메모리, 현재 작업공간과 명시적으로 첨부된&lt;br /&gt;
자료 중 실제로 접근할 수 있는 내용만 사용해, 다른 프로젝트에서도 도움이 될 문제와&lt;br /&gt;
해결 방법을 중앙 S3 Research Memory에 옮겨 줘.&lt;br /&gt;
&lt;br /&gt;
반드시 지킬 조건:&lt;br /&gt;
- S3 Research Memory의 MCP 도구만 사용한다. generic MediaWiki API, 별도 위키·DB,&lt;br /&gt;
  로컬 MCP 서버는 사용하거나 만들지 않는다.&lt;br /&gt;
- 프로젝트 소개, 진행 현황, 할 일, 일정, 회의록이나 실행 로그 자체를 메모로 만들지&lt;br /&gt;
  않는다. 한 가지 문제와 결론을 메모 한 건으로 만든다.&lt;br /&gt;
- 과거 대화나 저장 메모리를 실제로 읽을 수 없으면 읽었다고 가정하지 않는다. 접근한&lt;br /&gt;
  자료의 범위와 접근하지 못한 범위를 마지막에 구분해 적는다.&lt;br /&gt;
- 위키는 공개로 읽힐 수 있다. 비밀번호, 토큰, 개인 정보, 사적인 대화 원문, 개인의&lt;br /&gt;
  신상·관계·취향·업무 평가, 미공개 제3자 자료는 쓰지 않는다. 사람 이름 대신 필요한&lt;br /&gt;
  실제 역할만 쓴다. 비밀 파일은 읽지 않고, 원시 데이터와 큰 산출물은 원래 저장소에&lt;br /&gt;
  둔다. 공개 논문의 저자처럼 인용에 필요한 정보는 정확히 유지한다.&lt;br /&gt;
- 수치, 버전, 짧은 기술 표현은 출처가 뒷받침할 때만 정확히 보존한다. 경로, URL,&lt;br /&gt;
  체크섬, 날짜, 실험 결과나 인과관계를 추측해서 채우지 않는다.&lt;br /&gt;
&lt;br /&gt;
다음 순서로 처리한다:&lt;br /&gt;
1. 후보마다 핵심 메커니즘과 적용 조건으로 search_lessons를 먼저 실행한다.&lt;br /&gt;
   한 번에 최대 5건만 받고, 필요할 때만 find_related_lessons나&lt;br /&gt;
   find_analogies로 확인한다.&lt;br /&gt;
2. 같은 주장과 적용 범위의 메모가 있으면 새 메모를 만들지 않는다. 출처만 늘어나면&lt;br /&gt;
   attach_evidence를 사용한다. 기존 핵심 내용을 보강해야 하면 get_lesson으로 현재&lt;br /&gt;
   revision을 읽고 근거를 먼저 붙인다. 이어지는 revise_lesson에는 attach_evidence&lt;br /&gt;
   응답의 current_revision_id를 expected_revision_id로 사용한다. 그 사이 다른 변경이&lt;br /&gt;
   있었다면 get_lesson을 다시 읽고 변경안을 재계산한다.&lt;br /&gt;
3. 주장이 다르지만 관련 메모가 있으면 새 메모를 만든 뒤 supports, contradicts,&lt;br /&gt;
   analogous_to, failed_because, works_when, suggests, supersedes 중 정확한 관계만&lt;br /&gt;
   link_lessons로 연결한다. 관련성이 불명확하면 관계를 만들지 않는다.&lt;br /&gt;
4. 새 메모에는 고유한 operation_id, title, question, attempt, context, observation,&lt;br /&gt;
   interpretation, reusable_lesson, applicability, confidence, evidence, 구조화된&lt;br /&gt;
   initial_evidence와 필요하면 안정적인 lesson_id를 넣는다. observation에는 직접 확인한&lt;br /&gt;
   사실만 쓰고 interpretation에는 가능한 원인과 불확실성을 분리해 쓴다.&lt;br /&gt;
5. 다시 찾을 수 있는 인용이 최소 한 개 있을 때만 create_lesson_draft를 실행한다.&lt;br /&gt;
   initial_evidence는 Lesson과 같은 revision에 원자적으로 저장된다. 추가 인용만&lt;br /&gt;
   attach_evidence로 붙인다. 논문 근거에는 실제 확인 범위를&lt;br /&gt;
   full_text, official_abstract, partial_source, metadata_only 중 정확히 표시한다.&lt;br /&gt;
   확인 범위를 기록하지 못한 자료는 새 evidence로 붙이지 않으며, 기술 내용을&lt;br /&gt;
   추측하지 않는다. 근거가 기억뿐이거나 위치를 확인할 수 없으면 쓰지 말고&lt;br /&gt;
   &#039;근거 필요&#039; 후보로 남긴다. 실제 자료를 읽은 경우에만 자료 식별자,&lt;br /&gt;
   검사한 자료의 SHA-256, 위치, 확인 범위, 판정과 연결한 주장 필드를&lt;br /&gt;
   검증 기록으로 남긴다. high confidence는 full_text 또는 official_abstract의&lt;br /&gt;
   `supports` 검증이 observation, interpretation, reusable_lesson 세 필드를 모두&lt;br /&gt;
   뒷받침할 때만 쓴다.&lt;br /&gt;
6. 이번 실행에서는 생성 또는 변경한 메모를 합쳐 최대 5건까지만 처리한다. 그 이상은&lt;br /&gt;
   제목과 중단 이유만 남기고 위키를 변경하지 않는다.&lt;br /&gt;
- 다섯 쓰기 도구 모두 새 작업마다 고유한 operation_id를 쓰고, 결과가 불분명한 재시도에는&lt;br /&gt;
  원래 ID와 요청을 그대로 쓴다. 내부 호환 필드는 서버에 맡기며 삭제와 이동은 하지 않는다.&lt;br /&gt;
  같은 Lesson의 정정·보강은 revision으로 남기고, supersedes는 실제로 다른 주장이&lt;br /&gt;
  이전 주장을 대신할 때만 사용한다. evidence note나 Technical Review 중복 메모를&lt;br /&gt;
  본문 수정 대신 만들지 않는다.&lt;br /&gt;
&lt;br /&gt;
별도 확인 질문 없이 위 조건을 만족하는 항목만 처리해. 마지막에는 다음 순서로 짧게&lt;br /&gt;
보고해:&lt;br /&gt;
1. 실제로 확인한 자료 범위와 확인하지 못한 범위&lt;br /&gt;
2. 생성·근거 추가·검증 추가·관계 추가·중복으로 건너뜀을 구분한 Lesson ID와 제목&lt;br /&gt;
3. 위키에 쓰지 않은 &#039;근거 필요&#039; 후보와 빠진 정보&lt;br /&gt;
4. 접근 가능한 범위를 모두 확인했는지, 아니면 5건 제한 때문에 남은 후보가 있는지&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
생성·수정한 메모도 바로 검색됩니다. 반환된 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;Lesson:&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt; ID와 revision을 &amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;audit_lessons&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;로 확인하고, 위키에서 관찰과 해석이 섞이지 않았는지, 인용으로 원본을 다시 찾을 수 있는지, 공개하면 안 되는 내용이 없는지 확인하세요. 문제가 있으면 새 revision으로 고칩니다. 감사는 저장된 구조, digest, 검증 수령 기록의 일관성만 확인하며 외부 자료의 사실 진위를 자동으로 판정하지 않습니다.&lt;br /&gt;
&lt;br /&gt;
=== 무엇을 남기나 ===&lt;br /&gt;
&lt;br /&gt;
각 메모에는 궁금했던 점, 해본 것, 당시 조건, 실제 결과, 가능한 원인, 다음에 기억할 것, 언제 맞는지, 신뢰도와 관련 자료를 적습니다. 관계는 다음 일곱 가지만 사용합니다.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&amp;lt;nowiki&amp;gt;&lt;br /&gt;
supports, contradicts, analogous_to, failed_because,&lt;br /&gt;
works_when, suggests, supersedes&lt;br /&gt;
&amp;lt;/nowiki&amp;gt;&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
원시 로그, 대형 데이터셋과 실험 산출물 전체는 원래 저장소에 두고, 위키에는 다시 찾을 수 있는 URL·경로·체크섬과 해석을 기록합니다.&lt;br /&gt;
&lt;br /&gt;
=== 문서 ===&lt;br /&gt;
&lt;br /&gt;
사용자:&lt;br /&gt;
&lt;br /&gt;
* [[Help:S3 연구 메모리/Linux 연결|Linux 연결]]&lt;br /&gt;
* [[Help:S3 연구 메모리/macOS 연결|macOS 연결]]&lt;br /&gt;
* [[Help:S3 연구 메모리/Windows 연결|Windows 연결]]&lt;br /&gt;
* [[Help:S3 연구 메모리/MCP 클라이언트|제품별 MCP 참고]]&lt;br /&gt;
* [[Help:S3 연구 메모리/메모 사용법|메모 작성·검색·MCP 사용]]&lt;br /&gt;
* [[Help:S3 연구 메모리/문제 해결|문제 해결]]&lt;br /&gt;
서버 담당자:&lt;br /&gt;
&lt;br /&gt;
* [[Help:S3 연구 메모리/중앙 서버 관리|중앙 서버 관리]]&lt;br /&gt;
* [[Help:S3 연구 메모리/설치와 검증|설치와 검증]]&lt;br /&gt;
* [[Help:S3 연구 메모리/백업과 복원|백업·복원·업그레이드]]&lt;br /&gt;
* [[Help:S3 연구 메모리/보안|계정·비밀·네트워크 보안]]&lt;br /&gt;
* [[Help:S3 연구 메모리/아키텍처|데이터 계약과 아키텍처]]&lt;br /&gt;
* [[Help:S3 연구 메모리/벡터·하이브리드 검색 설계|벡터·하이브리드 검색 설계]]&lt;br /&gt;
기여:&lt;br /&gt;
&lt;br /&gt;
* [[Help:S3 연구 메모리/문체|사용자에게 보이는 한국어]]&lt;br /&gt;
* [[Help:S3 연구 메모리/파일럿 가정|파일럿 가정]]&lt;br /&gt;
* [[Help:S3 연구 메모리/참고문서|상위 프로젝트와 공식 참고문서]]&lt;br /&gt;
* 에이전트 기여 규칙 (&amp;lt;code&amp;gt;&amp;lt;nowiki&amp;gt;AGENTS.md&amp;lt;/nowiki&amp;gt;&amp;lt;/code&amp;gt;)&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=Form:Lesson&amp;diff=1716</id>
		<title>Form:Lesson</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=Form:Lesson&amp;diff=1716"/>
		<updated>2026-07-18T10:09:46Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 원본 스키마 동기화&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{{info|create title=새 메모|edit title=메모 편집}}}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-form-note&amp;quot;&amp;gt;&lt;br /&gt;
관찰한 사실과 해석을 나눠 적으세요. 저장하면 바로 검색됩니다. 근거 인용에는 원본을 다시 찾을 수 있는 경로나 URL을 적습니다. 실제 저장 계정과 시각은 MediaWiki revision에 남고, 아래 작성자·시각은 저장 경계를 확인하는 보조값입니다. 기존 메모를 고칠 때는 &amp;lt;em&amp;gt;수정 시각&amp;lt;/em&amp;gt;을 현재 UTC ISO-8601 시각으로 바꾸세요. 이 화면을 5분 넘게 열어 두었다면 저장 전에 새로고침하세요.&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
{{{for template|Lesson}}}&lt;br /&gt;
{| class=&amp;quot;formtable&amp;quot;&lt;br /&gt;
! 제목&lt;br /&gt;
| {{{field|title|input type=text|mandatory|size=80}}}&lt;br /&gt;
|-&lt;br /&gt;
! 궁금했던 점&lt;br /&gt;
| {{{field|question|input type=textarea|mandatory|rows=4|cols=100}}}&lt;br /&gt;
|-&lt;br /&gt;
! 해본 것&lt;br /&gt;
| {{{field|attempt|input type=textarea|mandatory|rows=5|cols=100}}}&lt;br /&gt;
|-&lt;br /&gt;
! 당시 조건&lt;br /&gt;
| {{{field|context|input type=textarea|mandatory|rows=6|cols=100}}}&lt;br /&gt;
|-&lt;br /&gt;
! 실제 결과&lt;br /&gt;
| {{{field|observation|input type=textarea|mandatory|rows=6|cols=100}}}&lt;br /&gt;
|-&lt;br /&gt;
! 왜 그랬는지&lt;br /&gt;
| {{{field|interpretation|input type=textarea|mandatory|rows=6|cols=100}}}&lt;br /&gt;
|-&lt;br /&gt;
! 다음에 기억할 것&lt;br /&gt;
| {{{field|reusable_lesson|input type=textarea|mandatory|rows=4|cols=100}}}&lt;br /&gt;
|-&lt;br /&gt;
! 언제 맞는지&lt;br /&gt;
| {{{field|applicability|input type=textarea|mandatory|rows=5|cols=100}}}&lt;br /&gt;
|-&lt;br /&gt;
! 신뢰도&lt;br /&gt;
| {{{field|confidence|input type=dropdown|values=low,medium,high|mapping template=S3RM confidence label|mandatory|default=low}}}&lt;br /&gt;
|-&lt;br /&gt;
! 관련 자료&lt;br /&gt;
| {{{field|evidence|input type=textarea|mandatory|rows=4|cols=100}}}&lt;br /&gt;
|-&lt;br /&gt;
! 자료 출처&lt;br /&gt;
| {{{field|record_origin|input type=dropdown|values=lab,imported|mapping template=S3RM origin label|mandatory|default=lab}}}&lt;br /&gt;
|-&lt;br /&gt;
! 작성자&lt;br /&gt;
| {{{field|author|input type=text|mandatory|default=current user}}}&lt;br /&gt;
|-&lt;br /&gt;
! 처음 작성한 시각 (UTC ISO-8601)&lt;br /&gt;
| {{{field|created_at|input type=text|mandatory|default={{#time:c}}}}}&lt;br /&gt;
|-&lt;br /&gt;
! 마지막 수정 시각 (UTC ISO-8601)&lt;br /&gt;
| {{{field|updated_at|input type=text|mandatory|default={{#time:c}}}}}&lt;br /&gt;
|}&lt;br /&gt;
{{{field|reviewer|hidden|default=}}}&lt;br /&gt;
{{{field|review_state|hidden|default=Draft}}}&lt;br /&gt;
{{{end template}}}&lt;br /&gt;
&lt;br /&gt;
== 자료 연결 ==&lt;br /&gt;
{{{for template|Lesson evidence|multiple|minimum instances=1|add button text=자료 추가}}}&lt;br /&gt;
{| class=&amp;quot;formtable&amp;quot;&lt;br /&gt;
! 자료 ID&lt;br /&gt;
| {{{field|id|input type=text|mandatory|placeholder=ev_run_017}}}&lt;br /&gt;
|-&lt;br /&gt;
! 출처&lt;br /&gt;
| {{{field|citation|input type=textarea|mandatory|rows=3|cols=100}}}&lt;br /&gt;
|-&lt;br /&gt;
! URL (선택)&lt;br /&gt;
| {{{field|url|input type=text|size=100}}}&lt;br /&gt;
|-&lt;br /&gt;
! 자료 종류&lt;br /&gt;
| {{{field|kind|input type=dropdown|values=paper,code,dataset,log,benchmark,other|mapping template=S3RM evidence kind label|mandatory|default=other}}}&lt;br /&gt;
|-&lt;br /&gt;
! 확인 범위&lt;br /&gt;
| {{{field|verification_basis|input type=dropdown|values=full_text,official_abstract,partial_source,metadata_only|mapping template=S3RM verification basis label|mandatory}}}&lt;br /&gt;
|-&lt;br /&gt;
! 메모&lt;br /&gt;
| {{{field|note|input type=textarea|rows=3|cols=100}}}&lt;br /&gt;
|-&lt;br /&gt;
! 추가한 사람&lt;br /&gt;
| {{{field|added_by|input type=text|mandatory|default=current user}}}&lt;br /&gt;
|-&lt;br /&gt;
! 추가 시각 (UTC ISO-8601)&lt;br /&gt;
| {{{field|added_at|input type=text|mandatory|default={{#time:c}}}}}&lt;br /&gt;
|}&lt;br /&gt;
{{{end template}}}&lt;br /&gt;
&lt;br /&gt;
== 자료 검증 기록 (선택) ==&lt;br /&gt;
{{{for template|Lesson evidence verification|multiple|add button text=검증 기록 추가}}}&lt;br /&gt;
{| class=&amp;quot;formtable&amp;quot;&lt;br /&gt;
! 검증 ID&lt;br /&gt;
| {{{field|id|input type=text|mandatory|placeholder=verify_ev_run_017_01}}}&lt;br /&gt;
|-&lt;br /&gt;
! 검증한 자료 ID&lt;br /&gt;
| {{{field|evidence_id|input type=text|mandatory|placeholder=ev_run_017}}}&lt;br /&gt;
|-&lt;br /&gt;
! 자료 내용 digest (SHA-256)&lt;br /&gt;
| {{{field|evidence_digest|input type=text|mandatory|size=70}}}&lt;br /&gt;
|-&lt;br /&gt;
! 실제 확인 범위&lt;br /&gt;
| {{{field|verification_basis|input type=dropdown|values=full_text,official_abstract,partial_source,metadata_only|mapping template=S3RM verification basis label|mandatory}}}&lt;br /&gt;
|-&lt;br /&gt;
! 확인한 자료 식별자&lt;br /&gt;
| {{{field|source_identity|input type=textarea|mandatory|rows=2|cols=100}}}&lt;br /&gt;
|-&lt;br /&gt;
! 확인한 자료 digest (SHA-256)&lt;br /&gt;
| {{{field|source_sha256|input type=text|mandatory|size=70}}}&lt;br /&gt;
|-&lt;br /&gt;
! 확인 위치&lt;br /&gt;
| {{{field|source_locator|input type=textarea|mandatory|rows=2|cols=100}}}&lt;br /&gt;
|-&lt;br /&gt;
! 확인 범위 설명&lt;br /&gt;
| {{{field|coverage|input type=textarea|mandatory|rows=4|cols=100}}}&lt;br /&gt;
|-&lt;br /&gt;
! 확인 결과&lt;br /&gt;
| {{{field|outcome|input type=dropdown|values=supports,contradicts,inconclusive|mapping template=S3RM verification outcome label|mandatory}}}&lt;br /&gt;
|-&lt;br /&gt;
! 연결한 주장 필드&lt;br /&gt;
| {{{field|claim_fields|input type=checkboxes|values=question,attempt,context,observation,interpretation,reusable_lesson,applicability|mandatory}}}&lt;br /&gt;
|-&lt;br /&gt;
! 확인한 사람&lt;br /&gt;
| {{{field|verified_by|hidden|default=current user}}}현재 로그인 계정&lt;br /&gt;
|-&lt;br /&gt;
! 확인 시각 (UTC ISO-8601)&lt;br /&gt;
| {{{field|verified_at|hidden|default={{#time:c}}}}}저장 시각&lt;br /&gt;
|}&lt;br /&gt;
{{{end template}}}&lt;br /&gt;
&lt;br /&gt;
== 다른 메모와 연결 (선택) ==&lt;br /&gt;
{{{for template|Lesson relation|multiple|add button text=관계 추가}}}&lt;br /&gt;
{| class=&amp;quot;formtable&amp;quot;&lt;br /&gt;
! 현재 메모&lt;br /&gt;
| {{{field|from|hidden|default={{FULLPAGENAMEE}}}}}&lt;br /&gt;
|-&lt;br /&gt;
! 관계&lt;br /&gt;
| {{{field|relation|input type=dropdown|values=supports,contradicts,analogous_to,failed_because,works_when,suggests,supersedes|mapping template=S3RM relation label|mandatory}}}&lt;br /&gt;
|-&lt;br /&gt;
! 연결할 메모&lt;br /&gt;
| {{{field|to|input type=text|mandatory|placeholder=Lesson:existing_lowercase_slug}}}&lt;br /&gt;
|-&lt;br /&gt;
! 연결 이유&lt;br /&gt;
| {{{field|note|input type=textarea|rows=3|cols=100}}}&lt;br /&gt;
|-&lt;br /&gt;
! 연결한 사람&lt;br /&gt;
| {{{field|created_by|input type=text|mandatory|default=current user}}}&lt;br /&gt;
|-&lt;br /&gt;
! 연결 시각 (UTC ISO-8601)&lt;br /&gt;
| {{{field|created_at|input type=text|mandatory|default={{#time:c}}}}}&lt;br /&gt;
|}&lt;br /&gt;
{{{end template}}}&lt;br /&gt;
&lt;br /&gt;
{{{standard input|free text|hidden}}}&lt;br /&gt;
{{{standard input|summary|value=메모 편집}}}&lt;br /&gt;
{{{standard input|save}}} {{{standard input|preview}}} {{{standard input|changes}}}&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%ED%8B%80:S3RM_verification_outcome_label&amp;diff=1715</id>
		<title>틀:S3RM verification outcome label</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%ED%8B%80:S3RM_verification_outcome_label&amp;diff=1715"/>
		<updated>2026-07-18T10:09:46Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 원본 스키마 설치&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;includeonly&amp;gt;{{#switch:{{lc:{{{1|}}}}}|supports=지지함|contradicts=반박함|inconclusive=판단 보류|{{{1|알 수 없음}}}}}&amp;lt;/includeonly&amp;gt;&amp;lt;noinclude&amp;gt;&lt;br /&gt;
자료 검증 결과 값을 짧은 한국어로 표시합니다.&lt;br /&gt;
&amp;lt;/noinclude&amp;gt;&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%ED%8B%80:Lesson_evidence_verification&amp;diff=1714</id>
		<title>틀:Lesson evidence verification</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%ED%8B%80:Lesson_evidence_verification&amp;diff=1714"/>
		<updated>2026-07-18T10:09:46Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 원본 스키마 설치&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;includeonly&amp;gt;&lt;br /&gt;
{{#subobject:&lt;br /&gt;
 |Verification ID={{#s3rm_value:{{{id|}}}}}&lt;br /&gt;
 |Verified evidence ID={{#s3rm_value:{{{evidence_id|}}}}}&lt;br /&gt;
 |Verified evidence digest={{#s3rm_value:{{{evidence_digest|}}}}}&lt;br /&gt;
 |Verification basis={{#s3rm_value:{{{verification_basis|}}}}}&lt;br /&gt;
 |Verification source identity={{#s3rm_value:{{{source_identity|}}}}}&lt;br /&gt;
 |Verification source SHA256={{#s3rm_value:{{{source_sha256|}}}}}&lt;br /&gt;
 |Verification source locator={{#s3rm_value:{{{source_locator|}}}}}&lt;br /&gt;
 |Verification coverage={{#s3rm_value:{{{coverage|}}}}}&lt;br /&gt;
 |Verification outcome={{#s3rm_value:{{{outcome|}}}}}&lt;br /&gt;
 |Verification claim fields={{#s3rm_value:{{{claim_fields|}}}}}&lt;br /&gt;
 |Evidence verified by={{#s3rm_value:{{{verified_by|}}}}}&lt;br /&gt;
 |Evidence verified at={{#s3rm_value:{{{verified_at|}}}}}&lt;br /&gt;
}}&lt;br /&gt;
&amp;lt;div class=&amp;quot;s3rm-evidence s3rm-verification&amp;quot;&amp;gt;&lt;br /&gt;
&#039;&#039;&#039;자료 검증 &amp;lt;code&amp;gt;{{#s3rm_value:{{{id|missing-id}}}}}&amp;lt;/code&amp;gt;&#039;&#039;&#039;:&lt;br /&gt;
&amp;lt;code&amp;gt;{{#s3rm_value:{{{evidence_id|missing-evidence}}}}}&amp;lt;/code&amp;gt; ·&lt;br /&gt;
{{S3RM verification outcome label|{{#s3rm_value:{{{outcome|inconclusive}}}}}}}&lt;br /&gt;
&amp;lt;br /&amp;gt;&amp;lt;small&amp;gt;확인 범위: {{S3RM verification basis label|{{#s3rm_value:{{{verification_basis|not_recorded}}}}}}} · 주장: {{#s3rm_value:{{{claim_fields|없음}}}}} · {{#s3rm_value:{{{verified_by|알 수 없음}}}}} · {{#s3rm_value:{{{verified_at|시각 없음}}}}}&amp;lt;/small&amp;gt;&lt;br /&gt;
&amp;lt;br /&amp;gt;자료: {{#s3rm_value:{{{source_identity|식별자 없음}}}}} / 위치: {{#s3rm_value:{{{source_locator|위치 없음}}}}}&lt;br /&gt;
&amp;lt;br /&amp;gt;{{#s3rm_value:{{{coverage|확인 범위 설명 없음}}}}}&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/includeonly&amp;gt;&amp;lt;noinclude&amp;gt;&lt;br /&gt;
기존 자료를 바꾸지 않고 실제 확인 결과를 시간순으로 남기는 검증 기록입니다.&lt;br /&gt;
&amp;lt;/noinclude&amp;gt;&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%EC%86%8D%EC%84%B1:Evidence_verified_at&amp;diff=1713</id>
		<title>속성:Evidence verified at</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%EC%86%8D%EC%84%B1:Evidence_verified_at&amp;diff=1713"/>
		<updated>2026-07-18T10:09:46Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 원본 스키마 설치&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;[[Has type::Date]]&lt;br /&gt;
&lt;br /&gt;
자료 검증 기록을 추가한 UTC ISO-8601 시각입니다.&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%EC%86%8D%EC%84%B1:Evidence_verified_by&amp;diff=1712</id>
		<title>속성:Evidence verified by</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%EC%86%8D%EC%84%B1:Evidence_verified_by&amp;diff=1712"/>
		<updated>2026-07-18T10:09:46Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 원본 스키마 설치&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;[[Has type::Text]]&lt;br /&gt;
&lt;br /&gt;
자료를 실제로 확인하고 검증 기록을 남긴 계정입니다.&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%EC%86%8D%EC%84%B1:Verification_claim_fields&amp;diff=1711</id>
		<title>속성:Verification claim fields</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%EC%86%8D%EC%84%B1:Verification_claim_fields&amp;diff=1711"/>
		<updated>2026-07-18T10:09:46Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 원본 스키마 설치&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;[[Has type::Text]]&lt;br /&gt;
&lt;br /&gt;
검증 결과를 연결한 Lesson 주장 필드 목록입니다.&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%EC%86%8D%EC%84%B1:Verification_outcome&amp;diff=1710</id>
		<title>속성:Verification outcome</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%EC%86%8D%EC%84%B1:Verification_outcome&amp;diff=1710"/>
		<updated>2026-07-18T10:09:46Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 원본 스키마 설치&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;[[Has type::Text]]&lt;br /&gt;
[[Allows value::supports]]&lt;br /&gt;
[[Allows value::contradicts]]&lt;br /&gt;
[[Allows value::inconclusive]]&lt;br /&gt;
&lt;br /&gt;
확인한 자료가 연결된 주장에 미치는 결과입니다.&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
	<entry>
		<id>https://s3wiki.yonsei.ac.kr/index.php?title=%EC%86%8D%EC%84%B1:Verification_coverage&amp;diff=1709</id>
		<title>속성:Verification coverage</title>
		<link rel="alternate" type="text/html" href="https://s3wiki.yonsei.ac.kr/index.php?title=%EC%86%8D%EC%84%B1:Verification_coverage&amp;diff=1709"/>
		<updated>2026-07-18T10:09:46Z</updated>

		<summary type="html">&lt;p&gt;S3Admin: S3 연구 메모리 원본 스키마 설치&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;[[Has type::Text]]&lt;br /&gt;
&lt;br /&gt;
확인한 범위와 확인하지 못한 범위를 설명합니다.&lt;/div&gt;</summary>
		<author><name>S3Admin</name></author>
	</entry>
</feed>