From 2db09ffb1ece62ef28d1af0384b2baee01f75414 Mon Sep 17 00:00:00 2001 From: hungrytech Date: Fri, 31 Jul 2026 17:27:38 +0900 Subject: [PATCH 01/21] feat: add typed context graph --- README.ja.md | 51 +++++++++++++ README.ko.md | 51 +++++++++++++ README.md | 51 +++++++++++++ README.zh-CN.md | 51 +++++++++++++ server/app/main.py | 43 +++++++---- server/app/neo4j_graph.py | 44 ++++++++++- server/app/okf.py | 122 ++++++++++++++++++++++++++++++- server/tests/test_api.py | 33 ++++++++- server/tests/test_neo4j_graph.py | 64 +++++++++++++++- server/tests/test_okf.py | 76 ++++++++++++++++++- web/src/App.test.tsx | 29 ++++++++ web/src/App.tsx | 88 ++++++++++++++++++++-- web/src/styles.css | 1 + 13 files changed, 669 insertions(+), 35 deletions(-) diff --git a/README.ja.md b/README.ja.md index f24cb2e..5d376f8 100644 --- a/README.ja.md +++ b/README.ja.md @@ -77,6 +77,57 @@ data/wiki/ `Source` は外部資料と provenance を保存し、`Concept` と `Comparison` は解釈を記録します。この境界により主張をレビュー可能にし、根拠を黙って書き換えることを防ぎます。 +### 型付きコンテキスト関係 + +長期記憶文書は、frontmatter に方向と種類を持つ関係を記録できます。短縮形式は既存 corpus の規約に合い、構造化形式では edge 単位の根拠や時刻も保持できます。 + +```yaml +relations: + - "supported-by [[sources/primary-evidence]]" + - kind: supersedes + target: concepts/previous-decision + context: ADR-0042 + timestamp: 2026-07-26T10:30:00Z +``` + +本文の内部リンクは `links-to`、frontmatter の関係は明示された kind として投影されます。UI の **コンテキスト** グラフは選択文書の incoming/outgoing edge を表示します。Neo4j は再構築可能な派生物であり、Markdown の関係が正本です。 + +### コンテキストが長く使える知識へ昇格する流れ + +グラフに線ができただけでは、信頼できる知識にはなりません。WikiBrain は一時的な観察が暗黙に永続的な事実へ変わらないよう、収集、解釈、利用を分けます。 + +| 段階 | 意味 | 代表的な文書・関係 | +|---|---|---| +| 0. 入口 | 会話、URL、handoff、観察がまだ知識として採用されていない | curator が収集するまでは正本 bundle の外に置く | +| 1. 根拠 | URL、時刻、hash とともに原文を保存する | `Source` | +| 2. 理解 | 主張を簡潔に整理し、根拠と照合する | `supported-by` で結んだ `Concept` または `Comparison` | +| 3. 利用 | レビュー済みの知識を実装や長期的な判断に使う | `Project` または判断用 `Concept` と、`extends`・`adopts` などの明示的な関係 | +| 4. 更新 | 新しい根拠で以前の結論を改めても、履歴は消さない | 新文書から旧文書への `supersedes` | + +現在の昇格は人によるレビューで行います。Curator が出典を確認し、重複を除き、事実と解釈を分けてから関係を明示します。グラフには、長期文書が作られた理由と、何を置き換えたかが残ります。 + +```mermaid +flowchart LR + A[会話、URL、
handoff、観察] -->|curator が収集| B[Source
変更しない原文根拠] + B --> C{出典があり
関連性と可読性が十分か?} + C -- いいえ --> X[長期 corpus の外に置くか
より良い根拠を求める] + C -- はい --> D[Concept または Comparison
整理した理解] + D -->|supported-by| B + D --> E{レビュー済みで
今後も役立つか?} + E -- まだ --> D + E -- はい --> F[Project または長期的な
判断用 Concept] + F -->|extends / adopts| D + G[新しい根拠] --> H[新しい解釈] + H -->|supersedes| D + H --> F +``` + +#### 現在実装されていること + +WikiBrain は型付き関係を解析・保存し、`/api/graph` で返し、Neo4j が有効なら派生グラフへ投影します。UI では選択文書の incoming/outgoing 関係を確認できます。Markdown が正本で、PostgreSQL と Neo4j は再構築できます。 + +現在、会話 session の自動収集、保持期間の計算、人気スコアによる自動昇格、関係種類の推論は行いません。`brainctl remember` コマンドも存在しません。自動昇格は将来の機能です。実装する場合は provenance の保持、競合の可視化、レビュー可能な方針を先に整え、その後に正本 Markdown を変更する必要があります。 + ## 開発 ```bash diff --git a/README.ko.md b/README.ko.md index bc734c0..ea824c5 100644 --- a/README.ko.md +++ b/README.ko.md @@ -80,6 +80,57 @@ data/wiki/ `Source`는 외부 원문과 provenance를 보관하고, `Concept`와 `Comparison`은 해석을 기록합니다. 이 경계를 유지하면 주장 검토가 쉬우며 근거를 조용히 바꾸지 않게 됩니다. +### 타입이 있는 맥락 관계 + +장기기억 문서는 frontmatter에 방향과 종류가 있는 관계를 기록할 수 있습니다. 짧은 형식은 기존 corpus 관례이고, 구조화 형식은 edge 수준의 근거나 시간을 함께 보존합니다. + +```yaml +relations: + - "supported-by [[sources/primary-evidence]]" + - kind: supersedes + target: concepts/previous-decision + context: ADR-0042 + timestamp: 2026-07-26T10:30:00Z +``` + +본문 내부 링크는 `links-to`로, frontmatter 관계는 명시한 kind로 projection됩니다. UI의 **맥락** 그래프는 선택 문서의 incoming/outgoing edge를 보여 줍니다. Neo4j는 재생성 가능한 파생물이고 Markdown 관계가 정본입니다. + +### 맥락이 오래 쓰는 지식으로 승급되는 과정 + +그래프 선이 생겼다고 곧바로 믿을 만한 지식이 되는 것은 아닙니다. WikiBrain은 임시 관찰이 조용히 영구 사실로 굳지 않도록 수집, 해석, 활용을 나눕니다. + +| 단계 | 의미 | 대표 문서·관계 | +|---|---|---| +| 0. 입구 | 대화, URL, handoff, 관찰이 아직 지식으로 채택되지 않은 상태 | curator가 수집하기 전에는 정본 bundle 밖에 둠 | +| 1. 근거 | URL, 시각, hash와 함께 원문을 보존 | `Source` | +| 2. 이해 | 주장을 짧게 정리하고 근거와 대조 | `supported-by`로 연결한 `Concept` 또는 `Comparison` | +| 3. 활용 | 검토한 지식이 구현이나 오래 유지할 의사결정에 쓰임 | `Project` 또는 의사결정 중심 `Concept`, `extends`·`adopts` 등 명시적 관계 | +| 4. 갱신 | 새 근거가 이전 결론을 바꾸되 과거 기록은 지우지 않음 | 새 문서가 이전 문서를 `supersedes`로 연결 | + +현재 승급은 사람이 검토하는 과정입니다. Curator가 출처를 확인하고, 중복을 제거하고, 사실과 해석을 분리한 뒤 관계를 직접 기록합니다. 그래프는 장기 문서가 왜 생겼고 무엇을 대신했는지 남깁니다. + +```mermaid +flowchart LR + A[대화, URL,
handoff, 관찰] -->|curator가 수집| B[Source
바꾸지 않는 원문 근거] + B --> C{출처가 남아 있고
관련성과 가독성이 충분한가?} + C -- 아니오 --> X[장기 corpus 밖에 두거나
더 나은 근거 요청] + C -- 예 --> D[Concept 또는 Comparison
정제한 이해] + D -->|supported-by| B + D --> E{검토를 마쳤고
나중에도 쓸 만한가?} + E -- 아직 아님 --> D + E -- 예 --> F[Project 또는 오래 유지할
의사결정 Concept] + F -->|extends / adopts| D + G[새 근거] --> H[새 해석] + H -->|supersedes| D + H --> F +``` + +#### 지금 실제로 되는 것 + +WikiBrain은 타입 관계를 읽고 보존하며, `/api/graph`로 반환하고, Neo4j가 켜져 있으면 파생 그래프에 투영합니다. UI에서는 선택 문서의 들어오는 관계와 나가는 관계를 볼 수 있습니다. Markdown이 정본이고 PostgreSQL과 Neo4j는 다시 만들 수 있습니다. + +현재는 대화 session을 자동 수집하거나, 보존 기간을 계산하거나, 인기 점수로 자동 승급하거나, 관계 종류를 추론하지 않습니다. `brainctl remember` 명령도 없습니다. 자동 승급은 향후 기능입니다. 구현하려면 provenance 보존, 충돌 노출, 검토 가능한 정책을 먼저 갖추고 나서 정본 Markdown을 변경해야 합니다. + ## 개발 ```bash diff --git a/README.md b/README.md index 0d25d79..11e2cd8 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,57 @@ data/wiki/ A `Source` document preserves external material and provenance. A `Concept` or `Comparison` document adds interpretation. Keeping these separate makes claims reviewable and lets the corpus evolve without silently rewriting source evidence. +### Typed context relations + +Durable context can declare directed, typed relations in frontmatter. The compact form matches the corpus's existing convention; the structured form adds edge-level provenance or time metadata: + +```yaml +relations: + - "supported-by [[sources/primary-evidence]]" + - kind: supersedes + target: concepts/previous-decision + context: ADR-0042 + timestamp: 2026-07-26T10:30:00Z +``` + +Internal Markdown links are projected as `links-to`; frontmatter relations keep their explicit kind. The UI's **Context** graph shows incoming and outgoing edges around the selected document. Neo4j remains a rebuildable projection—the Markdown relation is canonical. + +### How context becomes durable knowledge + +A graph edge does not make a note trustworthy. WikiBrain separates capture, interpretation, and action so a temporary observation does not silently become a durable fact. + +| Stage | What it means | Typical document or relation | +|---|---|---| +| 0. Intake | A conversation, URL, handoff, or observation has not been accepted as knowledge yet | Outside the canonical bundle until a curator captures it | +| 1. Evidence | The original material is captured with its URL, time, and hash | `Source` | +| 2. Understanding | A claim is distilled, checked, and connected to its evidence | `Concept` or `Comparison` with `supported-by` | +| 3. Use | Reviewed knowledge informs an implementation or durable decision | `Project`, or a decision-focused `Concept`, linked with `extends`, `adopts`, or another explicit kind | +| 4. Revision | New evidence changes an older conclusion without rewriting history | A new document with `supersedes`; the old document remains traceable | + +Promotion is review-driven today. A curator checks provenance, removes duplicates, separates fact from interpretation, and writes the relation explicitly. The graph records why the durable document exists and what it replaced. + +```mermaid +flowchart LR + A[Conversation, URL,
handoff, or observation] -->|curator captures| B[Source
immutable evidence] + B --> C{Provenance present?
Relevant and readable?} + C -- No --> X[Keep outside the durable corpus
or request better evidence] + C -- Yes --> D[Concept or Comparison
distilled understanding] + D -->|supported-by| B + D --> E{Reviewed and useful
for future work?} + E -- Not yet --> D + E -- Yes --> F[Project or durable
decision Concept] + F -->|extends / adopts| D + G[New evidence] --> H[New interpretation] + H -->|supersedes| D + H --> F +``` + +#### What is implemented now + +WikiBrain parses and preserves typed relations, returns them from `/api/graph`, projects them into Neo4j when enabled, and renders incoming and outgoing context in the UI. Markdown remains canonical; PostgreSQL and Neo4j can be rebuilt. + +WikiBrain does **not** currently ingest chat sessions automatically, run a retention clock, calculate a popularity score, infer relation kinds, or provide a `brainctl remember` command. Automatic promotion may be added later, but it must retain provenance, expose conflicts, and require a reviewable policy before it can change canonical Markdown. + ## Development ```bash diff --git a/README.zh-CN.md b/README.zh-CN.md index 76eb3b5..54cbf6a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -77,6 +77,57 @@ data/wiki/ `Source` 保存外部材料和 provenance,`Concept` 与 `Comparison` 记录解释。保留此边界可使主张更易审查,并避免静默重写证据。 +### 带类型的上下文关系 + +长期记忆文档可以在 frontmatter 中声明有方向、有类型的关系。简写形式沿用现有 corpus 约定;结构化形式还能保存 edge 级来源或时间信息。 + +```yaml +relations: + - "supported-by [[sources/primary-evidence]]" + - kind: supersedes + target: concepts/previous-decision + context: ADR-0042 + timestamp: 2026-07-26T10:30:00Z +``` + +正文内部链接会投影为 `links-to`,frontmatter 关系保留显式 kind。UI 的**上下文**图展示所选文档的 incoming/outgoing edge。Neo4j 仍是可重建投影,Markdown 关系才是权威来源。 + +### 上下文晋升为长期知识的过程 + +图中出现一条边,并不代表内容已经可信。WikiBrain 将采集、解释和使用分开,避免临时观察在没有审查的情况下变成永久事实。 + +| 阶段 | 含义 | 典型文档或关系 | +|---|---|---| +| 0. 入口 | 对话、URL、handoff 或观察尚未被接受为知识 | curator 采集之前保留在权威 bundle 之外 | +| 1. 证据 | 连同 URL、时间和 hash 保存原始材料 | `Source` | +| 2. 理解 | 精炼主张,并与证据核对 | 通过 `supported-by` 连接的 `Concept` 或 `Comparison` | +| 3. 使用 | 经审查的知识用于实现或长期决策 | `Project` 或决策型 `Concept`,并使用 `extends`、`adopts` 等显式关系 | +| 4. 修订 | 新证据改变旧结论,但不删除历史 | 新文档通过 `supersedes` 指向旧文档 | + +目前的晋升由人工审查完成。Curator 检查来源、去重、分离事实与解释,然后显式写入关系。图会记录长期文档为何产生,以及它替代了什么。 + +```mermaid +flowchart LR + A[对话、URL、
handoff 或观察] -->|curator 采集| B[Source
不可变的原始证据] + B --> C{来源完整且
相关、可读吗?} + C -- 否 --> X[保留在长期 corpus 之外
或请求更好的证据] + C -- 是 --> D[Concept 或 Comparison
精炼后的理解] + D -->|supported-by| B + D --> E{已经审查并且
今后仍有用吗?} + E -- 尚未 --> D + E -- 是 --> F[Project 或长期保存的
决策型 Concept] + F -->|extends / adopts| D + G[新证据] --> H[新解释] + H -->|supersedes| D + H --> F +``` + +#### 当前已经实现的功能 + +WikiBrain 会解析并保存带类型的关系,通过 `/api/graph` 返回;启用 Neo4j 时,也会投影到派生图中。UI 可以显示所选文档的 incoming/outgoing 关系。Markdown 是权威来源,PostgreSQL 和 Neo4j 都可以重建。 + +目前不会自动采集对话 session、计算保留周期、按流行度评分自动晋升,也不会推断关系类型。项目中也没有 `brainctl remember` 命令。自动晋升属于未来功能;在它能修改权威 Markdown 之前,必须先保留 provenance、显示冲突,并采用可审查的策略。 + ## 开发 ```bash diff --git a/server/app/main.py b/server/app/main.py index 39c24e7..bc84ead 100644 --- a/server/app/main.py +++ b/server/app/main.py @@ -17,19 +17,10 @@ from .catalog import semantic_ids, sync_catalog from .neo4j_graph import Neo4jGraphProjection, create_projection from .embeddings import LocalEmbedder -from .okf import Concept, parse_concept, render_concept +from .okf import Concept, load_concepts, parse_concept, parse_relations, render_concept from .sources import fetch_url -def load_concepts(root: Path) -> list[Concept]: - concepts: list[Concept] = [] - for path in root.rglob("*.md"): - if path.name in {"index.md", "log.md"}: - continue - concepts.append(parse_concept(path.relative_to(root).as_posix(), path.read_text(encoding="utf-8"))) - return sorted(concepts, key=lambda item: item.id) - - def concept_slug(title: str) -> str: slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") return slug or "untitled" @@ -42,6 +33,7 @@ class IngestRequest(BaseModel): tags: list[str] = [] resource: str | None = None body: str = Field(min_length=1) + relations: list[str] = Field(default_factory=list) class UrlIngestRequest(BaseModel): @@ -98,15 +90,33 @@ def graph() -> dict[str, list[dict] | str]: return graph_store.graph() concepts = load_concepts(root) ids = {concept.id for concept in concepts} + edges = [ + { + "source": concept.id, + "target": target, + "kind": "links-to", + "context": None, + "timestamp": None, + } + for concept in concepts + for target in concept.links + if target in ids + ] + [ + { + "source": concept.id, + "target": relation.target, + "kind": relation.kind, + "context": relation.context, + "timestamp": relation.timestamp, + } + for concept in concepts + for relation in concept.relations + if relation.target in ids + ] return { "source": "filesystem", "nodes": [{"id": concept.id, "label": concept.title, "type": concept.type, "tags": concept.tags} for concept in concepts], - "edges": [ - {"source": concept.id, "target": target} - for concept in concepts - for target in concept.links - if target in ids - ], + "edges": sorted(edges, key=lambda edge: (edge["source"], edge["target"], edge["kind"])), } @app.get("/api/search") @@ -147,6 +157,7 @@ def ingest(payload: IngestRequest) -> dict: timestamp=datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"), body=payload.body, links=[], + relations=parse_relations(relative[:-3], payload.relations), ) target.parent.mkdir(parents=True, exist_ok=True) target.write_text(render_concept(concept), encoding="utf-8") diff --git a/server/app/neo4j_graph.py b/server/app/neo4j_graph.py index ff98e48..5b44029 100644 --- a/server/app/neo4j_graph.py +++ b/server/app/neo4j_graph.py @@ -7,6 +7,8 @@ from __future__ import annotations from collections.abc import Iterable +import hashlib +import json from typing import Any, Protocol from .okf import Concept @@ -24,6 +26,16 @@ class Neo4jDriver(Protocol): def session(self, **kwargs: Any) -> Neo4jSession: ... +def _relation_key(relation: Any) -> str: + """Return a stable identity without leaking context text into the graph key.""" + payload = json.dumps( + [relation.kind, relation.target, relation.context, relation.timestamp], + ensure_ascii=False, + separators=(",", ":"), + ) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + def graph_records(concepts: Iterable[Concept]) -> dict[str, list[dict[str, Any]]]: """Return deterministic, metadata-only records with links confined to this corpus.""" ordered = sorted(concepts, key=lambda concept: concept.id) @@ -38,6 +50,17 @@ def graph_records(concepts: Iterable[Concept]) -> dict[str, list[dict[str, Any]] "resource": concept.resource, "timestamp": concept.timestamp, "links": sorted({target for target in concept.links if target in known_ids}), + "relations": [ + { + "key": _relation_key(relation), + "kind": relation.kind, + "target": relation.target, + "context": relation.context, + "timestamp": relation.timestamp, + } + for relation in concept.relations + if relation.target in known_ids + ], } for concept in ordered ] @@ -113,6 +136,18 @@ def sync(self, concepts: Iterable[Concept]) -> None: graph=self.graph_key, documents=documents, ) + session.run( + """ + UNWIND $documents AS doc + UNWIND doc.relations AS relation + MATCH (source:Document {graph: $graph, id: doc.id}) + MATCH (target:Document {graph: $graph, id: relation.target}) + MERGE (source)-[edge:RELATES_TO {graph: $graph, key: relation.key}]->(target) + SET edge.kind = relation.kind, edge.context = relation.context, edge.timestamp = relation.timestamp + """, + graph=self.graph_key, + documents=documents, + ) def graph(self) -> dict[str, list[dict[str, Any]] | str]: with self.driver.session() as session: @@ -128,9 +163,12 @@ def graph(self) -> dict[str, list[dict[str, Any]] | str]: ).data() edges = session.run( """ - MATCH (source:Document {graph: $graph})-[:LINKS_TO]->(target:Document {graph: $graph}) - RETURN source.id AS source, target.id AS target - ORDER BY source, target + MATCH (source:Document {graph: $graph})-[edge]->(target:Document {graph: $graph}) + WHERE type(edge) IN ['LINKS_TO', 'RELATES_TO'] AND edge.graph = $graph + RETURN source.id AS source, target.id AS target, + CASE type(edge) WHEN 'LINKS_TO' THEN 'links-to' ELSE edge.kind END AS kind, + edge.context AS context, edge.timestamp AS timestamp + ORDER BY source, target, kind """, graph=self.graph_key, ).data() diff --git a/server/app/okf.py b/server/app/okf.py index 5d6058a..2c4a6db 100644 --- a/server/app/okf.py +++ b/server/app/okf.py @@ -1,7 +1,9 @@ from __future__ import annotations -from dataclasses import dataclass -from pathlib import PurePosixPath +from dataclasses import dataclass, field +import logging +from pathlib import Path, PurePosixPath +from posixpath import normpath import re from typing import Any @@ -9,6 +11,17 @@ _FRONTMATTER = re.compile(r"\A---\s*\n(.*?)\n---\s*\n?(.*)\Z", re.DOTALL) _LINK = re.compile(r"\[[^\]]+\]\(([^)]+\.md)\)") +_RELATION = re.compile(r"^\s*([a-zA-Z0-9][a-zA-Z0-9._-]*)\s+\[\[([^\]]+)\]\]\s*$") +_NUMBERED_DUPLICATE = re.compile(r"^(.*) \d+$") +_logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class Relation: + kind: str + target: str + context: str | None = None + timestamp: str | None = None @dataclass(frozen=True) @@ -22,6 +35,7 @@ class Concept: timestamp: str | None body: str links: list[str] + relations: list[Relation] = field(default_factory=list) title_ko: str | None = None description_ko: str | None = None body_ko: str | None = None @@ -53,12 +67,76 @@ def _extract_links(concept_id: str, body: str) -> list[str]: return result +def _normalize_relation_target(concept_id: str, raw_target: str) -> str: + target = raw_target.strip().split("|", 1)[0].split("#", 1)[0] + if not target: + raise ValueError("relation target must not be empty") + if "://" in target or "\\" in target: + raise ValueError("relation target must be an internal POSIX document id") + target_path = PurePosixPath(target.lstrip("/")) + if target_path.suffix == ".md": + target_path = target_path.with_suffix("") + top_level = target_path.parts[0] if target_path.parts else "" + if target.startswith("/") or top_level in {"concepts", "sources", "comparisons", "projects"}: + resolved = target_path + else: + resolved = PurePosixPath(concept_id).parent / target_path + normalized = normpath(resolved.as_posix()) + normalized_path = PurePosixPath(normalized) + if normalized in {"", ".", ".."} or (normalized_path.parts and normalized_path.parts[0] == ".."): + raise ValueError("relation target must stay inside the wiki bundle") + return normalized + + +def _optional_string(value: Any) -> str | None: + if value in (None, ""): + return None + if hasattr(value, "isoformat"): + return value.isoformat() + return str(value) + + +def parse_relations(concept_id: str, values: Any) -> list[Relation]: + if values in (None, []): + return [] + if not isinstance(values, list): + raise ValueError("relations must be a YAML list") + relations: list[Relation] = [] + for value in values: + if isinstance(value, str): + match = _RELATION.match(value) + if not match: + raise ValueError("relation strings must use ' [[]]'") + kind, target = match.groups() + context = timestamp = None + elif isinstance(value, dict): + kind = value.get("kind") or value.get("type") + target = value.get("target") + context = _optional_string(value.get("context")) + timestamp = _optional_string(value.get("timestamp")) + else: + raise ValueError("relations entries must be strings or mappings") + if not isinstance(kind, str) or not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9._-]*", kind): + raise ValueError("relation kind must be a slug") + if not isinstance(target, str): + raise ValueError("relation target must be a string") + relation = Relation( + kind=kind.casefold(), + target=_normalize_relation_target(concept_id, target), + context=context, + timestamp=timestamp, + ) + if relation not in relations: + relations.append(relation) + return relations + + def parse_concept(path: str, source: str) -> Concept: match = _FRONTMATTER.match(source) if not match: raise ValueError("OKF concept requires YAML frontmatter") metadata: dict[str, Any] = yaml.safe_load(match.group(1)) or {} - concept_type = metadata.get("type") + concept_type = metadata.get("type") or metadata.get("kind") if not isinstance(concept_type, str) or not concept_type.strip(): raise ValueError("OKF concept requires a non-empty type") body = match.group(2).strip() @@ -68,7 +146,7 @@ def parse_concept(path: str, source: str) -> Concept: concept_id = _concept_id(path) return Concept( id=concept_id, - type=concept_type, + type={"concept": "Concept", "comparison": "Comparison", "project": "Project", "source": "Source"}.get(concept_type.strip().lower(), concept_type.strip()), title=str(metadata.get("title") or concept_id.rsplit("/", 1)[-1]), description=metadata.get("description"), tags=[str(tag) for tag in tags], @@ -76,17 +154,53 @@ def parse_concept(path: str, source: str) -> Concept: timestamp=metadata.get("timestamp"), body=body, links=_extract_links(concept_id, body), + relations=parse_relations(concept_id, metadata.get("relations")), title_ko=metadata.get("title_ko"), description_ko=metadata.get("description_ko"), body_ko=metadata.get("body_ko"), ) +def load_concepts(root: Path) -> list[Concept]: + """Load every non-reserved OKF Markdown document from a bundle root.""" + concepts: list[Concept] = [] + for path in root.rglob("*.md"): + if path.name in {"index.md", "log.md"}: + continue + try: + source = path.read_text(encoding="utf-8") + except OSError as error: + duplicate = _NUMBERED_DUPLICATE.fullmatch(path.stem) + canonical = path.with_name(f"{duplicate.group(1)}.md") if duplicate else None + if canonical is not None and canonical.is_file(): + _logger.warning("Skipping unreadable numbered duplicate %s; canonical sibling %s exists: %s", path, canonical, error) + continue + raise + concepts.append(parse_concept(path.relative_to(root).as_posix(), source)) + return sorted(concepts, key=lambda item: item.id) + + def render_concept(concept: Concept) -> str: metadata: dict[str, Any] = {"type": concept.type, "title": concept.title} for key in ("description", "resource", "tags", "timestamp", "title_ko", "description_ko", "body_ko"): value = getattr(concept, key) if value not in (None, [], ""): metadata[key] = value + if concept.relations: + metadata["relations"] = [ + f"{relation.kind} [[{relation.target}]]" + if relation.context is None and relation.timestamp is None + else { + key: value + for key, value in { + "kind": relation.kind, + "target": relation.target, + "context": relation.context, + "timestamp": relation.timestamp, + }.items() + if value is not None + } + for relation in concept.relations + ] frontmatter = yaml.safe_dump(metadata, allow_unicode=True, sort_keys=False).strip() return f"---\n{frontmatter}\n---\n\n{concept.body.strip()}\n" diff --git a/server/tests/test_api.py b/server/tests/test_api.py index 74adead..689283d 100644 --- a/server/tests/test_api.py +++ b/server/tests/test_api.py @@ -60,7 +60,13 @@ def test_api_lists_documents_exposes_graph_search_and_ingests_okf(tmp_path): assert health.json() == {"status": "ok"} assert [item["id"] for item in documents.json()] == ["concepts/pgvector", "concepts/rag"] - assert graph.json()["edges"] == [{"source": "concepts/rag", "target": "concepts/pgvector"}] + assert graph.json()["edges"] == [{ + "source": "concepts/rag", + "target": "concepts/pgvector", + "kind": "links-to", + "context": None, + "timestamp": None, + }] assert [item["id"] for item in search.json()] == ["concepts/pgvector", "concepts/rag"] assert ingested.status_code == 201 assert ingested.json()["id"] == "sources/okf-launch" @@ -96,6 +102,31 @@ def test_api_exposes_okf_korean_translation_extensions(tmp_path): assert payload["body_ko"] == "# 개요\n\nKtor는 Kotlin용 비동기 웹 프레임워크입니다." +def test_api_graph_exposes_typed_context_relations_from_markdown(tmp_path): + _write_concept( + tmp_path, + "concepts/decision.md", + """--- +type: Concept +title: Decision +relations: + - "supported-by [[sources/evidence]]" +--- + +See [background](/concepts/background.md). +""", + ) + _write_concept(tmp_path, "concepts/background.md", "---\ntype: Concept\ntitle: Background\n---\n\n# Background") + _write_concept(tmp_path, "sources/evidence.md", "---\ntype: Source\ntitle: Evidence\n---\n\n# Evidence") + + payload = TestClient(create_app(tmp_path)).get("/api/graph").json() + + assert payload["edges"] == [ + {"source": "concepts/decision", "target": "concepts/background", "kind": "links-to", "context": None, "timestamp": None}, + {"source": "concepts/decision", "target": "sources/evidence", "kind": "supported-by", "context": None, "timestamp": None}, + ] + + def test_api_allows_tailnet_web_origins(tmp_path): _write_concept(tmp_path, "concepts/okf.md", "---\ntype: Concept\ntitle: OKF\n---\n\n# OKF") client = TestClient(create_app(tmp_path)) diff --git a/server/tests/test_neo4j_graph.py b/server/tests/test_neo4j_graph.py index c93f309..dc25843 100644 --- a/server/tests/test_neo4j_graph.py +++ b/server/tests/test_neo4j_graph.py @@ -1,11 +1,12 @@ from types import SimpleNamespace +from typing import Any import pytest from app.neo4j_graph import Neo4jGraphProjection, graph_records -def concept(identifier: str, *, links=(), tags=(), type="Concept"): +def concept(identifier: str, *, links=(), relations=(), tags=(), type="Concept") -> Any: return SimpleNamespace( id=identifier, title=identifier.rsplit("/", 1)[-1], @@ -15,6 +16,7 @@ def concept(identifier: str, *, links=(), tags=(), type="Concept"): resource=None, timestamp=None, links=list(links), + relations=list(relations), ) @@ -36,6 +38,7 @@ def test_graph_records_keep_only_internal_known_links_and_metadata(): "resource": None, "timestamp": None, "links": [], + "relations": [], }, { "id": "concepts/rag", @@ -46,10 +49,50 @@ def test_graph_records_keep_only_internal_known_links_and_metadata(): "resource": None, "timestamp": None, "links": ["concepts/pgvector"], + "relations": [], }, ] +def test_graph_records_keep_typed_context_relations_for_known_documents(): + relation = SimpleNamespace( + kind="supported-by", + target="sources/evidence", + context="Primary source", + timestamp="2026-07-25T00:00:00Z", + ) + + records = graph_records([ + concept("concepts/decision", relations=[relation]), + concept("sources/evidence", type="Source"), + ]) + + relation_record = records["documents"][0]["relations"][0] + assert len(relation_record["key"]) == 64 + assert {key: value for key, value in relation_record.items() if key != "key"} == { + "kind": "supported-by", + "target": "sources/evidence", + "context": "Primary source", + "timestamp": "2026-07-25T00:00:00Z", + } + + +def test_relation_keys_keep_distinct_provenance_for_the_same_kind_and_target(): + relations = [ + SimpleNamespace(kind="supported-by", target="sources/evidence", context="Issue", timestamp=None), + SimpleNamespace(kind="supported-by", target="sources/evidence", context="Release notes", timestamp=None), + ] + + records = graph_records([ + concept("concepts/decision", relations=relations), + concept("sources/evidence", type="Source"), + ]) + + projected = records["documents"][0]["relations"] + assert len(projected) == 2 + assert len({relation["key"] for relation in projected}) == 2 + + class RecordingSession: def __init__(self): self.calls = [] @@ -82,8 +125,9 @@ def test_projection_rebuilds_only_its_own_graph_namespace(): query, params = driver.session_instance.calls[0] assert "MATCH (node {graph: $graph}) DETACH DELETE node" in query assert params == {"graph": "knowledge-wiki"} - assert len(driver.session_instance.calls) == 4 + assert len(driver.session_instance.calls) == 5 assert any("[:LINKS_TO {graph: $graph}]" in query for query, _ in driver.session_instance.calls) + assert any("[edge:RELATES_TO {graph: $graph, key: relation.key}]" in query for query, _ in driver.session_instance.calls) def test_projection_graph_uses_neo4j_document_and_link_records(): @@ -99,7 +143,13 @@ def run(self, query, **params): self.calls.append((query, params)) if "RETURN doc.id AS id" in query: return Result([{"id": "concepts/rag", "label": "RAG", "type": "Concept", "tags": ["rag"]}]) - return Result([{"source": "concepts/rag", "target": "concepts/pgvector"}]) + return Result([{ + "source": "concepts/rag", + "target": "concepts/pgvector", + "kind": "links-to", + "context": None, + "timestamp": None, + }]) driver = RecordingDriver() driver.session_instance = ReadSession() @@ -108,7 +158,13 @@ def run(self, query, **params): assert projection.graph() == { "source": "neo4j", "nodes": [{"id": "concepts/rag", "label": "RAG", "type": "Concept", "tags": ["rag"]}], - "edges": [{"source": "concepts/rag", "target": "concepts/pgvector"}], + "edges": [{ + "source": "concepts/rag", + "target": "concepts/pgvector", + "kind": "links-to", + "context": None, + "timestamp": None, + }], } diff --git a/server/tests/test_okf.py b/server/tests/test_okf.py index e704ffc..e0ee9ad 100644 --- a/server/tests/test_okf.py +++ b/server/tests/test_okf.py @@ -1,4 +1,6 @@ -from app.okf import Concept, parse_concept, render_concept +from pathlib import Path + +from app.okf import Concept, load_concepts, parse_concept, parse_relations, render_concept def test_parse_concept_reads_required_type_and_markdown_links(): @@ -29,6 +31,49 @@ def test_parse_concept_ignores_external_markdown_urls(): assert concept.links == [] +def test_parse_concept_reads_existing_wikilink_relations_and_structured_context(): + concept = parse_concept( + "concepts/temporal-metric.md", + """--- +kind: concept +title: Temporal metric +relations: + - "extends [[concepts/temporal-architecture]]" + - kind: supported-by + target: /sources/temporal-issue.md + context: Issue evidence + timestamp: 2026-07-25T14:57:15Z +--- + +# Metric +""", + ) + + assert concept.title == "Temporal metric" + assert concept.type == "Concept" + assert [relation.__dict__ for relation in concept.relations] == [ + {"kind": "extends", "target": "concepts/temporal-architecture", "context": None, "timestamp": None}, + { + "kind": "supported-by", + "target": "sources/temporal-issue", + "context": "Issue evidence", + "timestamp": "2026-07-25T14:57:15+00:00", + }, + ] + + +def test_relation_targets_normalize_parent_segments_but_cannot_escape_the_bundle(): + assert parse_relations("concepts/nested/decision", ["supported-by [[../../sources/evidence.md]]"])[0].target == "sources/evidence" + + for target in ("../../../outside", "https://example.com/evidence", r"sources\evidence"): + try: + parse_relations("concepts/decision", [f"supported-by [[{target}]]"]) + except ValueError: + pass + else: + raise AssertionError(f"Expected unsafe relation target to be rejected: {target}") + + def test_render_concept_emits_okf_frontmatter_and_body(): concept = Concept( id="concepts/rag", @@ -58,3 +103,32 @@ def test_parse_concept_rejects_missing_type(): assert "type" in str(error) else: raise AssertionError("Expected missing type to be rejected") + + +def test_load_concepts_reads_bundle_documents_but_skips_reserved_files(tmp_path: Path): + (tmp_path / "concepts").mkdir() + (tmp_path / "index.md").write_text("# Index\n", encoding="utf-8") + (tmp_path / "log.md").write_text("# Log\n", encoding="utf-8") + (tmp_path / "concepts" / "entry.md").write_text("---\ntype: Concept\ntitle: Entry\n---\n\nBody", encoding="utf-8") + + concepts = load_concepts(tmp_path) + + assert [concept.id for concept in concepts] == ["concepts/entry"] + + +def test_load_concepts_skips_unreadable_numbered_duplicate_when_canonical_exists(tmp_path: Path, monkeypatch): + (tmp_path / "concepts").mkdir() + canonical = tmp_path / "concepts" / "entry.md" + duplicate = tmp_path / "concepts" / "entry 2.md" + canonical.write_text("---\ntype: Concept\ntitle: Entry\n---\n\nBody", encoding="utf-8") + duplicate.write_text("cloud placeholder", encoding="utf-8") + original_read_text = Path.read_text + + def read_text(path, *args, **kwargs): + if path == duplicate: + raise OSError(11, "Resource deadlock avoided") + return original_read_text(path, *args, **kwargs) + + monkeypatch.setattr(Path, "read_text", read_text) + + assert [concept.id for concept in load_concepts(tmp_path)] == ["concepts/entry"] diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index bc256a8..0c0a532 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -49,6 +49,7 @@ test('opens the manual OKF ingestion form', async () => { fireEvent.click(screen.getByRole('button', { name: /문서 추가/i })) expect(await screen.findByLabelText('문서 제목')).toBeInTheDocument() + expect(screen.getByLabelText('맥락 관계')).toHaveAttribute('placeholder', expect.stringContaining('supported-by')) expect(screen.getByLabelText('Markdown 본문')).toBeInTheDocument() }) @@ -116,3 +117,31 @@ test('organizes the sidebar and graph by knowledge domain rather than document t expect(screen.getByText('SYSTEMS', { selector: '.graph-lane-label' })).toBeInTheDocument() expect(screen.getByText('SPRING', { selector: '.graph-lane-label' })).toBeInTheDocument() }) + +test('switches from the domain overview to a focused typed context graph', async () => { + vi.stubGlobal('fetch', vi.fn((url: string) => { + const data = url.includes('/graph') + ? { + nodes: [ + { id: 'concepts/decision', label: 'Architecture decision', type: 'Concept', tags: ['architecture'] }, + { id: 'sources/evidence', label: 'Primary evidence', type: 'Source', tags: ['evidence'] }, + ], + edges: [{ source: 'concepts/decision', target: 'sources/evidence', kind: 'supported-by', context: 'Pinned source', timestamp: null }], + } + : [ + { id: 'concepts/decision', type: 'Concept', title: 'Architecture decision', description: 'Durable context', tags: ['architecture'], body: '# Decision', links: [], relations: [] }, + { id: 'sources/evidence', type: 'Source', title: 'Primary evidence', description: 'Evidence', tags: ['evidence'], body: '# Evidence', links: [], relations: [] }, + ] + return Promise.resolve({ ok: true, json: () => Promise.resolve(data) }) + })) + + render() + fireEvent.click(await screen.findByRole('button', { name: '맥락 그래프 보기' })) + + expect(screen.getByRole('img', { name: '선택 문서 맥락 그래프' })).toBeInTheDocument() + expect(screen.getByText('supported-by · 근거 있음', { selector: '.context-edge-label' })).toBeInTheDocument() + expect(screen.getByText('Primary evidence', { selector: '.context-node-label' })).toBeInTheDocument() + + fireEvent.click(screen.getByText('Primary evidence', { selector: '.context-node-label' })) + expect(screen.getByRole('dialog', { name: 'Primary evidence 상세 문서' })).toBeInTheDocument() +}) diff --git a/web/src/App.tsx b/web/src/App.tsx index f2d67d2..f86f01e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,11 +1,13 @@ import { useEffect, useMemo, useState, type ReactNode } from 'react' +type Relation = { kind: string; target: string; context?: string | null; timestamp?: string | null } +type GraphEdge = { source: string; target: string; kind?: string; context?: string | null; timestamp?: string | null } type Document = { id: string; type: string; title: string; description?: string | null; tags: string[] - resource?: string | null; timestamp?: string | null; body: string; links: string[] + resource?: string | null; timestamp?: string | null; body: string; links: string[]; relations?: Relation[] title_ko?: string | null; description_ko?: string | null; body_ko?: string | null } -type Graph = { source?: 'neo4j' | 'filesystem'; nodes: { id: string; label: string; type: string; tags: string[] }[]; edges: { source: string; target: string }[] } +type Graph = { source?: 'neo4j' | 'filesystem'; nodes: { id: string; label: string; type: string; tags: string[] }[]; edges: GraphEdge[] } const configuredApi = import.meta.env.VITE_API_URL const isLocalBrowser = ['localhost', '127.0.0.1'].includes(window.location.hostname) @@ -82,6 +84,71 @@ function GraphView({ graph, activeId, select }: { graph: Graph; activeId: string } +const relationLabels: Record = { + 'links-to': '링크', + supports: '근거로 지원', + 'supported-by': '근거 있음', + extends: '확장', + 'extended-by': '확장됨', + supersedes: '대체', + 'superseded-by': '대체됨', + 'decided-by': '결정 근거', + 'handoff-to': '인계', + 'related-to': '관련', +} +const relationLabel = (kind?: string) => `${kind ?? 'links-to'} · ${relationLabels[kind ?? 'links-to'] ?? '관계'}` + +function ContextGraph({ graph, activeId, select }: { graph: Graph; activeId: string | null; select: (id: string) => void }) { + const active = graph.nodes.find(node => node.id === activeId) + if (!active) return
문서를 선택하면 직접 연결된 맥락을 보여 줍니다.
+ const incident = graph.edges.filter(edge => edge.source === active.id || edge.target === active.id) + const nodeById = new Map(graph.nodes.map(node => [node.id, node])) + const neighborIds = [...new Set(incident.map(edge => edge.source === active.id ? edge.target : edge.source))] + const visibleIds = neighborIds.slice(0, 12) + const visibleEdges = incident.filter(edge => visibleIds.includes(edge.source === active.id ? edge.target : edge.source)) + const visibleGroups = [...visibleEdges.reduce((groups, edge) => { + const key = `${edge.source}\u0000${edge.target}` + const group = groups.get(key) ?? { source: edge.source, target: edge.target, edges: [] as GraphEdge[] } + group.edges.push(edge); groups.set(key, group) + return groups + }, new Map()).values()] + const positions = new Map([[active.id, { x: 500, y: 180 }]]) + visibleIds.forEach((id, index) => { + const angle = (Math.PI * 2 * index / Math.max(visibleIds.length, 1)) - Math.PI / 2 + positions.set(id, { x: 500 + Math.cos(angle) * 360, y: 180 + Math.sin(angle) * 120 }) + }) + const openNode = (id: string) => { if (nodeById.has(id)) select(id) } + return <> +
+ + + {visibleGroups.map(group => { + const source = positions.get(group.source); const target = positions.get(group.target) + if (!source || !target) return null + const reverse = visibleGroups.some(candidate => candidate.source === group.target && candidate.target === group.source) + const length = Math.hypot(target.x - source.x, target.y - source.y) || 1 + const perpendicular = { x: -(target.y - source.y) / length, y: (target.x - source.x) / length } + const lineOffset = reverse ? 12 : 0 + const labelOffset = reverse ? 72 : 0 + const label = group.edges.length === 1 ? relationLabel(group.edges[0].kind) : `${group.source === active.id ? 'OUT' : 'IN'} ${group.edges.length}` + const kinds = group.edges.map(edge => relationLabel(edge.kind)).join(', ') + return + {kinds} + + {label} + + })} + {visibleIds.map(id => { const node = nodeById.get(id); const point = positions.get(id); if (!node || !point) return null; return openNode(id)} onKeyDown={event => { if (event.key === 'Enter' || event.key === ' ') openNode(id) }}>{node.label.slice(0, 24)} })} + {active.label.slice(0, 26)}선택한 장기기억 + +
+ {incident.length === 0 ?

명시적 관계가 없습니다. 본문 링크나 relations frontmatter를 추가하세요.

:
+ {incident.map((edge, index) => { const outgoing = edge.source === active.id; const neighborId = outgoing ? edge.target : edge.source; const neighbor = nodeById.get(neighborId); return })} + {neighborIds.length > visibleIds.length &&

그래프에는 직접 이웃 12개만 표시했습니다. 관계 목록에는 전체 {incident.length}개가 남습니다.

} +
} + +} + function InlineMarkdown({ text, onInternalLink }: { text: string; onInternalLink?: (id: string) => void }) { const tokens = text.split(/(\[[^\]]+\]\([^)]+\)|\*\*[^*]+\*\*|`[^`]+`)/g) return <>{tokens.map((token, index) => { @@ -150,6 +217,7 @@ function MarkdownView({ body, onInternalLink }: { body: string; onInternalLink?: export default function App() { const [documents, setDocuments] = useState([]) const [graph, setGraph] = useState({ nodes: [], edges: [] }) + const [graphMode, setGraphMode] = useState<'domain' | 'context'>('domain') const [activeId, setActiveId] = useState(null) const [detailId, setDetailId] = useState(null) const [detailLanguage, setDetailLanguage] = useState<'en' | 'ko'>('en') @@ -159,7 +227,7 @@ export default function App() { const [navigationOpen, setNavigationOpen] = useState(false) const [theme, setTheme] = useState<'dark' | 'light'>(() => typeof localStorage !== 'undefined' && typeof localStorage.getItem === 'function' && localStorage.getItem('knowledge-wiki-theme') === 'light' ? 'light' : 'dark') useEffect(() => { document.documentElement.dataset.theme = theme; if (typeof localStorage !== 'undefined' && typeof localStorage.setItem === 'function') localStorage.setItem('knowledge-wiki-theme', theme) }, [theme]) - const [form, setForm] = useState({ type: 'Concept', title: '', description: '', tags: '', resource: '', body: '# 개요\n\n' }) + const [form, setForm] = useState({ type: 'Concept', title: '', description: '', tags: '', resource: '', relations: '', body: '# 개요\n\n' }) useEffect(() => { Promise.all([fetch(`${API}/documents`), fetch(`${API}/graph`)]).then(async ([docs, graphResponse]) => { @@ -171,7 +239,15 @@ export default function App() { async function submitIngest(event: React.FormEvent) { event.preventDefault() - const response = await fetch(`${API}/documents`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...form, tags: form.tags.split(',').map(tag => tag.trim()).filter(Boolean) }) }) + const response = await fetch(`${API}/documents`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...form, + tags: form.tags.split(',').map(tag => tag.trim()).filter(Boolean), + relations: form.relations.split('\n').map(relation => relation.trim()).filter(Boolean), + }), + }) if (!response.ok) { setError('문서 저장에 실패했습니다. 같은 제목의 문서가 있는지 확인하세요.'); return } setShowIngest(false) window.location.reload() @@ -197,11 +273,11 @@ export default function App() {

LOCAL KNOWLEDGE SYSTEM

지식 작업 공간

문서를 탐색하고, 연결을 확인하고, 로컬 지식을 관리하세요.

전체 문서{documents.length}
문서 연결{graph.edges.length}
문서 형식OKF
검색 인덱스준비됨
{error &&
{error}
} -

{graph.source === 'neo4j' ? 'NEO4J · RELATIONSHIP MAP' : 'RELATIONSHIP MAP'}

{graph.source === 'neo4j' ? 'Neo4j 관계 그래프' : '지식 그래프'}

노드 {graph.nodes.length}개
selectDocument(id, true)}/>

{graph.source === 'neo4j' ? 'Markdown 정본에서 재생성한 Neo4j 관계를 도메인별로 묶어 보여 줍니다.' : '노드를 선택하면 읽기 쉬운 상세 문서가 열립니다.'}

+

{graph.source === 'neo4j' ? 'NEO4J · RELATIONSHIP MAP' : 'RELATIONSHIP MAP'}

{graphMode === 'context' ? '장기기억 맥락 그래프' : graph.source === 'neo4j' ? 'Neo4j 관계 그래프' : '지식 그래프'}

{graphMode === 'context' ? `관계 ${graph.edges.length}개` : `노드 ${graph.nodes.length}개`}
{graphMode === 'context' ? selectDocument(id, true)}/> : selectDocument(id, true)}/>}

{graphMode === 'context' ? '화살표는 관계 방향입니다. Markdown 링크와 typed relations를 구분해 표시합니다.' : graph.source === 'neo4j' ? 'Markdown 정본에서 재생성한 Neo4j 관계를 도메인별로 묶어 보여 줍니다.' : '노드를 선택하면 읽기 쉬운 상세 문서가 열립니다.'}

{active ? <>
{typeLabel(active.type)}

{active.title}

{active.description}

{active.tags.map(tag => #{tag})}

본문, 표, 코드, 메타데이터와 연결 문서를 상세 보기에서 읽을 수 있습니다.

{active.id}.md{active.resource && 원문 보기 ↗}
:
아직 문서가 없습니다. 첫 OKF 문서를 추가하세요.
}

BROWSE

문서 목록

setQuery(event.target.value)} />
{filtered.map(doc => )}
{detail &&
setDetailId(null)}>
event.stopPropagation()}>
{typeLabel(detail.type)}

{detailLanguage === 'ko' && detail.title_ko ? detail.title_ko : detail.title}

{detailLanguage === 'ko' && detail.description_ko ? detail.description_ko : detail.description}

{detail.id}.md{detail.timestamp && 갱신 {detail.timestamp}}{detail.resource && 원문 보기 ↗}
{detail.tags.map(tag => #{tag})}
selectDocument(id, true)}/>
{detail.links.length > 0 &&

연결 문서

{detail.links.map(link => { const linked = documents.find(doc => doc.id === link); return })}
}
} - {showIngest &&

CREATE OKF DOCUMENT

문서 추가